2.1.8 · D4OOP Fundamentals

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

2,135 words10 min readBack to topic

Before we start, one shared vocabulary reminder so no symbol is unearned:

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

Level 1 — Recognition

Goal: read a hierarchy and state its MRO / lookup result mechanically.

Exercise 1.1

class A: pass
class B(A): pass
class C(B): pass

Write C.__mro__ as a tuple.

Recall Solution 1.1

> Single inheritance is just the parent chain, ending in the universal root `object`.

Walk up: C → its parent B → its parent AA's implicit parent object. Why object at the end? Every class you write silently inherits from object, so it is always the last, most-general stop.

Exercise 1.2

class Animal:
    def speak(self): return "..."
class Dog(Animal): pass
print(Dog().speak())

What prints, and which class's speak ran?

Recall Solution 1.2

MRO of Dog = (Dog, Animal, object). Search for speak:

  • Dog body → no speak. Skip.
  • Animal body → has speak. ✅ Stop.

Output: ... — the method that ran lives in Animal (inherited, not overridden).


Level 2 — Application

Goal: apply override and super() to produce concrete outputs.

Exercise 2.1

class A:
    def who(self): return "A"
class B(A):
    def who(self): return "B"
class C(B): pass
print(C().who())

What prints?

Recall Solution 2.1

MRO = (C, B, A, object). Search who:

  • C → none. Skip.
  • B → defines who. ✅ Stop.

Output: B. A.who exists too, but we never reach it — the first match wins.

Exercise 2.2

class Base:
    def greet(self): return "hi"
class Child(Base):
    def greet(self): return super().greet() + " there"
print(Child().greet())

What prints, and where does super() resume the search?

Recall Solution 2.2

Child.greet runs first (most specific). Inside it, super() means "continue the same MRO search, starting after Child." MRO = (Child, Base, object). Position after Child is BaseBase.greet() returns "hi". Then we append " there". Output: hi there.

Exercise 2.3

class Account:
    def __init__(self, balance):
        self.balance = balance
class Savings(Account):
    def __init__(self, balance, rate):
        super().__init__(balance)
        self.rate = rate
s = Savings(200, 0.03)
print(s.balance, s.rate)

What prints?

Recall Solution 2.3

Savings.__init__ runs. Line by line:

  • super().__init__(balance) = Account.__init__(self, 200) → sets self.balance = 200.
  • self.rate = 0.03.

Output: 200 0.03. The super() call is what makes balance exist — see super() and cooperative multiple inheritance.


Level 3 — Analysis

Goal: trace dispatch through type(self), not "where the code lives".

Exercise 3.1

class X:
    def m(self):   return "X." + self.tag()
    def tag(self): return "x"
class Y(X):
    def tag(self): return "y"
print(Y().m())

Predict the output and justify which tag runs.

Recall Solution 3.1

The object is a Y, so type(self) is Y and its MRO is (Y, X, object).

  • Y().m(): Y has no m, X does → run X.m.
  • Inside X.m, self.tag() is a fresh lookup against type(self).__mro__ = (Y, X, object).
  • Y defines tag → run Y.tag"y".

Output: X.y. This is polymorphism: inherited code (X.m) calls the overridden method.

Exercise 3.2

class A:
    def f(self): return "A.f"
class B(A):
    def f(self): return "B.f->" + super().f()
class C(B):
    def f(self): return "C.f->" + super().f()
print(C().f())

Trace every super() hop.

Recall Solution 3.2

MRO = (C, B, A, object).

  • C.f runs → "C.f->" + super().f(). super() after CB.f.
  • B.f runs → "B.f->" + super().f(). super() after BA.f.
  • A.f runs → "A.f".

Unwinding: A.f = "A.f"; B.f = "B.f->A.f"; C.f = "C.f->B.f->A.f". Output: C.f->B.f->A.f.


Level 4 — Synthesis

Goal: combine override + super() + type(self) and build correct behaviour.

Exercise 4.1

Given:

class Shape:
    def describe(self): return f"a {self.name()}"
    def name(self):     return "shape"
class Circle(Shape):
    def name(self):     return "circle"
class Ring(Circle):
    def describe(self): return "hollow " + super().describe()
    def name(self):     return "ring"
print(Ring().describe())

Predict output. Then answer: which name() does the inherited Shape.describe use?

Recall Solution 4.1

MRO = (Ring, Circle, Shape, object).

  • Ring.describe runs → "hollow " + super().describe(). super() after RingCircle has no describe → keep going in MRO → Shape.describe.
  • Shape.describe = f"a {self.name()}". Here self is still a Ring! So self.name() searches (Ring, Circle, Shape, object)Ring.name"ring".
  • So Shape.describe returns "a ring".

