class DoppelDictUserDict:
def __setitem__(self, key, value):
super().__setitem__(key, [value] * 2)26 Inheritance: For Better or for Worse
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:
- Use
super()to call parent methods correctly. - Recognize why subclassing built-in types (
dict,list) is risky, and useUserDict/UserList/UserStringinstead. - Read Python’s Method Resolution Order (MRO) and predict what
super()calls. - Write a mixin — a class that adds focused behavior to other classes.
- 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.
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] * 2doubles 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-codingdictskips 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)callsdict.__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"] = 2is 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, orsetdefaultare 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:
UserDictis a thin pure-Python wrapper that holds a real dict inself.dataand delegates every operation through Python-level methods.- Because
UserDict.__init__is Python (not C), it routes through our__setitem__for every key it inserts — soDoppelUserDict(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:
Adefinespingonly. It’s the shared root.BandCboth inheritA’spingand add their ownpong— but with different output ("pong"vs"PONG"). This is the key conflict the MRO has to resolve.D(B, C)inherits from both. It overridespingto callsuper().ping()and then print a follow-up. It does not definepong, so aD().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:
CaseInsensitiveMixindoesn’t inherit from anything. It’s not a complete dict — it has no storage of its own. Each method callssuper(), which means “whatever class this gets mixed into.”- Each method does the same micro-step: lowercase the key, then delegate.
__setitem__,__getitem__,__contains__, andgeteach cover a different access pattern (write, read,in, default-get). class CaseInsensitiveDict(CaseInsensitiveMixin, UserDict):is the assembly. The emptypassbody is the whole point — the mixin contributes the override,UserDictcontributes the storage.- The order matters:
CaseInsensitiveMixinfirst, thenUserDict.super()calls inside the mixin land onUserDictnext, 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:
- Python looks for
__getitem__onCaseInsensitiveDict— not there. - It walks to
CaseInsensitiveMixin— finds it. super().__getitem__(key.lower())walks toUserDict.__getitem__.
Each link in the chain does its small job and delegates upward.
26.5 Coping with inheritance: six guidelines
- Favor composition over inheritance. “Has-a” beats “is-a” in most real systems.
- Make interfaces explicit with ABCs or
Protocol. They document the contract. - Use explicit mixins for code reuse. Suffix the class name with
Mixin. - Provide aggregate classes. Bundle commonly-paired mixins so callers get a one-line setup.
- Subclass only classes designed for subclassing. That excludes most built-ins (use
UserList,UserDict,UserString). - 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.
__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.
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
UserListvslist. Subclasslistand override__setitem__to log every assignment. Construct an instance from[1, 2, 3]. Are the constructor’s assignments logged? Now do the same withUserList. Compare.MRO for the diamond. Define
class A: pass,class B(A): pass,class C(A): pass,class D(B, C): pass. PrintD.__mro__. What does it look like?Order matters. In the
CaseInsensitiveDictexample, swap the inheritance order toclass CaseInsensitiveDict(UserDict, CaseInsensitiveMixin). Doesd["color"]still work? Why or why not?A logging mixin. Write a
LoggingMixinthat overrides__setattr__to print every assignment. Mix it intoVector2d. Does it interact with__slots__correctly?Composition rewrite. Take the
CaseInsensitiveDictexample and rewrite it using composition (a wrapper class that holds adict). Compare line counts and readability.
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].