Learning Goals
3 minBy the end of this lesson you can:
- Explain why outside code shouldn't reach into an object's internals (encapsulation).
- Use the
_underscoreprefix convention to mark "private" attributes. - Provide public methods (getters / setters / validators) to safely read and write "private" state.
- Recognise the double-underscore
__attrname mangling — and when to avoid it.
Warm-Up · A Hero Cheats
5 minThe Hero from PY-L3-06 is wide open:
hero = Hero("Aisyah") hero.hp = 9999 # ← cheating! hero.attack_power = 1000
Nothing stops you. Python objects are completely transparent — every attribute is reachable from outside. That makes the API messy: someone using the Hero class doesn't know which attributes are safe to touch.
An object should hide its internals. The outside world goes through methods; the data lives behind _underscore attributes that say "hands off".
New Concept · The Underscore Convention
14 minThe single underscore — "weak private"
Prefix any attribute or method with _ to say "internal, don't touch".
class BankAccount: def __init__(self, owner, balance): self.owner = owner # public — fine to read self._balance = balance # private — outsiders, hands off! def deposit(self, amount): self._balance += amount def withdraw(self, amount): if amount > self._balance: print("Insufficient funds.") return self._balance -= amount def get_balance(self): # public getter return self._balance
Python can't prevent outside access — account._balance = 1000000 still works. But every Python developer knows: a name with _ in front is internal. Don't touch.
Why this matters · the invariant
An invariant is a rule the object enforces. For a bank account: balance is never negative. With the leaky design, anyone can set balance = -100. With encapsulation, the only way to change _balance is through deposit and withdraw — both of which enforce the rule.
Without encapsulation With encapsulation account.balance = -100 account.withdraw(2000) → "Insufficient funds." (silently corrupted!) account._balance never goes negative.
Getter / setter methods
When outside code needs to read or write a private value, expose it via methods:
class Thermostat: def __init__(self): self._temperature = 22 def get_temperature(self): return self._temperature def set_temperature(self, value): if not (10 <= value <= 35): raise ValueError(f"Temperature out of safe range: {value}") self._temperature = value
Now setting an invalid temperature is impossible. t._temperature = 999 would still work (Python can't stop it), but a developer following convention uses the setter — which validates.
Pythonic alternative · @property
If you want obj.attr syntax that secretly runs a method, use @property. We'll dive in PY-L3-18 — for today, just know it exists.
The double underscore — name mangling
A name with two leading underscores (like __secret) is "name-mangled" — Python rewrites it inside the class.
class Vault: def __init__(self): self.__secret = "hidden treasure" v = Vault() # v.__secret # AttributeError! print(v._Vault__secret) # → hidden treasure ← Python renamed it
This isn't real privacy — anyone willing to type _ClassName__attr can still reach in. The intended purpose is to avoid collisions in inheritance — if Parent has __x and Child also has __x, they don't conflict because Python mangles them with different class names.
Stick to single underscore for "private-ish". Use __ only when you have a real collision worry. Most Python code never needs it.
The whole convention
public no prefix hero.name, hero.attack() private-ish _leading_underscore hero._hp, hero._level_up() strong hide __double_underscore very rare; for collision avoidance dunder __surrounded__ Python's special methods (__init__, __str__)
Worked Example · BankAccount v2
12 minRefactor the BankAccount with proper encapsulation. Save as bank_v2.py:
# bank_v2.py — encapsulation with _underscores class BankAccount: def __init__(self, owner, balance=0): self.owner = owner # public self._balance = balance # private self._history = [] # private # ---- public API ---- def balance(self): return self._balance def deposit(self, amount): self._validate(amount) self._balance += amount self._history.append(("deposit", amount)) print(f" Deposited RM {amount:.2f}. Balance: RM {self._balance:.2f}") def withdraw(self, amount): self._validate(amount) if amount > self._balance: raise ValueError(f"Insufficient funds: balance {self._balance}, want {amount}") self._balance -= amount self._history.append(("withdraw", amount)) print(f" Withdrew RM {amount:.2f}. Balance: RM {self._balance:.2f}") def statement(self): print(f"\nStatement for {self.owner}:") for action, amount in self._history: sign = "+" if action == "deposit" else "-" print(f" {action:<10} {sign}RM {amount:.2f}") print(f" Final balance: RM {self._balance:.2f}") # ---- private helpers ---- def _validate(self, amount): if amount <= 0: raise ValueError(f"Amount must be positive: {amount}") # Use the public API acc = BankAccount("Aisyah", 100) acc.deposit(50) acc.withdraw(30) try: acc.withdraw(1000) except ValueError as e: print(f" ! {e}") acc.statement() # Convention says don't, but Python doesn't stop you: print("\n--- DON'T DO THIS ---") acc._balance = 999999 # works, but breaks the abstraction print(f"Tampered balance: {acc.balance()}") print(f"...but history doesn't show the change.") acc.statement()
Output
Deposited RM 50.00. Balance: RM 150.00 Withdrew RM 30.00. Balance: RM 120.00 ! Insufficient funds: balance 120, want 1000 Statement for Aisyah: deposit +RM 50.00 withdraw -RM 30.00 Final balance: RM 120.00 --- DON'T DO THIS --- Tampered balance: 999999 ...but history doesn't show the change. Statement for Aisyah: deposit +RM 50.00 withdraw -RM 30.00 Final balance: RM 999999.00
Read the diff
Three things to spot. (1) _balance and _history have leading underscores — they're internal. (2) Five public methods make up the API: balance, deposit, withdraw, statement, plus the inherited owner attribute. (3) The "don't do this" section shows what happens if someone breaks the convention — the balance updates but the history doesn't. The invariant breaks. The class is no longer self-consistent.
Try It Yourself
13 minIn your Hero class, rename hp and max_hp to _hp and _max_hp. Add a public health() method that returns the current HP. Make sure the internal methods (take_damage, heal) use the new names.
Hint
class Hero: def __init__(self, name, hp=100): self.name = name self._hp = hp self._max_hp = hp def health(self): return self._hp def take_damage(self, n): self._hp = max(0, self._hp - n) def heal(self, n): self._hp = min(self._max_hp, self._hp + n) def is_alive(self): return self._hp > 0
From the outside, callers use hero.health(). The internal arithmetic stays inside the class. Anyone hacking hero._hp directly is breaking convention.
Build Thermostat with private _temp. Public get() and set(value) methods. set rejects values outside 10-35.
Hint
class Thermostat: def __init__(self): self._temp = 22 def get(self): return self._temp def set(self, value): if not (10 <= value <= 35): raise ValueError(f"Out of range: {value}") self._temp = value t = Thermostat() t.set(28) print(t.get()) # → 28 try: t.set(100) except ValueError as e: print(f"Rejected: {e}")
In your Hero class, refactor the levelling code from PY-L3-06 so _level_up is clearly private (starts with underscore). Make sure outside code can't accidentally call it — but gain_xp still triggers it.
Hint
def gain_xp(self, n): self._xp += n while self._xp >= self._level * 100: self._xp -= self._level * 100 self._level_up() # ✅ internal call def _level_up(self): # private — only meant to be called from inside self._level += 1 self._max_hp += 20 self._hp = self._max_hp print(f"Lv {self._level}!")
The leading underscore on _level_up is documentation: future readers know not to call this from outside. Internal callers (gain_xp) reference it without issue.
Mini-Challenge · Locked Stack
8 minBuild locked_stack.py. A Stack class with a private _items list. Public methods:
push(item)— add to top.pop()— remove and return top. RaiseIndexErrorif empty.peek()— return top without removing. RaiseIndexErrorif empty.size()— return count.is_empty()— boolean.
The point: outside code should never touch _items. All operations go through methods. Test the full interface.
Show one possible solution
# locked_stack.py — encapsulated stack class Stack: def __init__(self): self._items = [] def push(self, item): self._items.append(item) def pop(self): if not self._items: raise IndexError("pop from empty stack") return self._items.pop() def peek(self): if not self._items: raise IndexError("peek into empty stack") return self._items[-1] def size(self): return len(self._items) def is_empty(self): return len(self._items) == 0 s = Stack() for x in (1, 2, 3): s.push(x) print(s.peek()) # → 3 print(s.size()) # → 3 print(s.pop()) # → 3 print(s.pop()) # → 2 print(s.is_empty()) # → False try: Stack().pop() except IndexError as e: print(f" ! {e}")
Non-negotiables: a private _items, five public methods, two distinct IndexError raises. We'll build a proper Stack abstract data type in PY-L3-43 — this is a preview using encapsulation principles.
Recap
3 minPython has no real private keyword. Instead, it uses convention: a leading underscore says "hands off, internal use only". The discipline matters because the class can enforce invariants — rules about its own state — only if outside code doesn't bypass the methods. Outside code accesses an object through its public API; internal state stays internal. The double-underscore prefix invokes name mangling, which is rare in real Python — stick to the single underscore.
Vocabulary Card
- encapsulation
- The principle that an object's internal state is hidden, and outside code interacts via methods.
- _attr
- Convention: this attribute is internal. Outsiders shouldn't touch.
- __attr
- Name-mangled. Used for collision avoidance in inheritance. Rarely needed.
- invariant
- A rule the class guarantees about its own state. Example: balance is never negative.
- getter / setter
- Public methods that controllably read or write a private attribute.
Homework
4 minBuild counter.py. A BoundedCounter class with a private _value and a private _max. Public:
increment()— add 1, but never exceed_max.decrement()— subtract 1, but never go below 0.value()— return current value.reset()— set to 0.
Run a sequence of 20 random ups/downs and confirm the value stays in [0, _max] throughout.
Sample · counter.py
# counter.py — BoundedCounter with encapsulation import random class BoundedCounter: def __init__(self, maximum): self._max = maximum self._value = 0 def increment(self): if self._value < self._max: self._value += 1 def decrement(self): if self._value > 0: self._value -= 1 def value(self): return self._value def reset(self): self._value = 0 c = BoundedCounter(maximum=5) for _ in range(20): if random.random() < 0.5: c.increment() else: c.decrement() v = c.value() assert 0 <= v <= 5, f"Invariant broken: {v}" print(v, end=" ") print() print(f"Final: {c.value()}")
Non-negotiables: _value and _max private, four public methods, an assert in the loop that proves the invariant holds. The class enforces the range; the caller doesn't need to.