2.1.6 · D4OOP Fundamentals

Exercises — Encapsulation — hiding internal state, name mangling (`__name`)

2,219 words10 min readBack to topic

The one rule we lean on the whole way down:

Figure — Encapsulation — hiding internal state, name mangling (`__name`)

Level 1 — Recognition

Goal: can you spot which names get mangled, and what they become?

L1.1 — Which names get mangled?

For a class named Widget, decide for each identifier whether it is mangled, and if so to what:

gears, _gears, __gears, __gears_, __gears__, ___gears

Recall Solution

Apply "two-front, one-back":

Written inside Widget Mangled? Result
gears No (zero leading) gears
_gears No (only one leading) _gears
__gears Yes _Widget__gears
__gears_ Yes (one trailing is allowed) _Widget__gears_
__gears__ No (two trailing → dunder) __gears__
___gears Yes (three leading still counts as "≥2") _Widget___gears

The last one surprises people: the rule says at least two leading underscores, so three qualifies. Mangling adds _Widget in front of the whole name including its underscores.

L1.2 — Name the mangled attribute

class Robot:
    def __init__(self):
        self.__battery = 100

An outsider wants to read that value directly (without any getter). What exact attribute name must they type?

Recall Solution

__battery inside class Robot becomes _Robot__battery. So:

r = Robot()
print(r._Robot__battery)   # 100

Typing r.__battery fails with AttributeError, because that literal name was never stored — only the mangled one exists. This proves mangling is renaming, not locking.


Level 2 — Application

Goal: run the machinery yourself and predict real output.

L2.1 — Predict the output

class Vault:
    def __init__(self):
        self.__code = 4242
    def check(self, guess):
        return guess == self.__code
 
v = Vault()
print(v.check(4242))
print(v.__dict__)

What do the two print lines produce?

Recall Solution
  • v.check(4242) — inside check, self.__code was compiled as self._Vault__code, whose value is 4242. So the comparison is 4242 == 4242True.
  • v.__dict__ — the instance dictionary stores the mangled key, so:
True
{'_Vault__code': 4242}

There is no '__code' key anywhere. Whenever you're unsure what got stored, print __dict__ — it shows the real keys.

L2.2 — The guarded counter

class Counter:
    def __init__(self):
        self.__n = 0
    def tick(self):
        self.__n += 1
        return self.__n
 
c = Counter()
c.tick()
c.tick()
c.__n = 999          # look carefully at this line
print(c.tick())
print(c.__dict__)

What prints, and why doesn't c.__n = 999 change the count?

Recall Solution

Two tick() calls make _Counter__n go 0 → 1 → 2.

Now c.__n = 999 runs outside the class body, so no mangling happens there. It creates a brand-new attribute literally named __n — separate from _Counter__n. The real counter is untouched.

The next c.tick() uses self._Counter__n (currently 2), increments to 3, returns 3.

3
{'_Counter__n': 3, '__n': 999}

Two different keys now coexist. This is the classic "I set it but nothing changed" confusion.


Level 3 — Analysis

Goal: reason about inheritance, where mangling earns its existence.

L3.1 — Whose __x wins?

class Base:
    def __init__(self):
        self.__x = "B"
    def who(self):
        return self.__x
 
class Sub(Base):
    def __init__(self):
        super().__init__()
        self.__x = "S"
    def who_sub(self):
        return self.__x
 
s = Sub()
print(s.who())
print(s.who_sub())
print(sorted(s.__dict__.keys()))

Predict all three lines.

Recall Solution

Each __x is mangled using the class it is written in:

  • Base.__init__ stores _Base__x = "B"; Base.who reads _Base__x.
  • Sub.__init__ stores _Sub__x = "S"; Sub.who_sub reads _Sub__x.

They never collide. Both live in the same instance dict.

B
S
['_Base__x', '_Sub__x']

who() reports "B" because it only ever looks at _Base__x. This is exactly the accident mangling prevents: without it, Sub's self.__x = "S" would have overwritten Base's field and broken who().

L3.2 — A single-underscore contrast

Rewrite L3.1 mentally with self._x (one underscore) everywhere instead of self.__x. Now what do the three lines print?

Recall Solution

One underscore means no mangling. Both classes write to the same key _x.

  • Base.__init__ sets _x = "B".
  • Then Sub.__init__ (via its own line after super().__init__()) sets _x = "S", overwriting it.
  • Both who() and who_sub() read the one shared _x, now "S".
S
S
['_x']

