Session 24 - Methods & Encapsulation

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

What this lesson covers

The lesson, slide by slide

1. Methods & Encapsulation

Title

Python Fundamentals - Session 24

Let the object guard its own state - expose behavior, hide the internals

2. What you will be able to do

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:

  1. Tell a reader method from a mutator method (one looks, one changes).
  2. Call one method from another through self.
  3. Encapsulate: mark internals with a leading _ and expose behavior through methods.
  1. Protect an invariant by raising ValueError when a rule would break.
  2. Explain the difference between a class attribute (shared) and an instance attribute (per-object).
  3. Avoid the shared-mutable-class-attribute trap that leaks one object's list onto all of them.

3. What survived from Session 23 - Classes & Objects?

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.

4. Reading vs Mutating State

Section

Part 1

5. Every method works on one object's state

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).

6. Break it if you can: Every method works on one object's state

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.

7. Two jobs: read or change

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.

8. By analogy: Two jobs: read or change

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.

9. What has to happen first: A counter: one reader, one mutator

Ranking

Put in order

Put the moves of A counter: one reader, one mutator into the order they have to happen.

  1. value() reads, increment() mutates
  2. Two increments raise count from 0 to 2
  3. Trace c.count as the calls run

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.

10. A counter: one reader, one mutator

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.

callc.count beforec.count afterprints
c.value()000
c.increment()01-
c.increment()12-
c.value()222

11. Fill in: c.count before for A counter: one reader, one mutator

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.

callc.count beforec.count afterprints
c.value()000
c.increment()01-
c.increment()12-
c.value()222

12. A reader is a window; a mutator is a lever

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.

13. Teach it back: A reader is a window; a mutator is a lever

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.

14. Predict the next row: Reading twice does not change anything

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_balanceprints
a.balance()5050
a.balance()5050
a.deposit(10)60-
a.balance()6060

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.

15. Reading twice does not change anything

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_balanceprints
a.balance()5050
a.balance()5050
a.deposit(10)60-
a.balance()6060

16. What each one costs: Reading twice does not change anything

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_balanceprints
a.balance()5050
a.balance()5050
a.deposit(10)60-
a.balance()6060

17. A mutator usually returns None

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.

18. Methods Calling Methods

Section

Part 2

19. self reaches back to the same object

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.

20. Take the definitions apart: state vs self

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.

state
The current values of an object's attributes.; Methods either read the state (leave it alone) or mutate it (change it).
self
The first parameter of every method - the instance the method runs on.; obj.method() fills it in automatically.
b1
The current values of an object's attributes. Methods either read the state (leave it alone) or mutate it (change it).
b2
The first parameter of every method - the instance the method runs on. You never pass it by hand; obj.method() fills it in automatically.

21. You never pass self by hand

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.

22. Restore the missing line: One method uses another

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.

23. One method uses another

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.

callself.area()returns
r.area()1212
r.describe()12area is 12

24. Inspect it line by line: One method uses another

Error analysis

Annotate

Walk the callouts on One method uses another. Each one is a place this is easy to get subtly wrong.

  • Instead of repeating self.w * self.h, describe reuses the area method through self.
  • Verified by execution: 12, then area is 12.

25. Build big behavior from small methods

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.

26. A method that calls deposit twice

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
start0
self.deposit(100)100
self.deposit(1)101

27. Draw the shape of it: A method that calls deposit twice

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.

28. A private helper method

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)passes40
a.balance()-40

29. Something is wrong here: forgetting self

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.

30. Trap: forgetting self

Trap

The 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 writeresult
return w * hNameError: name 'w' is not defined. Did you mean: 'self.w'?

The fix

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 writeprints
return self.w * self.h12

31. Encapsulation

Section

Part 3

32. Hide the internals, expose the behavior

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.

33. A leading underscore says 'internal'

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).

34. Expose behavior, not attributes

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.

35. Where does each piece belong: Session 24 - Methods & Encapsulation

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.

Reading vs Mutating State
Every method works on one object's state; Two jobs: read or change; A counter: one reader, one mutator
Methods Calling Methods
self reaches back to the same object; You never pass self by hand; One method uses another
Encapsulation
Hide the internals, expose the behavior; A leading underscore says 'internal'; Expose behavior, not attributes
s1
Reading vs Mutating State is where Session 24 - Methods & Encapsulation puts Every method works on one object's state, Two jobs: read or change, A counter: one reader, one mutator. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s2
Methods Calling Methods is where Session 24 - Methods & Encapsulation puts self reaches back to the same object, You never pass self by hand, One method uses another. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s3
Encapsulation is where Session 24 - Methods & Encapsulation puts Hide the internals, expose the behavior, A leading underscore says 'internal', Expose behavior, not attributes. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.

36. Finish it with less help: An encapsulated account

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.

37. An encapsulated account

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_balanceprints
Account(100)100-
a.deposit(50)150-
a.balance()150150

38. Watch it run: An encapsulated account

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?

  1. Step 1: call is Account(100)
  2. Step 2: call is a.deposit(50)
  3. Step 3: call is a.balance()

