Lesson 03 / 11
Encapsulation Conventions
3 of four access forms give a value, and the access the name blocks is 0; a double underscore is not a lock but a name change, and it opens 2 entries instead of 1 for two classes' same name.
Contents
The previous lesson compared two designs through the class that answers, and showed that
Wrapper holds two objects inside itself. Those two objects are Wrapper’s internal
arrangement: tomorrow one might be dropped, renamed, or the two merged into one. But
nothing in the measurement stopped anyone from touching them from outside —
object.signed is exactly as valid a spelling as object.source().
This lesson measures how that boundary is declared. Some languages close visibility off with the language itself; Python does not. The question is: does an underscore in front of a name actually block access, what does a double underscore change, and if it does not block access, does the declaration have any measurable effect at all?
Three Namings, Four Accesses
A class can use three separate naming forms. A name without an underscore is open to the outside: its name, type, and meaning are part of the promise the class makes. A name starting with one underscore is internal: the class uses it, an outside reader reads it at their own responsibility, and the class can change it without notice. A name starting with two underscores triggers the language’s name mangling rule; the class’s name is prefixed onto it.
All three are assigned the same way and all three are written to the same dict. What separates them is which spelling can read them from outside.
- CD27 — Three names are set up in a single
__init__body, in three assignments identical to each other; their values reflect their names too. - CD28 — Four access forms are tried: three names directly, and the double-underscore name also with its mangled spelling. A form that cannot access it records the exception’s name with an exclamation prefix.
- CD29 — A read from the class’s own body is also printed; the measurement compares outside access against inside access.
- CD30 — Names in the instance dict are printed sorted; no identity number or environment-dependent data is printed.
"""What does the name actually block: three visibility declarations and four access forms.""" class Store: """Three separate naming forms; all three are assigned the same way.""" def __init__(self): self.open = "open" self._inner = "inner" self.__closed = "closed" def self_read(self): return (self.open, self._inner, self.__closed) d = Store() FORMS = ( ("d.open", lambda: d.open), ("d._inner", lambda: d._inner), ("d.__closed", lambda: getattr(d, "__closed")), ("d._Store__closed", lambda: d._Store__closed), ) def attempt(call): try: return repr(call()) except AttributeError as e: return f"!{type(e).__name__}" print(f"{'form':<22s} {'access from outside':>19s}") for name, call in FORMS: print(f"{name:<22s} {attempt(call):>19s}") passing = sum(1 for _, c in FORMS if not attempt(c).startswith("!")) print() print(f"the class itself reads all three -> {d.self_read()}") print(f"names in instance dict -> {sorted(d.__dict__)}") print(f"{passing} of four access forms gave a value; " f"access blocked by the name {len(FORMS) - passing - 1}")
form access from outside
d.open 'open'
d._inner 'inner'
d.__closed !AttributeError
d._Store__closed 'closed'
the class itself reads all three -> ('open', 'inner', 'closed')
names in instance dict -> ['_Store__closed', '_inner', 'open']
3 of four access forms gave a value; access blocked by the name 0
Three of four forms give a value. The first two lines produce the expected result: both
the open name and the single-underscore name read from outside. A single underscore has no
effect on access at all; as far as the language is concerned, there is no difference at
all between _inner and open. The only thing separating them is a declaration read by
humans and tools.
The third line looks like a block, but it is not; the fourth line shows this. The same value
reads without trouble under the spelling d._Store__closed. Looking at the instance dict
shows why: that name’s actual entry in the dict is already _Store__closed. A double
underscore is not a lock, it is a name change made at compile time — the spelling
self.__closed written inside the class body is translated into that name prefixed with the
class’s own name. d.__closed fails not because access was closed, it is because
no such name exists at all.
This is why the last line’s number is 0: access was not blocked on any of the four forms, one form looked for the wrong name, nothing more. In Python, visibility is not a rule enforced by the language, it is a declaration the class makes; whether to honor it is the caller’s decision, and the cost of not honoring it is paid not at runtime but when the class changes.
Since the declaration has a recipient, it is not an empty gesture either. Underscore-prefixed
names count as outside the class’s open surface: a reader asking which names of a class are
safe to use, a tool generating help text, and the rule deciding which names
from ... import * brings in all read the same signal. The declaration’s power comes not
from the language enforcing it, but from everyone reading it the same way. The previous
lesson’s Wrapper class is an example: had the two objects it holds inside been named with a
_, the responsibility for breakage in code touching them from outside would have been
explicitly handed off.
What Name Mangling Resolves
If a double underscore does not block access, what is it for? It has a measurable job, and it
does not show up in a single class alone: it appears when two classes use the same name.
In inheritance, a base and a derivative share a single instance dict; if both write
self.state, the second write erases the first. Name mangling removes this collision by
prefixing every class’s own name onto it.
- CD31 — A base and a derivative write the same two names; the derivative’s setup body calls the base’s first, then assigns its own values.
- CD32 — Each class’s own read is printed separately; what is measured is which value the same spelling sees in the two bodies.
- CD33 — The last line counts how many entries the names open in the instance dict.
class Base: def __init__(self): self._state = "Base" self.__state = "Base" def base_read(self): return {"single": self._state, "double": self.__state} class Derived(Base): def __init__(self): super().__init__() self._state = "Derived" self.__state = "Derived" def derived_read(self): return {"single": self._state, "double": self.__state} t = Derived() print(f"{'form':<14s} {'Base body reads':>16s} {'Derived body reads':>19s}") for key, name in (("single", "_state"), ("double", "__state")): print(f"{name:<14s} {t.base_read()[key]:>16s} {t.derived_read()[key]:>19s}") print() print(f"names in instance dict -> {sorted(t.__dict__)}") print(f"entries for the two classes' single-underscore name " f"{sum(1 for a in t.__dict__ if a == '_state')}, " f"for the double-underscore name {sum(1 for a in t.__dict__ if a.endswith('__state'))}")
form Base body reads Derived body reads _state Derived Derived __state Base Derived names in instance dict -> ['_Base__state', '_Derived__state', '_state'] entries for the two classes' single-underscore name 1, for the double-underscore name 2
The first row is the collision itself. When Base‘s body reads self._state, it sees
Derived — not the value it wrote itself. Since the derivative’s setup ran after the
base’s, it overwrote the same dict entry, and the base’s value was lost. The base class
assumed it was the only side writing this name; a subclass broke it without knowing.
The second row has no collision. Each body reads the value it wrote itself, because the two spellings were translated into two separate names. The numbers below confirm this: the single-underscore name opens 1 entry for the two classes, the double-underscore name opens 2. The names in the dict show this too.
This is exactly, and only, what name mangling resolves: that a class’s internal name stays its own, no matter who derives from it. If a class is a base others are expected to derive from and its internal state should not be overwritten by subclasses, a double underscore gives a measurable guarantee. For an ordinary internal name, a single underscore is enough — a double underscore there only lengthens the spelling and needlessly complicates a subclass’s access. It should be added that name mangling is tied to the class’s name: if two classes in two separate places happen to share a name, the prefix comes out the same and the guarantee drops. The promise made is not “no one can access it,” it is “classes not sharing the same name do not collide.”
A Declaration Does Not Protect an Invariant
Encapsulation’s purpose is not hiding a name, it is holding up an invariant. Protecting state and maintaining invariants in object-oriented programming were established in the Programming Paradigms course; that discussion is not repeated here. What is measured here is one question: is a naming declaration enough to protect an invariant?
- CD34 — The measured class has one invariant: the held total must equal the sum of the items. Consistency is checked after every write.
- CD35 — Five writes are tried: three through the class’s method, two directly on the internal names. Both use single-underscore names, and neither is blocked.
- CD36 — The “passed through body” column shows whether the write went through the class’s method; the other column shows whether the invariant is standing after that write.
- CD37 — Writes are made in order on the same object; an invariant broken stays broken in the following rows too.
class Counter: """Invariant: _total always equals the sum of _items.""" def __init__(self): self._items = [] self._total = 0 def add(self, n): self._items.append(n) self._total += n def consistent(self): return self._total == sum(self._items) s = Counter() WRITES = ( ("s.add(2)", lambda: s.add(2)), ("s.add(3)", lambda: s.add(3)), ("s.add(5)", lambda: s.add(5)), ("s._total = 99", lambda: setattr(s, "_total", 99)), ("s._items.append(7)", lambda: s._items.append(7)), ) print(f"{'write':<21s} {'passed through body':>19s} {'invariant held':>15s}") passed = held = 0 for name, action in WRITES: through_body = name.startswith("s.add") action() ok = s.consistent() passed += through_body held += ok print(f"{name:<21s} {str(through_body):>19s} {str(ok):>15s}") print() print(f"writes {len(WRITES)}, passed through body {passed}, " f"blocked by the name 0, invariant held {held}")
write passed through body invariant held s.add(2) True True s.add(3) True True s.add(5) True True s._total = 99 False False s._items.append(7) False False writes 5, passed through body 3, blocked by the name 0, invariant held 3
Three of five writes passed through the class’s body, and the invariant stood in all three. Two did not pass, and both broke the invariant. The number of writes blocked by the name is still 0: both violations were made on single-underscore names, and neither raised an exception.
The last line also shows a boundary being broken. s._items.append(7) is not even an
assignment; it is a mutation performed on an object that was read. Handing out a name for
reading also means handing out the mutable object it points to. This side of encapsulation
can never be solved by naming alone; it is solved only by handing out a copy, or an immutable
container, and both are decisions made inside the body.
Running the measurement in order shows one more point. The invariant broken on the fourth row stays broken on the fifth too; the object does not repair itself, and nobody questions its state. Where the breakage will surface is not the line the violation was made on, it is whatever line later reads the total. This gap between violation and symptom is the real cost of unchecked writing.
The answer to “who” in this lesson is singular: the only place protecting the invariant is
the add body. Whatever the name does, every write that bypasses that body is
unsupervised. A naming declaration says which spellings are supported; it is not the side
that enforces the contract. Writing the enforcing side is a separate job, and it requires
catching the access itself by putting a body in the way.
The One Declaration That Actually Blocks
If naming blocks nothing, is there a declaration that does? There is one, and it declares not
visibility but the attribute set. An array written with the name __slots__ in a class
body fixes which names that class’s instances can carry; writing to an undeclared name falls
at runtime, and instances carry no separate dict.
- CD38 — Two classes share the same setup body; the only difference is that one declares its attribute set and the other does not.
- CD39 — Two writes are tried: to a declared name, and to a name misspelled by one letter. The second is deliberately wrong.
- CD40 — The “instance dict” column reports whether the instance carries a separate attribute dict; its content and size are not printed.
- CD41 — The “blocked” column counts how many of the two writes fell with an exception.
class Free: """Does not declare an attribute set: any name can be written.""" def __init__(self, label): self.label = label class Restricted: """Declares an attribute set: only declared names can be written.""" __slots__ = ("label",) def __init__(self, label): self.label = label def write(obj, field): try: setattr(obj, field, "new") except AttributeError as e: return f"!{type(e).__name__}" return "passed" print(f"{'class':<11s} {'label write':>15s} {'lable write':>15s} " f"{'instance dict':>14s} {'blocked':>8s}") for cls in (Free, Restricted): n = cls("k") result = [write(n, "label"), write(n, "lable")] print(f"{cls.__name__:<11s} {result[0]:>15s} {result[1]:>15s} " f"{('yes' if hasattr(n, '__dict__') else 'no'):>14s} " f"{sum(1 for c in result if c.startswith('!')):>8d}")
class label write lable write instance dict blocked Free passed passed yes 0 Restricted passed !AttributeError no 1
The first row shows the silence of the undeclared case: the misspelled name passes too,
and adds a new attribute to the object. The program raises no error; label just stays at
its old value and nobody notices. In the row below, the same write falls; blocked writes
rise to 1.
This is the lesson’s one real block, and notably it is not a visibility block. The
__slots__ declaration says which names can be written; it says nothing, again, about which
names can be read from outside. A declared name, even underscore-prefixed, stays open to
outside reads. So the only encapsulation rule Python actually enforces is the set’s
closedness, not its secrecy.
The absence of an instance dict explains the mechanism behind this too. Once declared, instance names are not held in a free-form dict; a fixed slot is set up on the class for each name, and there is nowhere to write outside those slots. The source of the block is not a prohibition, it is the absence of a place.
Summary
- Visibility in Python is not a rule enforced by the language, it is a declaration made through naming; 3 of four access forms give a value, and access blocked by the name is 0.
- A single underscore has no effect on access at all; it declares that a name belongs to the class’s internal arrangement and can change without notice.
- A double underscore is not a lock, it is a name mangling that prefixes the class’s own
name;
d.__closedfalls not because access is closed, but because that name never existed. - Name mangling’s measurable job is preventing collisions: a base’s and a derivative’s same name opens 1 entry under a single underscore and the base’s value is lost; under a double underscore it opens 2 entries and each body reads its own value.
- The only place protecting an invariant is the method body; 3 of five writes passed through the body and the invariant stood in all three, the 2 that did not break it.
- The only declaration actually enforced is
__slots__, which closes the attribute set: a misspelled name passes silently on an undeclared class, and falls on a declared one. What is closed is the set, not visibility.
Next Step
Up to here, the classes we wrote were called through their own method names: add, format,
source. But most of the protocols measured in the previous two courses were called not by
method name but through syntax — len(n), n == m, with n, for x in n. Those syntax
forms were answered by objects the language provided, and the answering side was always
ready. The next lesson measures that same situation with a class we write ourselves: how many
of these syntax forms can our own class join without writing any special method, what happens
where it cannot, and which syntax form does each method we write open?
To keep your progress and take notes, Log in
My notes
Log in to take notes.