Layer 3 Session 21 State & Interactivity

Updating State via Methods

Direct attribute assignment lets anyone set invalid state from anywhere. This session channels every state change through named, validating methods instead.

population 54,000,000 changes over time

Every change funneled through one validating checkpoint.

Estimated time: 35โ€“40 minutes  ยท  Pre-quiz โ†’ Concept โ†’ Lab โ†’ Commit โ†’ Post-quiz

1. Learning Objective

By the end of this session you will be able to:

  • Rewrite direct attribute mutation as a named, validating method call
  • Design a method name that clearly communicates what state change it performs
  • Validate a proposed state change before applying it, reusing Session 11's exception patterns
  • Explain why "one method, one clear responsibility" makes state changes traceable
  • Add a second controlled-update method to CountryExplorer for adding a country safely

2. Pre-Coding Quiz

Answer these before reading the concept explanation or writing any code. It is fine to get these wrong โ€” that is the point. You need 4/5 to proceed to the lab.

Question 1 of 5

Compare k.population = -999999 to k.grow_population(-999999) where grow_population validates its input. What is the key difference?

Direct attribute assignment has no opportunity to check the value first. A method is a checkpoint โ€” it can inspect the proposed change and reject it (Session 11's raise) before any bad data is ever stored.
Question 2 of 5

Why is a specific method name like set_capital(new_capital) preferred over a generic update(field, value) method that can change any attribute?

A specific method like set_capital can say, precisely, "capital must be a non-empty string" โ€” a generic update(field, value) either has to special-case every possible field internally (messy) or skip validation entirely (unsafe).
Question 3 of 5

Given a method def set_population(self, value):\n if value < 0:\n raise ValueError(...)\n self.population = value, what happens if you call it with a negative number?

The validation check runs and raises before the assignment line ever executes. This guarantees the object's state can never become invalid through this method โ€” the invalid value is rejected, and the previous valid state is preserved.
Question 4 of 5

Why does "every state change goes through a named method" make debugging easier than uncontrolled direct assignment (from Session 20)?

If population can only change via grow_population() or set_population(), then tracing an unexpected value means searching for calls to those two specific methods โ€” a vastly smaller search space than "anywhere in the codebase that touches .population directly."
Question 5 of 5

You add an add_country(self, country) method to CountryExplorer that validates the argument is a Country instance before appending. Why is this better than callers doing explorer.countries.append(x) directly?

Without a controlled entry point, nothing stops explorer.countries.append("not a country") from corrupting the collection's invariant that every item is a Country instance. A validating method is the checkpoint that guarantees this stays true.

3. The Concept โ€” Controlled State Updates

PROPOSED VALUE-999999VALIDATIONrejected(ValueError)SELF.POPULATIONunchanged

A method acts as a checkpoint โ€” invalid values are rejected before they ever reach the attribute.

From direct assignment to a named method

Session 14 already introduced grow_population as a validating method. This session generalizes that pattern: every state change should go through a method, never a bare attribute assignment from outside the class.

class Country:
    def __init__(self, name, region, population):
        self.name = name
        self.region = region
        self.population = population

    def grow_population(self, amount):
        if amount < 0:
            raise ValueError(f"amount must be non-negative, got {amount}")
        self.population += amount

    def set_population(self, value):
        if value < 0:
            raise ValueError(f"population must be non-negative, got {value}")
        self.population = value  # only reachable if the value passed validation

Naming a method to communicate intent

A generic update(field, value) method either needs complicated per-field logic or skips validation entirely. Specific method names โ€” set_population, grow_population, set_capital โ€” each validate exactly one clear case.

# Avoid: a generic method that has to guess what "field" even means
def update(self, field, value):
    setattr(self, field, value)  # no validation possible here โ€” dangerous

# Prefer: specific, self-documenting, individually validated methods
def set_capital(self, new_capital):
    if not new_capital:
        raise ValueError("capital cannot be empty")
    self.capital = new_capital

Extending the pattern to CountryExplorer

The same discipline applies to the collection-level state from Session 16 โ€” an add_country method guarantees every item in the list is actually a valid Country.

class CountryExplorer:
    def __init__(self, countries):
        self.countries = countries

    def add_country(self, country):
        if not isinstance(country, Country):
            raise TypeError(f"expected a Country instance, got {type(country).__name__}")
        self.countries.append(country)

explorer = CountryExplorer(countries=[])
explorer.add_country(Country(name="Kenya", region="Africa", population=54000000))
print(len(explorer.countries))  # 1

# explorer.add_country("not a country")  # raises TypeError โ€” caught before corrupting the list

Why this actually solves Session 20's risk

Once population can only change through grow_population or set_population, tracing an unexpected value means checking those two call sites โ€” not the entire codebase.


4. Lab

Lab objective: Add set_population, set_capital, and add_country as controlled, validating state-change methods, replacing direct attribute assignment.

What you will build

A file called controlled_state.py.

Step-by-step instructions

1

Create the file with Country including grow_population from Session 14

# controlled_state.py
class Country:
    def __init__(self, name, region, population, capital=None):
        self.name = name
        self.region = region
        self.population = population
        self.capital = capital

    def grow_population(self, amount):
        if amount < 0:
            raise ValueError(f"amount must be non-negative, got {amount}")
        self.population += amount
2

Add set_population and set_capital methods, both validating

class Country:
    def __init__(self, name, region, population, capital=None):
        self.name = name
        self.region = region
        self.population = population
        self.capital = capital

    def grow_population(self, amount):
        if amount < 0:
            raise ValueError(f"amount must be non-negative, got {amount}")
        self.population += amount

    def set_population(self, value):
        if value < 0:
            raise ValueError(f"population must be non-negative, got {value}")
        self.population = value

    def set_capital(self, new_capital):
        if not new_capital:
            raise ValueError("capital cannot be empty")
        self.capital = new_capital
3

Prove invalid changes are rejected and valid state is preserved

k = Country(name="Kenya", region="Africa", population=54000000)

try:
    k.set_population(-5)
except ValueError as e:
    print("Rejected:", e)

print("Population still valid:", k.population)  # unchanged โ€” still 54000000

try:
    k.set_capital("")
except ValueError as e:
    print("Rejected:", e)
4

Add add_country to CountryExplorer with type validation

class CountryExplorer:
    def __init__(self, countries):
        self.countries = countries

    def add_country(self, country):
        if not isinstance(country, Country):
            raise TypeError(f"expected a Country instance, got {type(country).__name__}")
        self.countries.append(country)

explorer = CountryExplorer(countries=[])
explorer.add_country(k)
print(len(explorer.countries))
5

Attempt to add an invalid item and confirm it is rejected

try:
    explorer.add_country("not a country")
except TypeError as e:
    print("Rejected:", e)

print("Collection still valid, length:", len(explorer.countries))  # still 1

5. Expected Files Changed

FileActionWhy
controlled_state.py Created Replaces direct attribute assignment with validating set_population, set_capital, and add_country methods.
docs/sessions/session-21/index.html Created This session document.
If you find yourself editing any other file, stop. This session touches exactly 2 files.

6. Commit Checkpoint

Once the lab is complete and you can explain every line, make this exact commit:

git add controlled_state.py docs/sessions/session-21/index.html
git commit -m "session-21: channel all state changes through validating methods"
Do not commit until you can answer out loud: "Why does the object's state stay valid even after I tried to set an invalid population?"

7. Code Review Checklist

Go through your code line by line and check each item:


8. Post-Coding Quiz

Same 5 questions. Take it again now that you have written and run the code. You need 4/5 to mark this session complete.

Question 1 of 5

Compare k.population = -999999 to k.grow_population(-999999) where grow_population validates its input. What is the key difference?

Direct attribute assignment has no opportunity to check the value first. A method is a checkpoint โ€” it can inspect the proposed change and reject it (Session 11's raise) before any bad data is ever stored.
Question 2 of 5

Why is a specific method name like set_capital(new_capital) preferred over a generic update(field, value) method that can change any attribute?

A specific method like set_capital can say, precisely, "capital must be a non-empty string" โ€” a generic update(field, value) either has to special-case every possible field internally (messy) or skip validation entirely (unsafe).
Question 3 of 5

Given a method def set_population(self, value):\n if value < 0:\n raise ValueError(...)\n self.population = value, what happens if you call it with a negative number?

The validation check runs and raises before the assignment line ever executes. This guarantees the object's state can never become invalid through this method โ€” the invalid value is rejected, and the previous valid state is preserved.
Question 4 of 5

Why does "every state change goes through a named method" make debugging easier than uncontrolled direct assignment (from Session 20)?

If population can only change via grow_population() or set_population(), then tracing an unexpected value means searching for calls to those two specific methods โ€” a vastly smaller search space than "anywhere in the codebase that touches .population directly."
Question 5 of 5

You add an add_country(self, country) method to CountryExplorer that validates the argument is a Country instance before appending. Why is this better than callers doing explorer.countries.append(x) directly?

Without a controlled entry point, nothing stops explorer.countries.append("not a country") from corrupting the collection's invariant that every item is a Country instance. A validating method is the checkpoint that guarantees this stays true.

9. Reflection Questions

Think through these after the post-quiz. No right answer โ€” they are for discussion.

  1. Compare this session's code to Session 20's uncontrolled mutation demo. Walk through, step by step, why the exact same invalid value (-999999 or empty string) is now safely rejected.
  2. What would a generic update(field, value) version of set_population and set_capital look like, and why is it worse for validation specifically?
  3. If a bug report said "population went negative somewhere," how would having these controlled methods change your debugging process compared to Session 20's version?
  4. Is there a state change you can think of for Country or CountryExplorer that this session did not cover, but probably should have its own controlled method?

10. What Breaks If This Knowledge Is Missing?

  • Silent data corruption returns: If even one code path in a large project still assigns .population directly instead of calling set_population, the guarantee this whole session builds is broken โ€” controlled state updates only work if EVERY mutation goes through the checkpoint, with no exceptions.
  • Event handling (Session 22): The next session adds user input as a trigger for these exact methods. Without a solid, validated set_population/set_capital to call INTO, handling user input safely is not possible.
  • Data contracts (Layer 4): Session 30 formalizes exactly this kind of validation using type hints and dataclasses โ€” this session is the hand-rolled version of a discipline that later gets language-level support.

11. What We Learned

Python concept mastered: Controlled state updates โ€” validating, specifically-named methods as the only path to changing an object's state, replacing direct attribute assignment.

Unlocks: Country and CountryExplorer can no longer silently enter an invalid state โ€” every change is validated at a single, traceable checkpoint.

Next session: Session 22 โ€” Handling User Input. We connect these controlled methods to an actual trigger: input typed by a user at the keyboard.