39. Think of an ATM, not an open drawer

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.

40. A to-do list that guards its tasks

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_taskscount()
t.add("wash car")['wash car']-
t.add("study")['wash car', 'study']-
t.count()['wash car', 'study']2

41. Protecting an Invariant

Section

Part 4

42. An invariant is a rule that must always hold

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.

43. Guard the invariant inside the method

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.

44. withdraw that respects the balance

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.

callamount > balance?_balance
Account(100)-100
a.withdraw(30)no70
a.balance()-70

45. Watch it run: withdraw that respects the balance

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?

  1. Step 1: call is Account(100)
  2. Step 2: call is a.withdraw(30)
  3. Step 3: call is a.balance()

46. Withdrawing too much is refused

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.

callresult
a.withdraw(150)raises, _balance stays 100
last lineValueError: insufficient funds

47. Fill in: result for Withdrawing too much is refused

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.

callresult
a.withdraw(150)raises, _balance stays 100
last lineValueError: insufficient funds

48. Restore the missing line: A refused withdrawal leaves state intact

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.

49. A refused withdrawal leaves state intact

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_balanceprints
Account(50)50-
a.withdraw(80)50blocked
a.balance()5050

50. What stays fixed: A refused withdrawal leaves state intact

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.

  1. Step 1: step is Account(50)
  2. Step 2: step is a.withdraw(80)
  3. Step 3: step is a.balance()

51. Something is wrong here: bypassing the method

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.

52. Trap: bypassing the method

Trap

The 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_balanceprints
a._balance = -500-500-500

The fix

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_balanceresult
a.withdraw(500)100ValueError: insufficient funds

53. Reject a negative deposit too

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.

callresult
a.deposit(-20)raises, _balance stays 100
last lineValueError: amount must be positive

54. Class vs Instance Attributes

Section

Part 5

55. Instance attributes are per-object

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.

56. Term to definition: Session 24 - Methods & Encapsulation

Matching

Match the pairs

Match each term to the definition this lesson gave it — not the one you would guess from the word.

  • t1. state
  • t2. self
  • t3. encapsulation
  • t4. invariant
  • t5. instance attribute
  • d1. The current values of an object's attributes. Methods either read the state (leave it alone) or mutate it (change it).
  • d2. The first parameter of every method - the instance the method runs on. You never pass it by hand; obj.method() fills it in automatically.
  • d3. Bundling data with the methods that manage it, and keeping the data behind those methods so every change goes through a controlled path.
  • d4. A condition an object promises to keep true. Methods must never leave the object in a state that breaks it.
  • d5. A value stored on a single object (self.name). Two instances can hold different values for the same attribute name.

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.

57. Class attributes are shared by all

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.

58. Shared species, private name

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.

objectspeciesname
a (Rex)Canis familiarisRex
b (Fido)Canis familiarisFido

59. Change the class attribute, all see it

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.

stepa.legsb.legs
start44
Dog.legs = 333

60. Draw the shape of it: Change the class attribute, all see it

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.

61. A shared counter of instances

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).

eventRobot.count
Robot("R1")1
Robot("R2")2
Robot("R3")3

62. Watch it run: A shared counter of instances

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?

  1. Step 1: event is Robot("R1")
  2. Step 2: event is Robot("R2")
  3. Step 3: event is Robot("R3")

63. Shared bulletin board vs personal notebook

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.

64. Assigning self.x makes an instance copy

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.

65. How Python looks an attribute up

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.

66. Teach it back: How Python looks an attribute up

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.

67. The Shared-Mutable Trap

Section

Part 6

68. A mutable class attribute is shared, too

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.

69. By analogy: A mutable class attribute is shared, too

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.

70. Finish it with less help: The surprise: one append, two objects

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.

71. The surprise: one append, two objects

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.

stepa.itemsb.items
start[][]
a.add("apple")['apple']['apple']

72. Inspect it line by line: The surprise: one append, two objects

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.

  • a and b never get their own; self.items.append mutates the shared list in place - no assignment, so no instance copy is made.
  • Verified by execution: ['apple'] twice - b got a's apple, the classic surprise.

73. Something is wrong here: mutable class attribute

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.

74. Trap: mutable class attribute

Trap

The 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.

printoutput
print(b.items)['apple']

The fix

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.

printoutput
print(a.items)['apple']
print(b.items)[]

75. Which of these survive contact with Session 24 - Methods & Encapsulation?

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.

Holds up
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.; 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.
Breaks
Referring to an attribute by its bare name inside a method.; Reaching past the method and setting the internal field yourself.
sound
These are stated as this lesson states them — each one survives the edge cases Session 24 - Methods & Encapsulation puts it through.
flawed
Each of these is lifted from a trap in this deck: reasonable-sounding, and wrong in a way that only shows up once you rely on it.

76. Predict the next row: Both baskets pour into one list

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']

printoutput
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.

77. Both baskets pour into one list

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.

printoutput
a.items['apple', 'bread']
b.items['apple', 'bread']
Basket.items['apple', 'bread']

78. What each one costs: Both baskets pour into one list

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.

