Interfaces in Python: duck typing, ABCs and Protocols
Python has no interface keyword, and the interview question underneath
“how do you define an interface?” is whether you know the three mechanisms it
has instead, and which one to reach for. Type-checker mechanics for Protocol
are in
Protocol and structural typing;
this note is the OOP decision.
Three ways to say “anything with a .read()”
| Mechanism | Checked | Contract lives |
|---|---|---|
| Duck typing | at call time, by failing | nowhere |
| ABC | at instantiation, by inheritance | in the base class |
| Protocol | at type-check time, by shape | in the type |
Duck typing is the default and the oldest: call .read() and let it
raise if the object cannot. It costs nothing and documents nothing. The
failure surfaces wherever the method is first used, which may be deep inside
a library and long after the wrong object was passed in.
An ABC
An ABC makes the contract explicit and enforced. Subclass it, and forgetting a method fails at instantiation rather than at first use:
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def read(self, key: str) -> bytes: ...
@abstractmethod
def write(self, key: str, data: bytes) -> None: ...
class Broken(Storage):
def read(self, key): return b""
Broken() # TypeError: Can't instantiate abstract class Broken
# with abstract method writeThe cost is nominal typing: a class is a Storage only if it inherits from
Storage. A third-party class with the right methods is not one, and you
cannot change its bases.
A Protocol
A Protocol is the contract without the inheritance. Any object with the right methods satisfies it, checked statically:
from typing import Protocol
class Readable(Protocol):
def read(self, key: str) -> bytes: ...
def load(store: Readable, key: str) -> bytes:
return store.read(key)
load(boto3.client("s3"), "k") # type error: no read(key) - caught by mypy
load(MyStore(), "k") # fine, and MyStore never heard of ReadableThat second call is the whole argument. The consumer declares what it needs; the provider does not have to know. This is dependency inversion without an import in either direction, which is why Protocols are the right default for boundaries between packages and for anything you will want to fake in a test.
Choosing
Reach for an ABC when you own the hierarchy and want shared
implementation — a base class with a real close() that three subclasses
inherit, plus two abstract methods each must fill. abc also gives you
register() for declaring a virtual subclass you cannot edit.
Reach for a Protocol when the thing is defined by what it can do rather than what it is: a callback, a repository interface, a plugin, a file-like object. Most “interfaces” in application code are this kind.
Reach for plain duck typing in a script or a function that will never be called with anything else. Adding a Protocol to a ten-line helper is ceremony.
# The plugin case: a Protocol at the boundary, registration by name.
class Exporter(Protocol):
name: str
def export(self, rows: list[dict]) -> bytes: ...
EXPORTERS: dict[str, Exporter] = {}
def register(exporter: Exporter) -> Exporter:
EXPORTERS[exporter.name] = exporter # type-checked against the shape
return exporterThe interfaces Python already defines
Most of the language’s own contracts are informal protocols on dunder
methods, and collections.abc gives them names you can check against:
| You implement | You are a |
|---|---|
__iter__, __next__ |
Iterator |
__enter__, __exit__ |
context manager |
__call__ |
Callable |
__len__, __getitem__ |
Sequence-like |
__hash__, __eq__ |
Hashable |
isinstance(x, collections.abc.Iterable) works on any class with __iter__
even though it never subclassed anything — the ABC implements
__subclasshook__ to check the shape. That is structural typing predating
Protocol by a decade, and it is the precedent to cite when someone says
Protocols are un-Pythonic.
Where it breaks in production
isinstanceagainst a Protocol without@runtime_checkableraises. With it, the check only confirms method names exist — not signatures — so a class withread(self)passes forread(self, key)and fails later.- ABC across a package boundary. The other package must import your base class to inherit from it, coupling their release to yours. A Protocol removes the import.
- Protocol with default implementations. You can put a method body in a Protocol, but implementers only get it if they explicitly subclass the Protocol, which defeats the point. Shared behaviour wants an ABC or a mixin.
- Optional methods. Neither mechanism expresses “has
flush()if it can”. Split into two Protocols and check for the narrower one where it matters. - Duck typing failures deep in a call stack. The
AttributeErrorfires three libraries down from the mistake. A Protocol at the boundary moves that to type-check time.
Related
Interview angle 4
- “How do you define an interface in Python?” - three ways, chosen by who owns what. An ABC when you own the hierarchy and want shared code; a Protocol when the contract is a shape and the implementer should not have to know about you; plain duck typing when nothing else will ever be passed.
- “Protocol or ABC?” - Protocol at boundaries and for anything you will fake in tests, because the consumer declares the need and the provider stays ignorant of it. ABC when subclasses share real implementation. Follow-up: can a Protocol carry a default method? Only for explicit subclasses, which defeats it - that is the ABC case.
- “Isn’t structural typing un-Pythonic?” -
collections.abchas done it since 2.6:isinstance(x, Iterable)passes for any class with__iter__.Protocolgave the type checker what the runtime already did. - “What does
@runtime_checkableactually check?” - that the method names exist. Not signatures, not return types. A class with the wrong arity passesisinstanceand fails on the call.