26  Inheritance: For Better or for Worse

NoteCore idea

Inheritance is frequently misused. Multiple inheritance in particular has real complexity costs. Know when to use it, when to avoid it, and how Python resolves method order when you do use it.

In this chapter you will learn to:

  1. Use super() to call parent methods correctly.
  2. Recognize why subclassing built-in types (dict, list) is risky, and use UserDict/UserList/UserString instead.
  3. Read Python’s Method Resolution Order (MRO) and predict what super() calls.
  4. Write a mixin — a class that adds focused behavior to other classes.
  5. Apply the six guidelines for inheritance that the chapter closes with.

26.1 Always use super()

When overriding a method on a subclass, you almost always need to call back to the parent’s version — to do the work it would have done, plus your additions. super() is the right tool: it walks the inheritance chain in the canonical order and finds the next method up.

class DoppelDictUserDict:
    def __setitem__(self, key, value):
        super().__setitem__(key, [value] * 2)

Walking through this fragment:

  • super() (no arguments, from inside a method) returns a proxy bound to “the next class in the MRO.” Calling .__setitem__ on it invokes the parent’s implementation.
  • [value] * 2 doubles the value into a two-element list — that’s the override’s contribution. The parent then stores the doubled value under the original key.
  • We never write dict.__setitem__(self, key, [value] * 2) even though it would work for single inheritance. The moment a second parent enters the picture, hard-coding dict skips past anything else in the chain.

The general rule: super() respects the MRO; direct calls bypass it. Reach for super() every time, even before multiple inheritance is on the horizon.

26.2 Subclassing built-ins is tricky

The dangerous part: when you subclass a built-in type like dict, the C-level methods don’t always call your overrides. We override __setitem__ to double every value — and watch one assignment route through us while another doesn’t:

class DoppelDict(dict):
    def __setitem__(self, key, value):
        super().__setitem__(key, [value] * 2)

dd = DoppelDict(one=1)        # __init__ ignored our __setitem__
print(dd)
dd["two"] = 2                 # explicit __setitem__ — does call our override
print(dd)
{'one': 1}
{'one': 1, 'two': [2, 2]}

Walking through what happened:

  • DoppelDict(one=1) calls dict.__init__ to populate the dict from the keyword. dict.__init__ is implemented in C — it writes directly to the hash table and never goes through __setitem__. So our doubling logic is bypassed and the dict stores {"one": 1} instead of {"one": [1, 1]}.
  • dd["two"] = 2 is the assignment syntax, which Python does desugar to __setitem__. That call lands on our override and the value gets doubled.
  • The result is a class where assignments through [] are doubled but assignments through __init__, update, or setdefault are not — almost guaranteed to surprise the next reader.

The fix is to subclass collections.UserDict, which is written in pure Python and does call your overrides:

from collections import UserDict

class DoppelUserDict(UserDict):
    def __setitem__(self, key, value):
        super().__setitem__(key, [value] * 2)

dd = DoppelUserDict(one=1)
print(dd)
{'one': [1, 1]}

Walking through the fix:

  • UserDict is a thin pure-Python wrapper that holds a real dict in self.data and delegates every operation through Python-level methods.
  • Because UserDict.__init__ is Python (not C), it routes through our __setitem__ for every key it inserts — so DoppelUserDict(one=1) produces {"one": [1, 1]}.

The general rule: don’t subclass built-in C types when you need to override methods consistently. The same wrapper pattern is available for UserList (instead of list) and UserString (instead of str).

26.3 Multiple inheritance and the MRO

Python supports multiple inheritance, and resolves which method to call using the Method Resolution Order — a linearization of the inheritance graph. To see it in action we’ll build the classic diamond: A at the top, B and C both inheriting from A, and D inheriting from both. Both B and C define pong; we’ll see which one wins.

class A:
    def ping(self):
        print("ping:", type(self).__name__)

class B(A):
    def pong(self):
        print("pong:", type(self).__name__)

class C(A):
    def pong(self):
        print("PONG:", type(self).__name__)

class D(B, C):
    def ping(self):
        super().ping()
        print("post-ping:", type(self).__name__)

D.__mro__
(__main__.D, __main__.B, __main__.C, __main__.A, object)

Walking through what each class adds:

  • A defines ping only. It’s the shared root.
  • B and C both inherit A’s ping and add their own pong — but with different output ("pong" vs "PONG"). This is the key conflict the MRO has to resolve.
  • D(B, C) inherits from both. It overrides ping to call super().ping() and then print a follow-up. It does not define pong, so a D().pong() call has to find one in the chain.
  • D.__mro__ is the linearization: D → B → C → A → object.

graph TD
  D --> B
  D --> C
  B --> A
  C --> A
  A --> O[object]

graph LR
  D --> B --> C --> A --> O[object]

The left graph is the inheritance graph; the right is its linearization — the order Python actually walks for attribute lookup. C3 guarantees this is a consistent total order over the graph; that’s why super() works predictably.