printoutput
a.items['apple', 'bread']
b.items['apple', 'bread']
Basket.items['apple', 'bread']

79. Restore the missing line: The fix keeps them independent

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.

80. The fix keeps them independent

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 [].

stepa.membersb.members
start[][]
a.add("Sam")['Sam'][]

81. Fill in: a.members for The fix keeps them independent

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.

stepa.membersb.members
start[][]
a.add("Sam")['Sam'][]

82. The rule of thumb

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.

83. Break it if you can: The rule of thumb

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.

84. Patterns & Checks

Section

Part 7

85. Designing an encapsulated class

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.

86. Reader or mutator?

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.

87. Class attribute or instance attribute?

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.

88. Where this shows up: Session 24 - Methods & Encapsulation

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).

89. Check: reader vs mutator

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())
incrementsc.count
3?

Check your understanding

What does this print?

  • A. 3 (correct)
  • B. 0
  • C. None
  • D. 1

Answer: A

Why: increment() adds 1 to self.count each call; three calls make count 3, and value() returns it. Verified by execution.

Why B tempts people
count starts at 0, but the three increment calls raise it before value() reads it.
Why C tempts people
value() has a return, so it hands back the number 3, not None.
Why D tempts people
All three increments run on the same object c, so they stack to 3, not 1.

90. Check: methods calling methods

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?

  • A. area is 10 (correct)
  • B. area is 7
  • C. 10
  • D. NameError: name 'area' is not defined

Answer: A

Why: describe() calls self.area(), which returns 5 * 2 = 10, then builds the string. Verified by execution.

Why B tempts people
area multiplies the sides (5 * 2 = 10); it does not add them (5 + 2 = 7).
Why C tempts people
describe wraps the number in the text 'area is ', so the output is the full string, not just 10.
Why D tempts people
area is reached correctly through self.area(); the self. prefix is present, so there is no NameError.

91. Check: the shared list

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.addb.items
apple?

Check your understanding

What does print(b.items) show?

  • A. ['apple'] (correct)
  • B. []
  • C. None
  • D. AttributeError

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.

Why B tempts people
[] would be right only if each object had its own list - but the list lives in the class body, so it is shared.
Why C tempts people
b.items is a real list, not None; append mutated it rather than replacing it.
Why D tempts people
items exists (on the class), so accessing b.items is fine - no AttributeError.

92. Check: the fix

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.addb.items
apple?

Check your understanding

What does print(b.items) show now?

  • A. [] (correct)
  • B. ['apple']
  • C. None
  • D. ['apple', 'apple']

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.

Why B tempts people
That was the shared-class-attribute bug. With the list built in __init__, each object has its own, so b stays empty.
Why C tempts people
b.items is its own empty list, not None.
Why D tempts people
Only a received an apple; b's list was never touched, so it holds nothing.

93. Check: the invariant

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?

  • A. blocked then 50 (correct)
  • B. blocked then -30
  • C. 50 then blocked
  • D. blocked then 0

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.

Why B tempts people
The raise happens before self._balance -= amount, so the subtraction never runs; the balance is not -30.
Why C tempts people
The withdraw call (and its raise) runs first, so blocked prints before the balance line.
Why D tempts people
The balance is untouched at 50, not reset to 0 - the guard stopped the change entirely.

94. Check: changing a class attribute

Check

One assignment to the class.

class Dog:
    legs = 4

a = Dog()
b = Dog()
Dog.legs = 3
print(a.legs, b.legs)
Dog.legsa.legs / b.legs
3?

Check your understanding

What does this print?

  • A. 3 3 (correct)
  • B. 4 4
  • C. 3 4
  • D. 4 3

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.

Why B tempts people
The assignment Dog.legs = 3 changed the class attribute, so neither instance still reads 4.
Why C tempts people
Neither instance has its own legs; both fall back to the same class attribute, so they cannot differ.
Why D tempts people
Same reason - there is one shared legs, so both print 3, not one each.

95. Check: shadowing an instance

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 = 3b.legs / Dog.legs
3?

Check your understanding

What does this print?

  • A. 3 4 4 (correct)
  • B. 3 3 3
  • C. 3 4 3
  • D. 3 3 4

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.

Why B tempts people
Assigning through the instance does not change the class attribute, so b and Dog remain 4.
Why C tempts people
Dog.legs is unchanged at 4 - only a got its own instance attribute; Dog is not 3.
Why D tempts people
b never got its own legs, so it still reads the class value 4, not 3.

96. Connect it up: Session 24 - Methods & Encapsulation

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.

97. What you can do now

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 writeIt means
def balance(self): return self._balancea reader - hands a value back, changes nothing
def deposit(self, n): self._balance += na mutator - changes state, returns None
self.other()call another method on the same object
_balanceinternal 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.

Sources

  1. Python 3 Tutorial - Classes
  2. Python 3 Tutorial - Class and Instance Variables
  3. Python 3 Tutorial - Private Variables
  4. All snippets and error messages executed and copied from CPython 3.12. — Author verification run, 2026-07-15 (Python Fundamentals series, Session 24).

Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.

Book on Wyzant · Text (657) 465-8108