Whetstone.
The Spellingself, __init__, and the dunder protocols
Module 1 · Lesson 414 min

self, __init__, and the dunder protocols

Python classes look familiar enough to lull you and differ in three places that matter: the explicit self, the absence of any access control, and the fact that most of the interesting behaviour comes from the dunder protocols rather than from inheritance.

The shape

A class, top to bottom
class RetryPolicy:
    MAX = 10                                  # class attribute. Shared by all instances.

    def __init__(self, attempts: int = 3, backoff: float = 1.5) -> None:
        self.attempts = attempts              # instance attributes. Created here.
        self.backoff = backoff
        self._calls = 0                       # leading underscore: internal by convention

    def delay_for(self, n: int) -> float:     # a method. self is the receiver.
        return self.backoff ** n

    @property
    def is_aggressive(self) -> bool:          # a computed attribute, read as p.is_aggressive
        return self.attempts > 5

    @staticmethod
    def default() -> "RetryPolicy":           # no self. Just a namespaced function.
        return RetryPolicy()

    @classmethod
    def from_config(cls, cfg: dict) -> "RetryPolicy":   # cls is the class, not the instance
        return cls(attempts=cfg["attempts"])

RetryPolicy(attempts=5) constructs it. There is no new.

__init__ is not the constructor, exactly

__init__ initialises an object that already exists. The actual constructor is __new__, which you will essentially never write. The distinction matters once: __init__ must return None, and returning anything else from it is an error.

self is a real parameter

It is not a keyword and the interpreter does not inject it. instance.method performs an attribute lookup that returns a bound method with the instance already supplied as the first argument, and the parameter list has to have somewhere to receive it.

Two consequences that are worth more than the trivia:

  • You must write self.x for every instance attribute, everywhere, including inside the class’s own methods. There is no implicit receiver. Forgetting it creates a local variable that vanishes at the end of the method, silently.
  • A bound method keeps its receiver. callback = obj.handle and then calling callback() works exactly as you would hope. This is the JavaScript this-binding footgun simply not existing.

Attributes are created by assignment

There is no field declaration list. An attribute exists once something assigns to it, which is normally in __init__ but need not be.

Legal, and a genuine hazard
p = RetryPolicy()
p.atempts = 9        # typo. Creates a NEW attribute. No error at all.

Nothing catches that at runtime. A typechecker will, which is one of the stronger arguments for running one, and __slots__ will, at the cost of some flexibility. This is the clearest case in the language where the safety you are used to has to be added rather than assumed.

Nothing is private

The whole access-control story
self.public      # anyone
self._internal   # a request not to touch it. Nothing enforces this.
self.__mangled   # rewritten to self._ClassName__mangled. Still reachable.

A single leading underscore is the real convention and the only one you should use. It means this is not part of the interface, and every Python developer reads it that way. The double underscore triggers name mangling, which exists to stop a subclass accidentally colliding with a base class’s internals, not to prevent access. Describing it as private is wrong, and doing so in a code review will get you corrected.

Worth noting that TypeScript’s private is also erased at runtime, so the honest comparison is that both languages are relying on tooling. Python simply never pretended otherwise.

Dunders are the actual interface system

This is the idea worth taking from the whole lesson. Python puts operations in functions and operators that dispatch to double-underscore methods on your type, rather than in differently-named methods per class.

You write Python calls Give your class
len(x) x.__len__() a length, and falsiness for free
for i in x x.__iter__() iterability
x == y x.__eq__(y) value equality
x < y x.__lt__(y) sortability
x[k] x.__getitem__(k) subscripting
k in x x.__contains__(k) membership
with x: x.__enter__() / x.__exit__() context management
x(...) x.__call__(...) callability
print(x) x.__str__() a human string
REPL, containers x.__repr__() a debug string
await x x.__await__() awaitability

There is no implements and no interface declaration. Implementing the method is the entire act of conforming, which is structural typing enforced at the moment of use rather than at compile time. It is why the standard library composes as well as it does, and it is the mechanism behind three of the four idioms in module 4.

Implement __repr__ before __str__

__repr__ is the developer-facing one, and it is what the REPL prints, what a debugger shows, and what a list of your objects shows for each element. __str__ is the human-facing one and falls back to __repr__ when absent. The reverse is not true, so a class with a lovely __str__ and no __repr__ still gives you a debugging session full of memory addresses.

The minimum useful repr
def __repr__(self) -> str:
    return f"RetryPolicy(attempts={self.attempts}, backoff={self.backoff})"

In practice you will write this by hand rarely, because dataclasses generate it for you. That is module 3.

Inheritance, briefly

