Python Atlas
Core language

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 isinstance checks 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_error

A 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 Any from 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

  1. Define a consumer-owned protocol for a clock returning the current datetime, then inject a deterministic fake.
  2. Implement the same serializer contract as a protocol and an ABC. State which coupling each version creates.
  3. Split an interface with read, write, delete, and admin methods into capability protocols.
  4. Replace a one-method strategy class with Callable and 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

1What must a class do to satisfy a normal `Protocol` during static type checking?
2Which requirement most strongly favors an abstract base class over a protocol?