2.1.8 · D3OOP Fundamentals

Worked examples — Inheritance — single inheritance, method resolution order (MRO)

3,404 words15 min readBack to topic

This page builds on the parent topic inside OOP Fundamentals. New tools we meet here connect to Polymorphism and Method Overriding, super() and cooperative multiple inheritance, Encapsulation and Attribute Lookup, and object — the root class.


The scenario matrix

Before solving anything, let us name every distinct case a single-inheritance / MRO question can be. Each row is a "cell"; every worked example below is tagged with the cell it fills.

# Case (the "quadrant") What makes it different Example
1 Direct hit The method lives in the object's own class WE-1
2 Inherited hit Child empty, name found up the chain WE-2
3 Two overrides, dynamic dispatch Inherited method calls another method that is overridden — type(self) decides WE-3
4 super() to extend Run parent's version then add WE-4
5 Deep chain (3+ levels) Which of several ancestors wins WE-5
6 Degenerate: name nowhere Nobody defines it → AttributeError WE-6
7 Degenerate: attribute masks a method A data attribute shadows a method of the same name → calling it raises TypeError WE-7
8 Real-world word problem Model an is-a hierarchy from a story WE-8
9 Exam twist __init__ NOT chained → missing field crash WE-9

The mental model we will reuse

Every single example uses one algorithm. Keep this picture in your head:

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

How to read Figure s01. The purple box at the top is the object's own __dict__ (its personal storage). The row of boxes below is the class chain — the MRO — with the child on the left (orange), ancestors in teal, and object (the root) at the far right in dark ink. The purple arrow shows lookup checking the instance first; the horizontal arrows show it then walking the class chain left-to-right. The orange line at the bottom is the stop rule; the ink line is the failure rule. Every later figure is just this same walk applied to a specific example.

MRO for single inheritance is just the parent chain ending in object, most-specific first.


WE-1 · Cell 1: Direct hit

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

How to read Figure s02. Same walk as s01, applied here. The orange Square box (leftmost in the MRO) is checked first; because area is in Square.__dict__, the bold orange ring marks it as the stop point and the teal Shape box is never reached. This is the shortest possible walk — one step.

Steps

  1. type(obj) is Square, so MRO is (Square, Shape, object). Why this step? Lookup always starts from the object's dynamic type, never from where we happen to be reading.
  2. Walk left→right: is area in Square.__dict__? Yes. Stop immediately. Why this step? The first match wins; we never look at Shape.
  3. Run Square.area(self) with self.s = 44 * 4 = 16. Why this step? self.s was set in __init__, so the multiply is well-defined.
Recall Answer

16. Direct hit — the child's own area is found first.

Verify: side area . Units check: a length² gives an area. ✓


WE-2 · Cell 2: Inherited hit (child is empty)

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

How to read Figure s03. Same walk. The orange Circle box has no area (labelled "none"), so the ink arrow steps right to the teal Shape box, where area lives — that box gets the orange stop ring. The walk takes two steps instead of one, illustrating "inheritance fills the gap."

Steps

  1. MRO is (Circle, Shape, object). Why this step? Single parent chain + root.
  2. area in Circle.__dict__? No (pass = empty body). Keep walking. Why this step? An empty class just forwards every lookup upward.
  3. area in Shape.__dict__? Yes → run Shape.area → returns 0. Why this step? Inheritance means "borrow the parent's version when you have none."
Recall Answer

0. Not a crash — the inherited method fills the gap.

Verify: Shape.area returns the literal 0. ✓


WE-3 · Cell 3: Dynamic dispatch (the polymorphism trap)

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

How to read Figure s04. The orange box on the left is our object, Duck(), carried around as self. The teal box (top right) is Bird.describe, which runs because Duck did not override it. From it, the downward ink arrow is the call self.sound(). The two candidate sound methods are stacked below: Bird.sound = tweet (ink) and Duck.sound = quack (orange). The orange arrow from self to Duck.sound is the point of the figure: because self is a Duck, the call lands on the orange one, not the ink one — even though we launched from inside Bird.