The MRO is D → B → C → A → object. When you call D().pong(), Python walks this chain and picks the first match — B.pong. When you call D().ping(), it calls our override; super().ping() then looks past D in the MRO and finds A.ping.

D().ping()
ping: D
post-ping: D
D().pong()
pong: D

To call a specific parent’s method, bypass the MRO with the explicit form:

C.pong(D())
PONG: D

But that’s exactly the trap — direct calls don’t compose with multiple inheritance. Use super() unless you have a very good reason.

26.4 Mixin classes

A mixin is a class that adds focused behavior — usually no state — to other classes. Multiple mixins compose by inheritance. The convention is to suffix the class name with Mixin to signal intent. To see one in practice, we’ll add case-insensitive key lookup to a dict by intercepting every keyed operation and lowercasing the key before forwarding upstream.

from collections import UserDict

class CaseInsensitiveMixin:
    def __setitem__(self, key, value):
        super().__setitem__(key.lower(), value)
    def __getitem__(self, key):
        return super().__getitem__(key.lower())
    def __contains__(self, key):
        return super().__contains__(key.lower())
    def get(self, key, default=None):
        return super().get(key.lower(), default)

class CaseInsensitiveDict(CaseInsensitiveMixin, UserDict):
    pass

d = CaseInsensitiveDict({"Color": "red"})
d["Color"], d["color"], "COLOR" in d
('red', 'red', True)

Walking through the mixin:

  • CaseInsensitiveMixin doesn’t inherit from anything. It’s not a complete dict — it has no storage of its own. Each method calls super(), which means “whatever class this gets mixed into.”
  • Each method does the same micro-step: lowercase the key, then delegate. __setitem__, __getitem__, __contains__, and get each cover a different access pattern (write, read, in, default-get).
  • class CaseInsensitiveDict(CaseInsensitiveMixin, UserDict): is the assembly. The empty pass body is the whole point — the mixin contributes the override, UserDict contributes the storage.
  • The order matters: CaseInsensitiveMixin first, then UserDict. super() calls inside the mixin land on UserDict next, so the lowercased key reaches the real dict.

Look at the MRO:

CaseInsensitiveDict.__mro__
(__main__.CaseInsensitiveDict,
 __main__.CaseInsensitiveMixin,
 collections.UserDict,
 collections.abc.MutableMapping,
 collections.abc.Mapping,
 collections.abc.Collection,
 collections.abc.Sized,
 collections.abc.Iterable,
 collections.abc.Container,
 object)

CaseInsensitiveDict → CaseInsensitiveMixin → UserDict → .... When d["color"] runs:

  1. Python looks for __getitem__ on CaseInsensitiveDict — not there.
  2. It walks to CaseInsensitiveMixin — finds it.
  3. super().__getitem__(key.lower()) walks to UserDict.__getitem__.

Each link in the chain does its small job and delegates upward.

26.5 Coping with inheritance: six guidelines

  1. Favor composition over inheritance. “Has-a” beats “is-a” in most real systems.
  2. Make interfaces explicit with ABCs or Protocol. They document the contract.
  3. Use explicit mixins for code reuse. Suffix the class name with Mixin.
  4. Provide aggregate classes. Bundle commonly-paired mixins so callers get a one-line setup.
  5. Subclass only classes designed for subclassing. That excludes most built-ins (use UserList, UserDict, UserString).
  6. Avoid subclassing concrete classes. Prefer interfaces (Protocol, ABC) as the base type.

These are guidelines, not rules. The trap they’re protecting you from is the fragile base class problem: a small change to a parent class silently breaks a distant subclass. Composition contains that radius; inheritance widens it.

NoteModern alternative: __init_subclass__

For the common case of “every subclass should do X on creation”, you don’t need a metaclass or even multiple inheritance — __init_subclass__ is the hook:

class Plugin:
    registry = []
    def __init_subclass__(cls, **kw):
        super().__init_subclass__(**kw)
        Plugin.registry.append(cls)

class A(Plugin): pass
class B(Plugin): pass

[c.__name__ for c in Plugin.registry]
['A', 'B']

Runs once per subclass at class-creation time. Covers most of what people used to reach for metaclasses for. The full machinery is in Chapter 36.

TipWhy this matters

Multiple inheritance with mixins works when each mixin adds one focused behavior, is stateless, and uses super() correctly. The C3 linearization algorithm (MRO) makes super() work predictably. The moment you have diamond inheritance with state, you’re in trouble. Prefer composition.

26.6 Build: an ABC plus a logging mixin

A clean inheritance hierarchy: an abstract Repository ABC declares the contract, an InMemoryRepository concretes it, and a LoggingMixin adds before-and-after logging by overriding methods and calling super() to chain. Three classes, every chapter idea on display.

