2.1.8 · D2OOP Fundamentals

Visual walkthrough — Inheritance — single inheritance, method resolution order (MRO)

2,551 words12 min readBack to topic

We will only assume you know: a class is a labelled box of recipes (methods), and one class can say "I'm based on another." Everything else we build here.

Parent note: Inheritance — single inheritance & MRO.


Step 1 — A class is a box; a method is a recipe inside it

WHAT. Draw one class as a box. Inside the box lives a small dictionary called __dict__ — literally a name → recipe table. Only the recipes written in that exact class live in its own box.

WHY. Before we can talk about searching for a method, we must be precise about where a method physically lives. It does not live "somewhere in the object"; it lives in the class box that defined it. This distinction is the whole game later.

PICTURE. The box labelled A holds one entry: who → "return 'A'". That entry is in A.__dict__, nowhere else.

Figure — Inheritance — single inheritance, method resolution order (MRO)

Read this as: "is the text name a key in A's own recipe table?" — name is the method name we're hunting (like who), and A.__dict__ is A's own box. Nothing about parents yet.


Step 2 — "is-a" draws an arrow to a parent box

WHAT. When we write class B(A):, we draw an arrow from B up to A. B gets its own box (maybe empty of who), and a link saying "my base is A."

WHY. We need a way to reuse A's recipes without copying them into B. The arrow is that reuse: it says "if you can't find a recipe in me, there's a place to look next." Copying would duplicate bugs; the arrow keeps one source of truth.

PICTURE. Two boxes. B on the bottom (specific), A on top (general), an arrow B → A. B's own box is empty; A's box has who.

Figure — Inheritance — single inheritance, method resolution order (MRO)

Step 3 — Every chain ends at the same root: object

WHAT. Follow the arrows up. In Python, if a class names no base, its base is secretly object. So every chain, however long, terminates at the single box called ==object==.

WHY. We want lookup to always finish, never loop or fall off the edge. A guaranteed final box gives us a stopping point and a home for universal recipes (like how to print an object). Without a root, "search all ancestors" would have no defined end.

PICTURE. The chain C → B → A → object. object sits at the very top with a flat "floor" under it — the search cannot go higher.

Figure — Inheritance — single inheritance, method resolution order (MRO)

Step 4 — Flatten the chain into a single ordered list: the MRO

WHAT. Take the arrow-chain and write it out as a flat list, front to back: most specific first, object last. That ordered sequence is the Method Resolution Order — the MRO. Python actually stores it for you on the class, under the name __mro__.

WHY. Searching a tree by wandering can give ambiguous answers. Searching a sequence is unambiguous: you go left to right, you stop at the first hit, done. Python computes this order once, when the class is created, and caches it in __mro__ so every later lookup is fast and — crucially — always gives the same answer.

PICTURE. The vertical chain from Step 3 laid down horizontally as ordered slots: position 0 = C, 1 = B, 2 = A, 3 = object.

Figure — Inheritance — single inheritance, method resolution order (MRO)

For chains with only one parent each, this list is literally the arrow-chain read top-down. (For diamonds with multiple parents, the ordering rule generalizes to C3 linearization — out of scope here, but it reduces to exactly this list when each class has one parent.)


Step 5 — The lookup algorithm: check the instance, then walk the list

WHAT. To resolve obj.who(): first peek inside the object's own table, obj.__dict__, in case the value was stored right on the instance. If it's not there, take the MRO from Step 4 and, for each box in order, ask Step 1's question "is who in this box's __dict__?". The first box that answers yes wins; stop immediately.

WHY. An object can carry its own per-object data (self.name = "Rex" lives in obj.__dict__, not in any class). So Python must look there first — otherwise per-instance values could never shadow class-level ones. Only after the instance comes up empty do we fall back to the class chain. For methods the instance table is usually empty, so in practice the class walk is what finds them — which is why the rest of the page focuses on it.

PICTURE. A scanner first taps the small obj.__dict__ bubble (empty for who), then sweeps left to right over [C, B, A, object]. It skips C and B (no who), lights up green at A (has who), and stops — object is greyed out, never visited.

Figure — Inheritance — single inheritance, method resolution order (MRO)

If the instance has nothing and no box matches, the scan runs off the end of the list → Python raises AttributeError.


Step 6 — Edge case: the child overrides, so the scan stops early

WHAT. Now give C its own who. Re-run the scan.