Steps

  1. Duck() has no describe; MRO (Duck, Bird, object) finds Bird.describe. It runs. Why this step? Cell-2 logic — inherited method found in Bird.
  2. Inside, self.sound() is evaluated. self is still a Duck. Why this step? self carries the object's type; being physically inside Bird's code does not change that.
  3. So self.sound() re-runs the lookup on Duck.__mro__Duck.sound"quack". Why this step? This is polymorphism: an inherited method calls the child's override.
Recall Answer

"A bird that says quack".

Verify: type(self) is Duck, so sound resolves to Duck.sound"quack". ✓


WE-4 · Cell 4: super() to extend

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

How to read Figure s05. The same MRO row: TimeLogger (orange), Logger (teal), object (ink). The first orange arrow shows the normal lookup landing on TimeLogger.log. The second, plum arrow labelled super() skips past TimeLogger and lands on Logger.log — showing that super() starts one step later, so it can never re-enter the current class (no infinite loop). The two returned pieces are joined at the bottom.

Steps

  1. MRO (TimeLogger, Logger, object). TimeLogger.log runs first. Why this step? Direct hit in the child.
  2. super() inside TimeLogger = "restart the walk at the class after TimeLogger" = Logger. Why this step? super() is next-in-MRO, so it cannot re-enter the current class — no infinite loop.
  3. Logger.log()"[LOG]"; then we concatenate " 12:00". Why this step? Extend = parent's result plus our addition. See super().
Recall Answer

"[LOG] 12:00".

Verify: "[LOG]" + " 12:00" = "[LOG] 12:00". ✓


WE-5 · Cell 5: Deep chain — who wins among three ancestors?

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