Step 1: the ABC with abstract + concrete methods. save, get, and all are abstract; count is a concrete mixin built on top of all (the partial-implementation payoff from the chapter):

import abc

class Repository(abc.ABC):
    @abc.abstractmethod
    def save(self, item: dict) -> dict: ...

    @abc.abstractmethod
    def get(self, id: int) -> dict | None: ...

    @abc.abstractmethod
    def all(self) -> list[dict]: ...

    def count(self) -> int:
        return len(self.all())                  # concrete — uses abstract all()

# Cannot instantiate directly:
Repository()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[12], line 1
----> 1 Repository()

TypeError: Can't instantiate abstract class Repository without an implementation for abstract methods 'all', 'get', 'save'

abc.ABC plus @abc.abstractmethod makes the three abstract methods enforced at construction — Repository() raises TypeError. count is concrete and free for any subclass: it’s written entirely in terms of self.all(), an abstract method, so it works regardless of the storage backend.

Step 2: a concrete InMemoryRepository. Implement the three abstract methods over a dict:

class InMemoryRepository(Repository):
    def __init__(self) -> None:
        self._data: dict[int, dict] = {}

    def save(self, item: dict) -> dict:
        self._data[item["id"]] = item
        return item

    def get(self, id: int) -> dict | None:
        return self._data.get(id)

    def all(self) -> list[dict]:
        return list(self._data.values())

repo = InMemoryRepository()
repo.save({"id": 1, "name": "Alice"})
repo.save({"id": 2, "name": "Bob"})
[repo.get(1), repo.count(), repo.all()]
[{'id': 1, 'name': 'Alice'},
 2,
 [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}]]

InMemoryRepository(Repository) inherits from the ABC. Because all three abstract methods are overridden, the class is concrete and instantiable. count() is inherited unchanged — it just calls self.all() and reports the length.

Step 3: a LoggingMixin that wraps every save via super(). A stateless mixin that adds logging without touching storage. Mixed before the storage class, its super() call lands on the next class in the MRO — exactly the chapter’s CaseInsensitiveMixin shape:

class LoggingMixin:
    def save(self, item: dict) -> dict:
        print(f"[log] saving id={item.get('id')!r}")
        result = super().save(item)
        print(f"[log] saved  id={item.get('id')!r}")
        return result

class LoggedRepository(LoggingMixin, InMemoryRepository):
    pass

repo2 = LoggedRepository()
repo2.save({"id": 10, "name": "Carol"})
[repo2.count(), [c.__name__ for c in LoggedRepository.__mro__]]
[log] saving id=10
[log] saved  id=10
[1,
 ['LoggedRepository',
  'LoggingMixin',
  'InMemoryRepository',
  'Repository',
  'ABC',
  'object']]

LoggingMixin overrides only save — every other method falls through to InMemoryRepository. The super().save(item) call follows the MRO: LoggedRepository → LoggingMixin → InMemoryRepository → Repository → object, so it lands on InMemoryRepository.save next, which actually mutates self._data. The print lines bracket the real call. The MRO printout shows the chain Python actually walks.

The build is the chapter applied at full strength: an ABC with both abstract and concrete methods (Step 1), a concrete subclass (Step 2), and a super()-driven mixin combined via multiple inheritance with the right MRO order (Step 3). The fragile-base-class warning still applies — but for stateless, focused mixins like this, the pattern is exactly what mixins were designed for.

26.7 Exercises

  1. UserList vs list. Subclass list and override __setitem__ to log every assignment. Construct an instance from [1, 2, 3]. Are the constructor’s assignments logged? Now do the same with UserList. Compare.

  2. MRO for the diamond. Define class A: pass, class B(A): pass, class C(A): pass, class D(B, C): pass. Print D.__mro__. What does it look like?

  3. Order matters. In the CaseInsensitiveDict example, swap the inheritance order to class CaseInsensitiveDict(UserDict, CaseInsensitiveMixin). Does d["color"] still work? Why or why not?

  4. A logging mixin. Write a LoggingMixin that overrides __setattr__ to print every assignment. Mix it into Vector2d. Does it interact with __slots__ correctly?

  5. Composition rewrite. Take the CaseInsensitiveDict example and rewrite it using composition (a wrapper class that holds a dict). Compare line counts and readability.

NoteFurther reading

Beazley, Python Distilled §7.8–7.9 makes the strongest pragmatic case for avoiding inheritance — composition, function-based dispatch, and the practical pitfalls of deep hierarchies. It’s the right companion read once you’ve worked through this chapter’s MRO and super() mechanics.

26.8 Summary

Inheritance is a sharp tool. Use it with super(), prefer the User* wrappers when subclassing built-ins, and reserve multiple inheritance for stateless, well-named mixins. When in doubt, choose composition: it limits the blast radius and makes the dependency graph readable.

Next, Chapter 27 returns to type hints with the heavyweights: @overload, TypedDict, and the variance rules that explain why list[int] is not a list[float].