WHY. This is the whole point of inheritance — specialization. We must see the scanner stop at index 0 and never even glance at A. Same MRO, different first-hit.

PICTURE. Same list [C, B, A, object], but now C's box holds who. The scanner lights up green at position 0 and halts; B, A, object are all greyed out.

Figure — Inheritance — single inheritance, method resolution order (MRO)
class A:
    def who(self): return "A"
class B(A):
    pass
class C(B):
    def who(self): return "C"
 
print(C.__mro__)   # (C, B, A, object)  <- an immutable tuple, built at class creation
print(C().who())   # 'C'   -> C matched at index 0
print(B().who())   # 'A'   -> B skipped, A matched

Step 7 — Edge case: self reopens the scan from the object's real type

WHAT. A method inherited from an ancestor calls self.tag(). Where does that inner call look? It restarts the whole lookup rule from type(self) — the object's real class — not from the box where the outer code text lives.

WHY. This is polymorphism: code written in a general parent automatically calls the specialized child version. The scan always keys off the object (via its type(self).__mro__), so an old parent method can invoke a brand-new override it never knew about.

PICTURE. Left: X.m runs (found via the scan). Inside it, self.tag() fires a second scanner over type(self).__mro__ = (Y, X, object). That scanner hits Y.tag first — Y's override — even though the calling code physically sits in X.

Figure — Inheritance — single inheritance, method resolution order (MRO)
class X:
    def m(self):   return "X." + self.tag()   # self is a Y!
    def tag(self): return "x"
class Y(X):
    def tag(self): return "y"
 
print(Y().m())   # 'X.y'

Step 8 — Degenerate case: nothing matches → fall off the end

WHAT. Call a name that neither the instance nor any box in the list defines. The scanner reaches object, still nothing, walks off the right end.

WHY. We claimed "first hit wins" — we owe you the "no hit" outcome so no scenario is left unshown. It is not silent; it is a loud, defined error.

PICTURE. The scanner checks obj.__dict__ (empty), then sweeps every box [C, B, A, object], all grey, and drops off the edge into a red AttributeError marker.

Figure — Inheritance — single inheritance, method resolution order (MRO)
C().nope()   # AttributeError: 'C' object has no attribute 'nope'

The one-picture summary

Everything above is one flowchart: check the instance, then compute-and-scan the __mro__ tuple, stop at first hit (or fall off the end).

Figure — Inheritance — single inheritance, method resolution order (MRO)

yes

no

yes

no

yes

no

you call obj dot name

name in obj __dict__

use instance value STOP

T = type of obj

order = T __mro__ tuple

take next class K in order

name in K __dict__

use it and STOP

more classes left

raise AttributeError

Reveal the core facts:

What MRO stands for
Method Resolution Order — the fixed sequence of classes searched for a name.
MRO of class C(B), class B(A)
(C, B, A, object) — the chain, object last, stored in C.__mro__.
What __mro__ is stored as
an immutable tuple, built once at class-creation time.
Where Python looks first for obj.name
the instance table obj.__dict__, before any class in the MRO.
Where a method physically lives
in the (read-only mappingproxy) __dict__ of the exact class that wrote it.
Which box wins a class lookup
the first box in the MRO whose __dict__ has the name.
Inside self.foo(), the scan restarts from
type(self).__mro__ (the object's real class).
No instance value and no box has the name →
AttributeError.
Recall 🧒 Feynman: the whole walkthrough in plain words

Every class is a box of recipes (its __dict__). When one class is "based on" another, we draw an arrow upward to the more general box, and every chain of arrows ends at the same top box, object. Now flatten that chain of arrows into a plain sequence, most-specific box first, object last — Python saves that sequence for us as __mro__, a fixed tuple you can't rearrange, and "MRO" is just short for Method Resolution Order. To find obj.something, Python first peeks inside the object itself (obj.__dict__) in case the value was stored right there; if not, a little scanner walks the __mro__ tuple left to right and grabs the first box that actually has the recipe, then stops. If a child put its own version in, the child sits earlier in the tuple, so the scanner finds it first — that's overriding. When a recipe from a general box says "now do self.tag()", the scanner restarts from scratch using the object's real type list, so it can find a specialized version the general box never heard of — that's polymorphism. And if the object has nothing and the scanner walks the whole tuple and finds nothing, it falls off the end and shouts AttributeError.


Related: attribute lookup · composition over inheritance · C3 linearization.