How to read Figure s06. The boxes are the four classes plus object, laid out in MRO order left-to-right: D and C (orange, "none" = they don't define kind), then B (teal, kind=B), then A (plum, kind=A), then object (ink). The ink arrows are the walk stepping rightward. The bold orange rectangle around B marks where the search stops — the first box with kind in it — and the "STOP: first match" label points there. A also has kind, but the walk never reaches it, which is the whole lesson: the nearer ancestor shadows the farther one.

Steps

  1. MRO is the whole chain: (D, C, B, A, object) (this is exactly what D.__mro__ prints). Why this step? Single inheritance stacks parents most-specific first.
  2. Walk: D no, C no, B yes → stop at B. Why this step? First match wins; we never reach A even though it also has kind.
  3. Result "B" — the nearest ancestor that defines the name shadows the farther one. Why this step? "Most specific override closest to the object beats a more general one."
Recall Answer

MRO (D, C, B, A, object); call returns "B".

Verify: first class along (D,C,B,A,object) with kind defined is B. ✓


WE-6 · Cell 6: Degenerate — name defined nowhere

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

How to read Figure s07. Same walk, but every box — Kid (orange), Base (teal), object (ink) — is labelled "none". The ink arrows step through all three and then off the right edge of the row into a red-ringed "AttributeError" box. There is no orange stop ring anywhere, because nothing ever matched: this is what "falling off the end of the MRO" looks like.

Steps

  1. MRO (Kid, Base, object). Search fly in each __dict__. Why this step? Same algorithm, no shortcuts for missing names.
  2. Not in Kid, not in Base, not in object. Why this step? object is the final stop — the wall at the end of the walk.
  3. Walk falls off the end → Python raises AttributeError: 'Kid' object has no attribute 'fly'. Why this step? "First match" with no match is the only failure mode.
Recall Answer

Raises AttributeError — the well-defined "not found anywhere" outcome.

Verify: hasattr(Kid(), "fly") is False, confirming the walk finds nothing. ✓


WE-7 · Cell 7: Degenerate — a data attribute masks a method

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

How to read Figure s08. This figure emphasises the first step of the mental model (s01): the instance check. The plum box at top is t.__dict__, now holding tick = 5. The purple arrow shows the lookup finding tick right there and stopping — so the teal Timer.tick method box below (holding the real method) is greyed out and never reached. The bottom label shows that calling the found value, an int, raises TypeError.

Steps

  1. Lookup checks the instance __dict__ first, before the class MRO. Why this step? The instance-first rule from Encapsulation and Attribute Lookup: personal storage beats the class.
  2. t.__dict__ now contains tick = 5, so t.tick returns the integer 5 — the method is completely hidden. Why this step? A data attribute and a method share the same name tick; whoever is found first wins, and the instance is found first.
  3. Line B does t.tick() = (5)() = trying to call an integer. Integers are not callable → TypeError: 'int' object is not callable. Why this step? This is the sharp end of attribute-vs-method masking: the name still exists, but it no longer points at something you can call.
Recall Answer

Line A prints 5 (the data attribute masks the method); line B raises TypeError: 'int' object is not callable.

Verify: t.tick is the int 5; callable(5) is False, so calling it fails. ✓


WE-8 · Cell 8: Real-world word problem (model the hierarchy)

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

How to read Figure s09. The MRO row for the drink hierarchy: Mocha (orange), Latte (teal), Drink (plum), object (ink). The plum super() arrows chain rightward one step at a time — Mocha.price calls Latte.price calls Drink.price. The running totals are stacked underneath each box: Drink returns 100, Latte adds 40 → 140, Mocha adds 30 → 170. The figure shows how super() climbs the chain and the additions stack on the way back down.

Steps

  1. is-a chain: Mocha is-a Latte is-a Drink → single inheritance, three levels. Why this step? "is-a" (not "has-a") tells us to inherit, per the parent note's 80/20 rule.
  2. Each level uses super().price() and adds its own cost:
    class Drink:
        def price(self): return 100
    class Latte(Drink):
        def price(self): return super().price() + 40
    class Mocha(Latte):
        def price(self): return super().price() + 30
    print(Mocha().price())
    Why this step? Extend, don't replace — each ingredient stacks on the parent's total.
  3. Trace: Mocha.pricesuper() (Latte) → super() (Drink) 100, +40 = 140, +30 = 170. Why this step? super() walks up one MRO step each call: (Mocha, Latte, Drink, object).
Recall Answer

170 (100 + 40 + 30, in whatever single currency the café uses).

Verify: 100 + 40 + 30 = 170. Same-currency amounts add consistently. ✓


WE-9 · Cell 9: Exam twist — forgetting super().__init__

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

How to read Figure s10. The plum box is s.__dict__ after construction: it holds only roll = 7 — the name slot is drawn empty/crossed-out because Student.__init__ never called super().__init__. The lower row shows the lookup for s.name: it misses the instance dict, then walks StudentPersonobject (all labelled "no data attr name") and falls off the end into the red AttributeError box. The contrast with the filled roll slot is the lesson: only the field you actually assigned exists.

Steps

  1. Defining __init__ in Student replaces Person.__init__ entirely. Why this step? __init__ is just a method; the override rule (Cell 5) applies — the child's version shadows the parent's.
  2. Student.__init__ sets self.roll but never calls super().__init__, so self.name is never assigned. Why this step? Unlike other methods, __init__ is not auto-chained; you must call the parent yourself.
  3. s.roll7. Then s.name searches instance dict (no name), then MRO (name is not a class attribute either) → AttributeError. Why this step? Same lookup as WE-6/WE-7 — the field was simply never created.
Recall Answer

s.roll prints 7; s.name raises AttributeError because super().__init__ was skipped.

Verify: hasattr(s, "roll") True, hasattr(s, "name") False (name never set). ✓


Putting the matrix back together

All nine cells are really one flowchart. Read it as the life of a single lookup obj.name: first Python checks the object's own storage (instance dict); if the name isn't there it walks the class MRO left-to-right; the first class that has the name ends the search; and if the walk runs past object with no match, you get an AttributeError. Trace any example above through these exact arrows and you will land on its answer.

found

missing

found

end of MRO

obj dot name

check instance dict

return value

walk type of obj MRO

AttributeError


Active Recall

Recall WE-3: an inherited

describe calls self.sound() and self is a Duck. Which sound? Duck.sound — dispatch uses type(self), not where the code text lives. Result "quack".

Recall WE-5: in

(D, C, B, A, object) where both A and B define kind, which wins? B — the nearest (most specific) ancestor along the MRO shadows the farther one.

Recall WE-7: instance stores

t.tick = 5 while the class defines a method tick. What do t.tick and t.tick() do? t.tick returns 5 (the data attribute masks the method); t.tick() raises TypeError: 'int' object is not callable.

Recall WE-9: child defines

__init__ but omits super().__init__. Fate of the parent's field? Never created → accessing it raises AttributeError. __init__ is not auto-chained.