---
title: 'Encapsulation Conventions'
source: 'https://academia.sh/en/courses/python-object-and-types/encapsulation-conventions'
course: 'Object-Oriented Python and Types'
language: en
updated: '2026-08-17T18:10:29+00:00'
license: 'CC BY-SA 4.0'
---

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

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

```python
"""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.

```python
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.

```python
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.

```python
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.__closed` falls 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?