Subclassing and super
class BackoffPolicy(RetryPolicy):
    def __init__(self, attempts: int, jitter: float) -> None:
        super().__init__(attempts=attempts)
        self.jitter = jitter

super() takes no arguments in modern Python and finds the next class in the resolution order. Multiple inheritance is legal and is genuinely used for mixins, which is why the resolution order has a name and an algorithm. You do not need to learn it today; you need to recognise that a class with three bases is doing something deliberate rather than something broken.

Abstract base classes exist in abc, and you will meet them in frameworks. typing.Protocol is the more modern and much more Pythonic answer to the same problem, and it gets a proper look in module 3.

Where this goes

That is the language. You can now read a Python file end to end and know what every line is doing.

Module 2 is the part that actually changed since you last looked: the toolchain. It is one tool, it is genuinely excellent, and the reason it exists takes about five minutes to explain properly.

Practice

Try it yourself

Quiz

The first parameter

Every method in a class takes self as its first parameter, and the caller never passes it. What is self actually doing there?

  1. AIt is a scoping directive making the class body's names visible inside the method's local scope
  2. BIt is a reserved keyword the interpreter injects, so naming it anything else is a syntax error
  3. CIt is a type annotation slot letting the checker resolve the class, and it is erased at runtime
  4. DIt is an ordinary parameter receiving the instance, which attribute lookup supplies at call time
Show answer

Correct answer: D — It is an ordinary parameter receiving the instance, which attribute lookup supplies at call time

self is a plain parameter with no special status in the grammar. Naming it something else works and is merely unreadable, which is the tell that it is convention rather than syntax. What makes it work is that instance.method returns a bound method with the instance already supplied as the first argument. This matters practically: it explains why a method referenced without calling it can be passed around as a callback with its receiver already attached, unlike a JavaScript method which loses this the moment you detach it.

Quiz

Making an attribute private

You want an attribute that code outside the class cannot touch. What does Python give you?

  1. ANothing enforced. A leading underscore means internal, and readers respect it or they do not
  2. BThe private keyword, restricting access to the defining class as it does in TypeScript
  3. CA double leading underscore, which makes the attribute inaccessible from outside the class
  4. DDeclaring it in __slots__, which makes an attribute private unless it is explicitly exported
Show answer

Correct answer: A — Nothing enforced. A leading underscore means internal, and readers respect it or they do not

There is no access control in the language. A single underscore is a request. A double underscore triggers name mangling, which rewrites __x to _ClassName__x and exists to stop accidental collisions in subclasses rather than to stop access: the mangled name is trivially reachable, so calling it private is wrong. __slots__ is real but unrelated, restricting which attributes may exist at all in exchange for memory. TypeScript's private is also erased at runtime, so the honest comparison is that both are conventions, and only one of them has a compiler checking it.

Quiz

The two string conversions

A class you are debugging prints as an angle-bracketed object-at-address line in your terminal. Which method fixes that, and what is the other one for?

  1. A__str__, the form the REPL prints; __repr__ is consulted only when writing to a log file
  2. B__repr__, the developer-facing form the REPL uses; __str__ is the human one and is optional
  3. C__str__, the developer-facing form; __repr__ is user-facing and must return valid source
  4. DEither one, since Python tries both and takes whichever returns a non-empty string first
Show answer

Correct answer: B — __repr__, the developer-facing form the REPL uses; __str__ is the human one and is optional

__repr__ is the one to implement first: the REPL uses it, and printing a list of your objects uses it for every element even if __str__ exists, which is why a well-implemented __str__ and a missing __repr__ still gives you a debug session full of memory addresses. The relationship is one-directional: str() falls back to __repr__ when __str__ is absent, but never the reverse. The convention that __repr__ should look like source is a convention, not a requirement, so pairing 'must return valid source' with __str__ instead is a real fact attached to the wrong method.

Recall

What a dunder actually is

The single idea that makes half the standard library make sense at once.

Why does Python write len(x) rather than x.length, and what is the general pattern behind names like __len__, __iter__, __eq__ and __enter__?

Reveal answer

The double-underscore methods are protocol hooks: they define how a built-in operation applies to your type. len(x) calls x.__len__(), for calls x.__iter__(), == calls x.__eq__(), with calls x.__enter__() and x.__exit__(), and x[k] calls x.__getitem__(k). The pattern is that Python puts the operation in a function or an operator and dispatches to the protocol, rather than making each type expose a differently-named method. The practical consequence is that any class of yours becomes iterable, sized, comparable, printable, or usable in a with block by implementing the right dunder, with no interface to declare and no base class to inherit.

Sign in to track your progress →