Contrast with L3.1: the double underscore gave two independent slots (B and S); the single underscore gave one shared slot that the subclass clobbered. This side-by-side is the entire reason name mangling exists.


Level 4 — Synthesis

Goal: design a class that actually protects an invariant — mangling alone can't.

L4.1 — Build a guarded Speed class

Write a class Speed storing a speed in km/h that must satisfy 0 <= speed <= 300. Requirements:

  1. Reject invalid values with ValueError, at construction and on later change.
  2. Expose the value with clean s.value syntax (no get_/set_).
  3. Provide a read-only computed mps (metres per second, value / 3.6).

Then predict:

s = Speed(72)
print(s.value, round(s.mps, 3))
s.value = 100
print(round(s.mps, 3))
# s.value = 400   -> ?
# s.mps = 10      -> ?
Recall Solution

The Pythonic tool is @property (see Properties and Descriptors), backing the value in a _value internal:

class Speed:
    def __init__(self, value):
        self.value = value          # goes through the setter's check
 
    @property
    def value(self):
        return self._value
 
    @value.setter
    def value(self, v):
        if not (0 <= v <= 300):
            raise ValueError("speed out of range")
        self._value = v
 
    @property
    def mps(self):                  # no setter -> read-only
        return self._value / 3.6

Why route __init__ through self.value = value instead of self._value = value? So construction uses the same validation as later assignment — one guard, no bypass. This is a class invariant enforced in one place.

Outputs:

  • Speed(72)_value = 72; mps = 72 / 3.6 = 20.0. Prints 72 20.0.
  • s.value = 100 passes the check; mps = 100 / 3.6 = 27.778. Prints 27.778.
  • s.value = 400 → setter sees 400 > 300 → raises ValueError.
  • s.mps = 10mps has no setter → raises AttributeError.

Note: mangling was not the right tool here. It hides a name; it does not validate a value. Validation is the property's job.


Level 5 — Mastery

Goal: the degenerate and edge cases the parent note warned about.

L5.1 — Dunder immunity

class Model:
    def __init__(self):
        self.__init__called = True   # careful: read the name exactly
    def __str__(self):
        return "a model"
 
m = Model()
print(str(m))
print('__str__' in dir(Model))
print(sorted(k for k in m.__dict__))

Which of these names get mangled, and what prints?

Recall Solution
  • __init__ and __str__ end with two underscores → dunders → never mangled. str(m) calls the real __str__, printing a model, and '__str__' in dir(Model) is True.
  • __init__called is subtle: it has two leading underscores and no trailing double underscore (it ends in d). So it is mangled → stored as _Model__init__called.
a model
True
['_Model__init__called']

The trailing test looks at the end of the whole identifier, not at an internal __. __init__called ends in d, so ≤1 trailing → mangled.

L5.2 — Mangling with a trailing underscore

class Node:
    def __init__(self):
        self.__next_ = None      # one trailing underscore
 
n = Node()
print(sorted(n.__dict__))
print(hasattr(n, '_Node__next_'))
Recall Solution

__next_ has two leading and exactly one trailing underscore → the rule allows ≤1 trailing → mangled_Node__next_.

['_Node__next_']
True

Compare __next__ (two trailing) which would not mangle. A single trailing underscore is a common trick to avoid clashing with a keyword (like class_), and it still gets mangled.

L5.3 — Mangling only fires inside a class body

class Bag:
    def __init__(self):
        self.__items = []
    def add(self, x):
        self.__items.append(x)
 
def sneaky(bag):
    return bag.__items          # this line is a plain function, not a class
 
b = Bag()
b.add("apple")
print(b.add.__self__ is b)      # ignore this line's detail; it's True
try:
    sneaky(b)
    print("worked")
except AttributeError:
    print("AttributeError")

What prints for the sneaky call, and why?

Recall Solution

The function sneaky is defined at module level — not inside a class body — so bag.__items is not mangled. Python looks for a literal attribute __items on the instance, which doesn't exist (only _Bag__items does). Result: AttributeError.

True
AttributeError

This nails the mental model: mangling is a compile-time textual rewrite scoped to the enclosing class definition. Move the identical code outside the class and it stops mangling. To reach the field here you'd have to write the mangled name bag._Bag__items explicitly.


Recall Feynman recap: the one sentence

Name mangling is a name tag the interpreter staples on (_ClassName in front) to any __x-style name written inside a class, so different classes' fields never get mixed up — it is not a lock, not validation, and it doesn't fire outside class bodies.

Related builds: Access Modifiers (Java vs Python) · Abstraction — hiding complexity behind interfaces · Inheritance — base and derived classes