Session 24 of the Python Fundamentals series, covered in depth. It distinguishes methods that read state from methods that change it, and shows one method calling another through self. It then covers encapsulation - keeping internals private by convention with a leading underscore and exposing behavior through methods - and protecting an invariant, such as a balance that must never go negative, by raising a ValueError. It ends with the difference between class attributes, which every instance shares, and instance attributes, of which each object has its own. The traps are forgetting self, bypassing a method and breaking an invariant, and the classic shared-mutable-class-attribute surprise, where one object's append shows up on all of them. Every snippet and error message was executed and copied verbatim from CPython 3.12.
Subject: Python Fundamentals · 97 slides · code lesson
Open the interactive version of this deck · Homework for this lesson
Title
Python Fundamentals - Session 24
Let the object guard its own state - expose behavior, hide the internals
Objectives
You can already write a class with __init__ and attributes. Now you make the object protect its own data. By the end you can:
self._ and expose behavior through methods.ValueError when a rule would break.Warm-up
Discussion prompt
Before we open Session 24 - Methods & Encapsulation: without looking back, what was the main idea of Session 23 - Classes & Objects, and what could you do by the end of it that you could not do before?
Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.
Answer:
Session 23 of the Python Fundamentals series, in depth. Moving from a dict that only holds data to a class that bundles data and behavior together: the class keyword, __init__, self, instance attributes, creating instances, and the fact that each instance carries its own state.
Section
Part 1
Concept
An object holds data in its attributes. A method is a function attached to the class that acts on that object's attributes through self.
state — The current values of an object's attributes. Methods either read the state (leave it alone) or mutate it (change it).
Counterexample
Discussion prompt
An object holds data in its attributes. A method is a function attached to the class that acts on that object's attributes through self.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Concept
A reader looks at the state and hands back a value - it does not change anything. A mutator changes the state and usually returns nothing.
Naming helps: value(), balance(), count() read; increment(), deposit(), add() mutate.
Analogy
Discussion prompt
Explain Two jobs: read or change by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
A reader looks at the state and hands back a value - it does not change anything. A mutator changes the state and usually returns nothing.
Ranking
Put in order
Put the moves of A counter: one reader, one mutator into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. value returns self.count untouched; increment adds 1 to self.count.
Worked example
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
def value(self):
return self.count
c = Counter()
print(c.value())
c.increment()
c.increment()
print(c.value())value() reads, increment() mutates
Why: value returns self.count untouched; increment adds 1 to self.count.
Two increments raise count from 0 to 2
Why: Each call runs on the same object c, so the changes stack up.
Trace c.count as the calls run
Why: Verified by execution: prints 0 then 2.
| call | c.count before | c.count after | prints |
|---|---|---|---|
| c.value() | 0 | 0 | 0 |
| c.increment() | 0 | 1 | - |
| c.increment() | 1 | 2 | - |
| c.value() | 2 | 2 | 2 |
Comparison
Comparison matrix
From A counter: one reader, one mutator: refill the c.count before column from what you know. The rest of the table is as it appeared.
| call | c.count before | c.count after | prints |
|---|---|---|---|
| c.value() | 0 | 0 | 0 |
| c.increment() | 0 | 1 | - |
| c.increment() | 1 | 2 | - |
| c.value() | 2 | 2 | 2 |
Intuition
A reader is a window: you look in and see the value, but looking never changes what is inside. Call it twice and you get the same answer.
A mutator is a lever: pulling it moves something. After you pull it, the next look through the window shows the new value.
Explain it
Discussion prompt
Explain A reader is a window; a mutator is a lever to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
A reader is a window: you look in and see the value, but looking never changes what is inside. Call it twice and you get the same answer.
Pattern
Predict first
The table runs: a.balance() | 50 | 50 · a.balance() | 50 | 50 · a.deposit(10) | 60 | -
In Reading twice does not change anything, given the rows so far: what is the next one — the row where call is a.balance()?
Correct: a.balance() | 60 | 60
| call | _balance | prints |
|---|---|---|
| a.balance() | 50 | 50 |
| a.balance() | 50 | 50 |
| a.deposit(10) | 60 | - |
| a.balance() | 60 | 60 |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. Calling it twice gives 50 both times - nothing moved.
Worked example
class Account:
def __init__(self):
self._balance = 50
def balance(self):
return self._balance
def deposit(self, amount):
self._balance += amount
a = Account()
print(a.balance())
print(a.balance())
a.deposit(10)
print(a.balance())balance() is a pure reader
Why: Calling it twice gives 50 both times - nothing moved.
deposit() is the only thing that changes the number
Why: Verified by execution: 50, 50, then 60.
| call | _balance | prints |
|---|---|---|
| a.balance() | 50 | 50 |
| a.balance() | 50 | 50 |
| a.deposit(10) | 60 | - |
| a.balance() | 60 | 60 |
Trade off
Comparison matrix
From Reading twice does not change anything: every row here is a choice with a cost. Fill the _balance column, then say which row you would actually pick and what you give up for it.
| call | _balance | prints |
|---|---|---|
| a.balance() | 50 | 50 |
| a.balance() | 50 | 50 |
| a.deposit(10) | 60 | - |
| a.balance() | 60 | 60 |
Concept
A method with no return hands back None, just like any function. Mutators such as increment() change state for their effect, not for a value.
So x = c.increment() puts None in x. Read the new value with a separate reader call.
Section
Part 2
Concept
Inside a method, self is the object the method was called on. self.attr reads its data; self.other() calls another of its methods.
self — The first parameter of every method - the instance the method runs on. You never pass it by hand; obj.method() fills it in automatically.
Definition probe
Sort into buckets
Every line below is part of the definition of state or of self — one or the other, never both. Put each where it belongs.
Concept
When you call r.area(), Python quietly passes r as self. The object before the dot becomes the first argument.
So the definition def area(self): has one parameter, but the call r.area() looks empty - the object is the hidden first argument.
Fill the middle
Fill in the blanks
From One method uses another — one line has had its right-hand side removed. Put it back.
class Rectangle:
def __init__(self, w, h):
self.w = w
self.h = h
def area(self):
return self.w * self.h
def describe(self):
return "area is " + str(self.area())
r = Rectangle(3, 4)
print(r.area())
print(r.describe())
Why: r is what everything below it consumes, so the wrong expression here fails later and somewhere else. Instead of repeating self.w * self.h, describe reuses the area method through self.
Worked example
class Rectangle:
def __init__(self, w, h):
self.w = w
self.h = h
def area(self):
return self.w * self.h
def describe(self):
return "area is " + str(self.area())
r = Rectangle(3, 4)
print(r.area())
print(r.describe())describe() calls self.area()
Why: Instead of repeating self.w * self.h, describe reuses the area method through self.
Both calls run on the same rectangle r
Why: Verified by execution: 12, then area is 12.
| call | self.area() | returns |
|---|---|---|
| r.area() | 12 | 12 |
| r.describe() | 12 | area is 12 |
Error analysis
Annotate
Walk the callouts on One method uses another. Each one is a place this is easy to get subtly wrong.
Concept
A mutator can call other mutators through self. This keeps each method small and lets one rule live in one place.
If deposit already knows how to add money, a fancier method can just call self.deposit(...) instead of touching the balance itself.
Worked example
class Account:
def __init__(self):
self._balance = 0
def deposit(self, amount):
self._balance += amount
def deposit_with_bonus(self, amount):
self.deposit(amount)
self.deposit(1)
def balance(self):
return self._balance
a = Account()
a.deposit_with_bonus(100)
print(a.balance())deposit_with_bonus reuses deposit
Why: It adds the amount, then a 1-unit bonus - both through self.deposit.
Two deposits leave 101
Why: Verified by execution: 100 + 1 = 101.
| step | _balance |
|---|---|
| start | 0 |
| self.deposit(100) | 100 |
| self.deposit(1) | 101 |
Blank canvas
Draw it
Draw what A method that calls deposit twice just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Worked example
class Account:
def __init__(self):
self._balance = 0
def _check(self, amount):
if amount <= 0:
raise ValueError("amount must be positive")
def deposit(self, amount):
self._check(amount)
self._balance += amount
def balance(self):
return self._balance
a = Account()
a.deposit(40)
print(a.balance())_check is an internal helper
Why: The leading underscore says 'callers should not use this directly'. deposit runs it first.
A valid deposit passes the check
Why: Verified by execution: 40 is positive, so balance becomes 40.
| call | _check result | _balance |
|---|---|---|
| a.deposit(40) | passes | 40 |
| a.balance() | - | 40 |
Anomaly
Predict first
A student writes this, and it looks reasonable:
Referring to an attribute by its bare name inside a method.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: They live on the object as self.w and self.h.
Reach the object's data through self.
Why: They live on the object as self.w and self.h. Bare w has no meaning here, so Python raises NameError.
Trap
Referring to an attribute by its bare name inside a method.
class Rectangle:
def __init__(self, w, h):
self.w = w
self.h = h
def area(self):
return w * h
r = Rectangle(3, 4)
print(r.area())w and h are not plain variables
Why: They live on the object as self.w and self.h. Bare w has no meaning here, so Python raises NameError.
| you write | result |
|---|---|
| return w * h | NameError: name 'w' is not defined. Did you mean: 'self.w'? |
Reach the object's data through self.
class Rectangle:
def __init__(self, w, h):
self.w = w
self.h = h
def area(self):
return self.w * self.h
r = Rectangle(3, 4)
print(r.area())self.w and self.h read the instance
Why: Now the method finds the stored values. Real output: 12. Attributes always need the self. prefix inside a method.
| you write | prints |
|---|---|
| return self.w * self.h | 12 |
Section
Part 3
Concept
Encapsulation means the object keeps its data to itself and offers methods to work with it. Callers ask the object to do things, not poke its raw fields.
encapsulation — Bundling data with the methods that manage it, and keeping the data behind those methods so every change goes through a controlled path.
Concept
Python has no truly private attributes. Instead, a single leading underscore - _balance - is a convention meaning 'this is internal; do not touch it from outside'.
Nothing stops you technically, but the underscore is a clear signal to every reader (including future you).
Concept
Keep _balance internal and give callers deposit(), withdraw(), and balance(). They describe what you can do, not how it is stored.
Now every change funnels through a method - the one place you can add rules and checks.
Sorting
Sort into buckets
These are the pieces of Session 24 - Methods & Encapsulation, out of order. Put each one back under the part of the lesson it belongs to.
Faded example
Fill in the blanks
An encapsulated account, with the scaffolding fading: two lines are gone now — fill both.
class Account:
def __init__(self, balance):
self._balance = balance
def deposit(self, amount):
self._balance += amount
def balance(self):
return self._balance
a = Account(100)
a.deposit(50)
print(a.balance())
Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. Callers use deposit to change money and balance to read it - not the underscore field.
Worked example
class Account:
def __init__(self, balance):
self._balance = balance
def deposit(self, amount):
self._balance += amount
def balance(self):
return self._balance
a = Account(100)
a.deposit(50)
print(a.balance())_balance is internal; deposit and balance are the door
Why: Callers use deposit to change money and balance to read it - not the underscore field.
Deposit then read
Why: Verified by execution: 100 + 50 = 150.
| call | _balance | prints |
|---|---|---|
| Account(100) | 100 | - |
| a.deposit(50) | 150 | - |
| a.balance() | 150 | 150 |
Pattern
Step through it
Step through An encapsulated account one row at a time. What is driving the change, and what would the row after the last one be?
Intuition
An open cash drawer lets anyone take or add money with no record - that is exposing _balance directly.
An ATM only offers buttons: deposit, withdraw, check balance. Every action goes through a slot that can enforce rules. Encapsulation is giving your object an ATM face instead of an open drawer.
Worked example
class TodoList:
def __init__(self):
self._tasks = []
def add(self, task):
self._tasks.append(task)
def count(self):
return len(self._tasks)
t = TodoList()
t.add("wash car")
t.add("study")
print(t.count())_tasks is internal; add and count are the interface
Why: Callers add tasks and ask for a count; they never reach into the list themselves.
Two adds give a count of 2
Why: Verified by execution: prints 2.
| call | _tasks | count() |
|---|---|---|
| t.add("wash car") | ['wash car'] | - |
| t.add("study") | ['wash car', 'study'] | - |
| t.count() | ['wash car', 'study'] | 2 |
Section
Part 4
Concept
An invariant is a promise about the object's state that should be true at all times - for an account, _balance should never go negative.
invariant — A condition an object promises to keep true. Methods must never leave the object in a state that breaks it.
Concept
Because every change goes through a method, that method can check the rule before changing state and refuse when the rule would break.
The tool for 'this value is not allowed' is raise ValueError(...) - it stops the bad change and reports why.
Worked example
class Account:
def __init__(self, balance):
self._balance = balance
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("insufficient funds")
self._balance -= amount
def balance(self):
return self._balance
a = Account(100)
a.withdraw(30)
print(a.balance())Check before you change
Why: If amount is more than the balance, raise and stop - the subtraction never runs.
A valid withdrawal goes through
Why: Verified by execution: 30 <= 100, so balance drops to 70.
| call | amount > balance? | _balance |
|---|---|---|
| Account(100) | - | 100 |
| a.withdraw(30) | no | 70 |
| a.balance() | - | 70 |
Pattern
Step through it
Step through withdraw that respects the balance one row at a time. What is driving the change, and what would the row after the last one be?
Worked example
class Account:
def __init__(self, balance):
self._balance = balance
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("insufficient funds")
self._balance -= amount
a = Account(100)
a.withdraw(150)150 > 100, so the guard fires
Why: raise stops the method immediately; _balance is never touched.
Read the exact error
Why: Verified by execution: the program stops with the traceback below.
| call | result |
|---|---|
| a.withdraw(150) | raises, _balance stays 100 |
| last line | ValueError: insufficient funds |
Comparison
Comparison matrix
From Withdrawing too much is refused: refill the result column from what you know. The rest of the table is as it appeared.
| call | result |
|---|---|
| a.withdraw(150) | raises, _balance stays 100 |
| last line | ValueError: insufficient funds |
Fill the middle
Fill in the blanks
From A refused withdrawal leaves state intact — one line has had its right-hand side removed. Put it back.
class Account:
def __init__(self, balance):
self._balance = balance
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("insufficient funds")
self._balance -= amount
def balance(self):
return self._balance
a = Account(50)
try:
a.withdraw(80)
except ValueError:
print("blocked")
print(a.balance())
Why: a is what everything below it consumes, so the wrong expression here fails later and somewhere else. So the invariant holds: the balance was never lowered.
Worked example
class Account:
def __init__(self, balance):
self._balance = balance
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("insufficient funds")
self._balance -= amount
def balance(self):
return self._balance
a = Account(50)
try:
a.withdraw(80)
except ValueError:
print("blocked")
print(a.balance())raise happens before the subtraction
Why: So the invariant holds: the balance was never lowered.
Catch it and confirm the balance
Why: Verified by execution: prints blocked, then 50 - untouched.
| step | _balance | prints |
|---|---|---|
| Account(50) | 50 | - |
| a.withdraw(80) | 50 | blocked |
| a.balance() | 50 | 50 |
Invariant
Step through it
Step through A refused withdrawal leaves state intact one row at a time. One of these columns never changes — find it, and say why it cannot.
Anomaly
Predict first
A student writes this, and it looks reasonable:
Reaching past the method and setting the internal field yourself.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: Writing a._balance directly skips withdraw's check, so nothing stops a negative balance.
Go through the method so the guard runs.
Why: Writing a._balance directly skips withdraw's check, so nothing stops a negative balance. The invariant is silently broken - no error, just bad data.
Trap
Reaching past the method and setting the internal field yourself.
a = Account(100)
a._balance = -500
print(a.balance())The underscore is a signal, not a lock
Why: Writing a._balance directly skips withdraw's check, so nothing stops a negative balance. The invariant is silently broken - no error, just bad data.
| you write | _balance | prints |
|---|---|---|
| a._balance = -500 | -500 | -500 |
Go through the method so the guard runs.
a = Account(100)
a.withdraw(500)withdraw refuses the impossible amount
Why: 500 > 100, so it raises instead of corrupting the balance. Real result: ValueError: insufficient funds, and _balance stays 100. Respect the underscore - always use the method.
| you write | _balance | result |
|---|---|---|
| a.withdraw(500) | 100 | ValueError: insufficient funds |
Worked example
class Account:
def __init__(self, balance):
self._balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("amount must be positive")
self._balance += amount
a = Account(100)
a.deposit(-20)A deposit must be positive
Why: amount <= 0 is not allowed, so the guard raises before adding.
Read the exact error
Why: Verified by execution: -20 fails the check and the program stops.
| call | result |
|---|---|
| a.deposit(-20) | raises, _balance stays 100 |
| last line | ValueError: amount must be positive |
Section
Part 5
Concept
An attribute you set with self.name = ... inside __init__ belongs to that one object. Each instance gets its own copy.
instance attribute — A value stored on a single object (self.name). Two instances can hold different values for the same attribute name.
Matching
Match the pairs
Match each term to the definition this lesson gave it — not the one you would guess from the word.
Why: These are the working definitions of state, self, encapsulation, invariant, instance attribute as Session 24 - Methods & Encapsulation uses them. Pairing them correctly is the test of whether you could state each one with the slide switched off.
Concept
An attribute defined in the class body - outside any method - is a class attribute. Every instance sees the same one.
class attribute — A value stored on the class itself, shared by every instance. Good for constants and shared defaults - risky when it is mutable.
Worked example
class Dog:
species = "Canis familiaris"
def __init__(self, name):
self.name = name
a = Dog("Rex")
b = Dog("Fido")
print(a.species)
print(b.species)
print(a.name)
print(b.name)species is shared; name is per-dog
Why: species is defined in the class body; name is set on self in __init__.
Both dogs share species, differ in name
Why: Verified by execution: Canis familiaris twice, then Rex and Fido.
| object | species | name |
|---|---|---|
| a (Rex) | Canis familiaris | Rex |
| b (Fido) | Canis familiaris | Fido |
Worked example
class Dog:
legs = 4
a = Dog()
b = Dog()
print(a.legs, b.legs)
Dog.legs = 3
print(a.legs, b.legs)legs lives on the class
Why: a and b do not have their own legs; they both look it up on Dog.
One change updates every instance
Why: Verified by execution: 4 4, then 3 3.
| step | a.legs | b.legs |
|---|---|---|
| start | 4 | 4 |
| Dog.legs = 3 | 3 | 3 |
Blank canvas
Draw it
Draw what Change the class attribute, all see it just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Worked example
class Robot:
count = 0
def __init__(self, name):
self.name = name
Robot.count += 1
a = Robot("R1")
b = Robot("R2")
c = Robot("R3")
print(Robot.count)
print(a.count)Each __init__ bumps the shared count
Why: Robot.count is one number for the whole class; every new robot adds 1.
The count reads the same from anywhere
Why: Verified by execution: 3, then 3 (a.count finds the class attribute).
| event | Robot.count |
|---|---|
| Robot("R1") | 1 |
| Robot("R2") | 2 |
| Robot("R3") | 3 |
Pattern
Step through it
Step through A shared counter of instances one row at a time. What is driving the change, and what would the row after the last one be?
Intuition
A class attribute is one bulletin board on the wall. Everyone reads the same board; change it and the change is visible to all.
An instance attribute is each person's own notebook. Writing in yours never touches anyone else's.
Concept
Reading a.legs falls back to the class if the instance has none. But assigning a.legs = 3 creates a new instance attribute that shadows the class one - for a only.
So an assignment through one instance does not change the class attribute or the other instances.
Concept
When you read a.legs, Python checks the instance first. If the object has no legs of its own, it falls back to the class.
That fallback is why every Dog can share species without storing it - and why assigning self.x later can quietly shadow a class attribute.
Explain it
Discussion prompt
Explain How Python looks an attribute up to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
When you read a.legs, Python checks the instance first. If the object has no legs of its own, it falls back to the class.
Section
Part 6
Concept
If a class attribute is a list (or dict, or set), every instance shares the same list object. Appending through one instance shows up on all of them.
This bites people because it looks like each object should have its own list - but nothing made a separate one.
Analogy
Discussion prompt
Explain A mutable class attribute is shared, too by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
If a class attribute is a list (or dict, or set), every instance shares the same list object. Appending through one instance shows up on all of them.
Faded example
Fill in the blanks
The surprise: one append, two objects, with the scaffolding fading: two lines are gone now — fill both.
class Basket:
items = []
def add(self, item):
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
print(a.items)
print(b.items)
Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. a and b never get their own; self.items.append mutates the shared list in place - no assignment, so no instance copy is made.
Worked example
class Basket:
items = []
def add(self, item):
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
print(a.items)
print(b.items)items is one list on the class
Why: a and b never get their own; self.items.append mutates the shared list in place - no assignment, so no instance copy is made.
b.items shows apple too
Why: Verified by execution: ['apple'] twice - b got a's apple, the classic surprise.
| step | a.items | b.items |
|---|---|---|
| start | [] | [] |
| a.add("apple") | ['apple'] | ['apple'] |
Error analysis
Annotate
Walk the callouts on The surprise: one append, two objects. Each one is a place this is easy to get subtly wrong.
Anomaly
Predict first
A student writes this, and it looks reasonable:
Putting the list in the class body, where all instances share it.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: There is only one items list, shared by every Basket.
Create the list per object, inside __init__.
Why: There is only one items list, shared by every Basket. a.add mutates it, so b.items is not empty. Leaky, hard-to-find bug.
Trap
Putting the list in the class body, where all instances share it.
class Basket:
items = []
def add(self, item):
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
print(b.items)b sees a's item
Why: There is only one items list, shared by every Basket. a.add mutates it, so b.items is not empty. Leaky, hard-to-find bug.
| output | |
|---|---|
| print(b.items) | ['apple'] |
Create the list per object, inside __init__.
class Basket:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
print(b.items)Each Basket gets its own list
Why: self.items = [] runs once per object, making a fresh list each time. Real output: []. Rule: mutable per-object state belongs in __init__, never the class body.
| output | |
|---|---|
| print(a.items) | ['apple'] |
| print(b.items) | [] |
Two truths and a lie
Sort into buckets
Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.
self.; A reader looks at the state and hands back a value - it does not change anything. A mutator changes the state and usually returns nothing.; A reader is a window: you look in and see the value, but looking never changes what is inside. Call it twice and you get the same answer.Pattern
Predict first
The table runs: a.items | ['apple', 'bread'] · b.items | ['apple', 'bread']
In Both baskets pour into one list, given the rows so far: what is the next one — the row where print is Basket.items?
Correct: Basket.items | ['apple', 'bread']
| output | |
|---|---|
| a.items | ['apple', 'bread'] |
| b.items | ['apple', 'bread'] |
| Basket.items | ['apple', 'bread'] |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. a and b both mutate Basket.items, so all three prints show both items.
Worked example
class Basket:
items = []
def add(self, item):
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
b.add("bread")
print(a.items)
print(b.items)
print(Basket.items)Every add lands in the same list
Why: a and b both mutate Basket.items, so all three prints show both items.
One list, three views of it
Why: Verified by execution: ['apple', 'bread'] printed three times.
| output | |
|---|---|
| a.items | ['apple', 'bread'] |
| b.items | ['apple', 'bread'] |
| Basket.items | ['apple', 'bread'] |
Trade off
Comparison matrix
From Both baskets pour into one list: every row here is a choice with a cost. Fill the output column, then say which row you would actually pick and what you give up for it.
| output | |
|---|---|
| a.items | ['apple', 'bread'] |
| b.items | ['apple', 'bread'] |
| Basket.items | ['apple', 'bread'] |
Fill the middle
Fill in the blanks
From The fix keeps them independent — one line has had its right-hand side removed. Put it back.
class Team:
def __init__(self, name):
self.name = name
self.members = []
def add(self, m):
self.members.append(m)
a = Team("Red")
b = Team("Blue")
a.add("Sam")
print(a.members)
print(b.members)
Why: a is what everything below it consumes, so the wrong expression here fails later and somewhere else. Each Team gets a brand-new list, so a's roster is separate from b's.
Worked example
class Team:
def __init__(self, name):
self.name = name
self.members = []
def add(self, m):
self.members.append(m)
a = Team("Red")
b = Team("Blue")
a.add("Sam")
print(a.members)
print(b.members)members is created per team in __init__
Why: Each Team gets a brand-new list, so a's roster is separate from b's.
Adding to a leaves b empty
Why: Verified by execution: ['Sam'] then [].
| step | a.members | b.members |
|---|---|---|
| start | [] | [] |
| a.add("Sam") | ['Sam'] | [] |
Comparison
Comparison matrix
From The fix keeps them independent: refill the a.members column from what you know. The rest of the table is as it appeared.
| step | a.members | b.members |
|---|---|---|
| start | [] | [] |
| a.add("Sam") | ['Sam'] | [] |
Concept
Class body: constants and immutable shared defaults (species = "...", a shared counter you reassign). Per-object mutable state (lists, dicts, sets): make it in __init__ with self.
If two fresh objects should not share it, it does not belong in the class body.
Counterexample
Discussion prompt
Class body: constants and immutable shared defaults (species = "...", a shared counter you reassign). Per-object mutable state (lists, dicts, sets): make it in __init__ with self.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Answer:
If two fresh objects should not share it, it does not belong in the class body.
Section
Part 7
Pattern
1. Store per-object state in __init__ with self, and mark internals with a leading underscore
Why: self.<x> = ... gives each object its own copy; the _ signals 'do not touch from outside'.
2. Expose behavior as methods, not raw attributes
Why: deposit / withdraw / balance describe what callers may do; the internals stay hidden behind them.
3. Guard invariants inside the mutators
Why: Check the rule before changing state; raise ValueError when the change is not allowed.
4. Let methods reuse methods through self
Why: One rule lives in one place; other methods call self.that_method() instead of copying it.
Pattern
Reader: returns a value, changes nothing
Why: balance(), value(), count(). Safe to call as often as you like; the answer is stable.
Mutator: changes state, usually returns None
Why: deposit(), add(), increment(). Call it for its effect, then read with a separate reader.
Never expect a value out of a mutator
Why: x = obj.increment() puts None in x - that is the most common slip.
Pattern
Same for every object and immutable? Class body
Why: Constants and shared defaults: species = "...", legs = 4.
Different per object, or mutable? __init__ with self
Why: Names, balances, and any list/dict/set each object owns must be created per instance.
When unsure, prefer __init__
Why: A shared mutable in the class body is the classic leak; per-object is almost always what you meant.
Real world
Discussion prompt
Outside this lesson: where does Session 24 - Methods & Encapsulation actually turn up? Name one concrete situation — a job, a piece of software someone ships, a decision somebody has to make — and say which part of Class attribute or instance attribute? is doing the work in it.
Hint: Vague is the failure mode here. "Engineering" is not a situation; "deciding whether this build is fast enough to ship" is.
Answer:
Session 24 of the Python Fundamentals series, in depth. Methods that read state versus methods that change it; one method calling another through self; encapsulation - keeping internals private-by-convention with a leading underscore and exposing behavior through methods; protecting an invariant (a balance that must never go negative) by raising ValueError; and the difference between class attributes (shared by every instance) and instance attributes (one per object).
Check
Trace the counter.
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
def value(self):
return self.count
c = Counter()
c.increment()
c.increment()
c.increment()
print(c.value())| increments | c.count |
|---|---|
| 3 | ? |
Check your understanding
What does this print?
Answer: A
Why: increment() adds 1 to self.count each call; three calls make count 3, and value() returns it. Verified by execution.
Check
describe() leans on area().
class Box:
def __init__(self, w, h):
self.w = w
self.h = h
def area(self):
return self.w * self.h
def describe(self):
return "area is " + str(self.area())
print(Box(5, 2).describe())| self.area() | describe() |
|---|---|
| 10 | ? |
Check your understanding
What does this print?
Answer: A
Why: describe() calls self.area(), which returns 5 * 2 = 10, then builds the string. Verified by execution.
Check
items is in the class body.
class Basket:
items = []
def add(self, item):
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
print(b.items)| a.add | b.items |
|---|---|
| apple | ? |
Check your understanding
What does print(b.items) show?
Answer: A
Why: items is one list shared by every Basket. a.add appends to that shared list in place, so b.items sees the apple. Verified by execution.
Check
Now the list is created in __init__.
class Basket:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
print(b.items)| a.add | b.items |
|---|---|
| apple | ? |
Check your understanding
What does print(b.items) show now?
Answer: A
Why: self.items = [] runs once per object, so a and b each own a separate list; adding to a leaves b empty. Verified by execution.
Check
The withdrawal is too big.
class Account:
def __init__(self, balance):
self._balance = balance
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("insufficient funds")
self._balance -= amount
def balance(self):
return self._balance
a = Account(50)
try:
a.withdraw(80)
except ValueError:
print("blocked")
print(a.balance())| withdraw(80) | _balance |
|---|---|
| blocked | ? |
Check your understanding
What are the two lines of output?
Answer: A
Why: 80 > 50 so withdraw raises before subtracting; except prints blocked, and the balance was never lowered, so it stays 50. Verified by execution.
Check
One assignment to the class.
class Dog:
legs = 4
a = Dog()
b = Dog()
Dog.legs = 3
print(a.legs, b.legs)| Dog.legs | a.legs / b.legs |
|---|---|
| 3 | ? |
Check your understanding
What does this print?
Answer: A
Why: legs is a class attribute, so a and b both look it up on Dog; changing Dog.legs updates the value both see. Verified by execution.
Check
This time the assignment is on an instance.
class Dog:
legs = 4
a = Dog()
b = Dog()
a.legs = 3
print(a.legs, b.legs, Dog.legs)| a.legs = 3 | b.legs / Dog.legs |
|---|---|
| 3 | ? |
Check your understanding
What does this print?
Answer: A
Why: Assigning a.legs = 3 creates an instance attribute on a that shadows the class one; b and Dog still see the original 4. Verified by execution.
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — Reading vs Mutating State · Methods Calling Methods · Encapsulation · Protecting an Invariant · Class vs Instance Attributes · The Shared-Mutable Trap. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
A method reads or mutates one object's state through self, and methods can call each other. Encapsulation hides internals behind _names and exposes behavior; a mutator can guard an invariant with raise ValueError.
| You write | It means |
|---|---|
| def balance(self): return self._balance | a reader - hands a value back, changes nothing |
| def deposit(self, n): self._balance += n | a mutator - changes state, returns None |
| self.other() | call another method on the same object |
| _balance | internal by convention - reach it only through methods |
| raise ValueError(...) | refuse a change that would break an invariant |
| legs = 4 (class body) | class attribute - shared by every instance |
| self.items = [] (in __init__) | instance attribute - a fresh list per object |
Put mutable per-object state in __init__, never the class body - that one rule kills the shared-list surprise. Next session we build on this with inheritance: classes that reuse and extend other classes.
Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.