Reusable abstractions, ABCs, and protocols
Define minimal behavioral contracts with functions, composition, abstract base classes, and structural typing.
An abstraction is useful when it removes irrelevant detail while preserving the choices callers need. Good abstractions follow stable concepts: “store a record,” “render a document,” or “send a message.” Weak abstractions merely collect unrelated helpers or mirror one implementation behind extra indirection.
Begin with the smallest contract
Before defining a hierarchy, ask what the consumer actually does:
from collections.abc import Iterable
def first_nonempty(lines: Iterable[str]) -> str | None:
return next((line for line in lines if line.strip()), None)The function depends on iteration, not indexing, length, mutation, or a concrete list. The narrow contract admits more implementations and requires fewer tests.
Generalize after at least two real consumers expose a shared concept. Premature interfaces often encode guesses and become harder to change than duplicated straightforward code.
Structural contracts with Protocol
A protocol describes required members. A class satisfies it without inheriting from or registering with the protocol:
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True, slots=True)
class Event:
topic: str
payload: bytes
class EventSink(Protocol):
def publish(self, event: Event) -> None: ...
class AuditService:
def __init__(self, sink: EventSink) -> None:
self._sink = sink
def record_login(self, user_id: str) -> None:
self._sink.publish(Event("login", user_id.encode()))This is static structural subtyping: a type checker verifies that the injected
object has a compatible publish method. Production classes remain independent
of the consumer's interface, and tests can supply a small fake.
class RecordingSink:
def __init__(self) -> None:
self.events: list[Event] = []
def publish(self, event: Event) -> None:
self.events.append(event)Define protocols near the code that consumes them when practical. This keeps contracts consumer-driven rather than forcing every implementation to depend on a central “interfaces” package.
@runtime_checkable enables limited isinstance checks:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...Runtime protocol checks verify attribute presence, not full signatures or semantic behavior. Prefer normal polymorphism and static checking unless runtime branching is genuinely needed.
Nominal contracts with abstract base classes
An abstract base class (ABC) requires explicit inheritance and can share implementation:
from abc import ABC, abstractmethod
class Serializer(ABC):
@abstractmethod
def dumps(self, value: object) -> bytes:
"""Encode a value."""
def dump_hex(self, value: object) -> str:
return self.dumps(value).hex()
class Utf8Serializer(Serializer):
def dumps(self, value: object) -> bytes:
return str(value).encode("utf-8")Python prevents instantiation while abstract methods remain unimplemented. ABCs are useful when:
- Membership in the family should be explicit.
- A shared implementation or controlled extension point has value.
- Runtime
isinstancechecks are part of the design. - Subclasses must cooperate with a framework lifecycle.
The collections.abc module also provides standard behavioral ABCs such as
Iterable, Iterator, Sequence, Mapping, and Callable. Prefer these
well-known contracts to project-specific copies.
Protocol or ABC?
Choose a protocol when independent existing types should satisfy a contract and the consumer only needs behavior. Choose an ABC when nominal identity, runtime membership, or shared lifecycle is essential.
Neither is required for every dependency. A callable type may be enough:
from collections.abc import Callable
def retry(operation: Callable[[], str], attempts: int = 3) -> str:
if attempts < 1:
raise ValueError("attempts must be positive")
last_error: Exception | None = None
for _ in range(attempts):
try:
return operation()
except OSError as error:
last_error = error
assert last_error is not None
raise last_errorA first-class function avoids a one-method class while still supporting dependency injection.
Composition creates reusable systems
class Encoder(Protocol):
def encode(self, value: object) -> bytes: ...
class Transport(Protocol):
def send(self, payload: bytes) -> None: ...
class Publisher:
def __init__(self, encoder: Encoder, transport: Transport) -> None:
self._encoder = encoder
self._transport = transport
def publish(self, value: object) -> None:
self._transport.send(self._encoder.encode(value))The publisher coordinates two orthogonal capabilities. Implementations can vary independently, and tests can replace either boundary. This is often more flexible than subclasses for every encoder-transport combination.
Tradeoffs and pitfalls
- A narrow protocol is easy to implement but may require more composition.
- An ABC can provide tested shared behavior but couples subclasses to its design.
- Marker interfaces with no behavior usually add little in Python.
- A “god protocol” forces implementations to provide methods many consumers never use; split it by capability.
- Returning
Anyfrom an abstraction disables useful static checks. - Test doubles that behave unlike production dependencies create false confidence; encode important semantics in contract tests.
- Dependency injection does not require a container. Constructor parameters are often sufficient and easier to trace.
Practice
- Define a consumer-owned protocol for a clock returning the current datetime, then inject a deterministic fake.
- Implement the same serializer contract as a protocol and an ABC. State which coupling each version creates.
- Split an interface with read, write, delete, and admin methods into capability protocols.
- Replace a one-method strategy class with
Callableand compare type clarity.
LEARNING RECORD
Finish this lesson
Mark it complete when you can explain the main decision without looking.
KNOWLEDGE CHECK
Reusable abstractions, ABCs, and protocols
The data model and magic methods
Integrate custom objects with Python through attribute access, representation, equality, hashing, containers, and operators.
Decorators and closures
Capture lexical state, build correct wrappers, preserve metadata, and choose explicit alternatives when wrapping hides too much.