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.
Every change funneled through one validating checkpoint.
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.
Compare k.population = -999999 to k.grow_population(-999999) where grow_population validates its input. What is the key difference?
Why is a specific method name like set_capital(new_capital) preferred over a generic update(field, value) method that can change any attribute?
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).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?
Why does "every state change goes through a named method" make debugging easier than uncontrolled direct assignment (from Session 20)?
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."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?
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
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
What you will build
A file called controlled_state.py.
Step-by-step instructions
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
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
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)
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))
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
| File | Action | Why |
|---|---|---|
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. |
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"
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.
Compare k.population = -999999 to k.grow_population(-999999) where grow_population validates its input. What is the key difference?
Why is a specific method name like set_capital(new_capital) preferred over a generic update(field, value) method that can change any attribute?
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).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?
Why does "every state change goes through a named method" make debugging easier than uncontrolled direct assignment (from Session 20)?
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."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?
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.
- 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.
- What would a generic update(field, value) version of set_population and set_capital look like, and why is it worse for validation specifically?
- 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?
- 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.