17. Inheritance, composition, ABCs, and protocols
17.1 Inheritance and super()
class Notifier:
def send(self, to: str, msg: str) -> None:
raise NotImplementedError
class EmailNotifier(Notifier):
def __init__(self, smtp_host: str) -> None:
self.smtp_host = smtp_host
def send(self, to: str, msg: str) -> None:
print(f"[smtp {self.smtp_host}] -> {to}: {msg}")
class AuditedEmailNotifier(EmailNotifier):
def send(self, to: str, msg: str) -> None:
print(f"audit: notify {to}")
super().send(to, msg) # call the parent's version
AuditedEmailNotifier("mail.local").send("ops@example.com", "disk 91%")
print(AuditedEmailNotifier.__mro__) # method resolution order
Python supports multiple inheritance, resolved by the MRO (method
resolution order, C3 linearization). super() means "next class in the
MRO", not "my parent", which is what makes cooperative mixins work. Keep
hierarchies shallow; deep inheritance trees are hard to follow in any
language.
17.2 Prefer composition
class RetryingNotifier:
"""Wraps any notifier; does not inherit from one."""
def __init__(self, inner, attempts: int = 3) -> None:
self.inner = inner
self.attempts = attempts
def send(self, to: str, msg: str) -> None:
for i in range(self.attempts):
try:
return self.inner.send(to, msg)
except ConnectionError:
if i == self.attempts - 1:
raise
RetryingNotifier(EmailNotifier("mail.local")).send("ops@example.com", "hi")
Composition lets you combine behaviours (retrying + audited + rate-limited)
without a class for every combination. requests does this with transport
adapters mounted on a session (Ch 64).
17.3 ABCs: enforced interfaces
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def put(self, key: str, data: bytes) -> None: ...
@abstractmethod
def get(self, key: str) -> bytes: ...
class MemoryStorage(Storage):
def __init__(self) -> None:
self._d: dict[str, bytes] = {}
def put(self, key: str, data: bytes) -> None:
self._d[key] = data
def get(self, key: str) -> bytes:
return self._d[key]
s = MemoryStorage()
s.put("a", b"1")
print(s.get("a"))
Storage()
# TypeError: Can't instantiate abstract class Storage without an implementation for abstract methods 'get', 'put'
17.4 Protocols: duck typing the type checker understands
ABCs require inheritance. typing.Protocol describes the shape and accepts
any class that has it, like a Go interface:
from typing import Protocol
class SupportsSend(Protocol):
def send(self, to: str, msg: str) -> None: ...
def alert_all(n: SupportsSend, people: list[str]) -> None:
for p in people:
n.send(p, "incident started")
class SlackNotifier: # does NOT inherit from anything
def send(self, to: str, msg: str) -> None:
print(f"[slack] @{to}: {msg}")
alert_all(SlackNotifier(), ["ada", "linus"]) # the type checker accepts this
Rule of thumb: use a Protocol for "anything with these methods" in
function signatures; use an ABC when you want to share implementation and
force subclasses to fill in specific methods. The
Go guide's interfaces and
Rust's traits
are the same idea with compiler enforcement.