Final: "hollow " + "a ring" = hollow a ring.

Key insight: Shape.describe uses Ring.name, because self's dynamic type drives the second lookup.

Exercise 4.2

You are given a Stack that (wrongly) inherits from list to reuse append/pop:

class Stack(list):
    def push(self, x): self.append(x)

Explain one concrete way this breaks the "stack" promise, and rewrite it with composition.

Recall Solution 4.2

The break: because Stack is-a list, it also exposes insert, __getitem__, etc. A caller can do s.insert(0, 99) and jump the queue — a stack is only supposed to touch the top. The is-a contract leaked a bigger interface than intended.

Composition fix (Composition over Inheritance) — has-a a list instead of is-a a list:

class Stack:
    def __init__(self):
        self._items = []          # has-a list
    def push(self, x): self._items.append(x)
    def pop(self):     return self._items.pop()
    def __len__(self): return len(self._items)

Now only push/pop/len are public — the underlying list is hidden (encapsulated). No insert leak.

Rule: inherit only for genuine is-a; wrap for has-a.


Level 5 — Mastery

Goal: design/reason about a whole hierarchy and predict subtle interactions.

Exercise 5.1

Consider:

class Logger:
    def log(self, msg): return f"[{self.level()}] {msg}"
    def level(self):    return "INFO"
class WarnLogger(Logger):
    def level(self):    return "WARN"
class TimedWarn(WarnLogger):
    def log(self, msg): return "t=0 " + super().log(msg)
 
print(TimedWarn().log("disk full"))

Give the output and the full MRO, then explain why the inner self.level() picks WARN.

Recall Solution 5.1

MRO = (TimedWarn, WarnLogger, Logger, object).

  • TimedWarn.log runs → "t=0 " + super().log("disk full"). After TimedWarnWarnLogger has no logLogger.log.
  • Logger.log = f"[{self.level()}] {msg}". self is a TimedWarn; self.level() searches the MRO → TimedWarn has no level, WarnLogger does → "WARN".
  • So Logger.log returns "[WARN] disk full".

Final: "t=0 [WARN] disk full".

Why WARN not INFO? Even though the string self.level() sits inside Logger, dispatch uses type(self).__mro__, and the first level found there is WarnLogger.level.

Exercise 5.2

Design task. You must model VehicleCarElectricCar where:

  • Vehicle.__init__(self, wheels) sets self.wheels.
  • Car.__init__(self) should give 4 wheels.
  • ElectricCar.__init__(self, battery) should keep 4 wheels and set self.battery.

Write the three classes so that ElectricCar(75).wheels == 4 and .battery == 75, using super() correctly. Then state each object's MRO.

Recall Solution 5.2
class Vehicle:
    def __init__(self, wheels):
        self.wheels = wheels
class Car(Vehicle):
    def __init__(self):
        super().__init__(4)          # Vehicle sets wheels = 4
class ElectricCar(Car):
    def __init__(self, battery):
        super().__init__()           # Car.__init__ -> sets wheels = 4
        self.battery = battery

Trace for ElectricCar(75) — MRO = (ElectricCar, Car, Vehicle, object):

  • ElectricCar.__init__(75)super().__init__() = Car.__init__(self).
  • Car.__init__super().__init__(4) = Vehicle.__init__(self, 4)self.wheels = 4.
  • back in ElectricCar: self.battery = 75.

Result: wheels == 4, battery == 75. ✅

MROs:

  • Car.__mro__ = (Car, Vehicle, object)
  • ElectricCar.__mro__ = (ElectricCar, Car, Vehicle, object)

Exercise 5.3 (degenerate/edge case)

class Empty: pass
e = Empty()
print(Empty.__mro__)
print(e.__str__())         # does this error?

What is the MRO of a class with no explicit parent, and where does __str__ come from?

Recall Solution 5.3

A class with no written parent still inherits from object. So: e.__str__() does not error: Empty has no __str__, but the search continues to object, which defines a default __str__ (returning something like '<__main__.Empty object at 0x...>'). This is the base case of inheritance — even "no parent" means "parent = object". See object — the root class.


Quick self-test wrap-up

Term reveals (cover the right side):

Which class in an MRO wins a lookup?
The first one (left-to-right) whose own body defines the name.
super() resumes the search at which position?
The class immediately after the current one in type(self)'s MRO.
Inside inherited code, self.foo() resolves against?
type(self).__mro__ — the object's dynamic type, not where the code lives.
MRO of a parentless class class Q: pass?
(Q, object).