Protocols and ABCs
Protocols and ABCs
A protocol is a shape: “this object has send.” An ABC (abstract base class) is a family: “this object declares it is a TicketSink.” The boring default for a desk seam is a small Protocol. Use an ABC when you want a shared base, isinstance that requires inheritance, or a place to put real helper methods. Do not build a class tree so a test can pass a fake.
Mental model
Python was always duck typed: if it has send, you can call send. Structural typing is that idea written down. typing.Protocol lists methods. A checker accepts any object that has them, whether or not it subclasses the protocol.
Nominal typing is inheritance. abc.ABC with @abstractmethod refuses to construct a subclass that forgot the method. isinstance(x, TicketSink) is true only if TicketSink is in the type’s MRO (or was registered). A printer that happens to have send is not an ABC sink until it subclasses.
| Protocol | ABC | |
|---|---|---|
| Match | structure (methods present) | name (subclass) |
| Tests | a fake with the methods is enough | fake must inherit or register |
isinstance |
only if @runtime_checkable |
yes |
| Forgotten method | checker error; runtime still constructs | TypeError on Broken() |
Keep the protocol tiny. One method is a seam. Twelve methods is a god object with extra steps.
Worked examples
Case 1: a TicketSink protocol
Save as ticket_sink_protocol.py. Printer and MemorySink do not inherit. Both satisfy TicketSink because they have send.
# ticket_sink_protocol.py
from typing import Protocol
class TicketSink(Protocol):
def send(self, ticket_id: int, message: str) -> None: ...
class Printer:
def send(self, ticket_id: int, message: str) -> None:
print(f"#{ticket_id} {message}")
class MemorySink:
def __init__(self) -> None:
self.lines: list[str] = []
def send(self, ticket_id: int, message: str) -> None:
self.lines.append(f"#{ticket_id} {message}")
def fire(sink: TicketSink, ticket_id: int) -> None:
sink.send(ticket_id, "fired")
def main() -> None:
printer = Printer()
memory = MemorySink()
fire(printer, 7)
fire(memory, 8)
print(memory.lines)
if __name__ == "__main__":
main()Run:
uv run python ticket_sink_protocol.pyOutput:
#7 fired
['#8 fired']
The ... body is the protocol’s way of saying “no implementation.” A checker uses the signature. The runtime never calls TicketSink.send.
MemorySink is the fake you want in a test: it records lines. No mock library. No subclass.
Case 2: the same seam as an ABC
Save as ticket_sink_abc.py. Printer must inherit. NotASink has the method and still fails isinstance.
# ticket_sink_abc.py
from abc import ABC, abstractmethod
class TicketSink(ABC):
@abstractmethod
def send(self, ticket_id: int, message: str) -> None:
raise NotImplementedError
class Printer(TicketSink):
def send(self, ticket_id: int, message: str) -> None:
print(f"#{ticket_id} {message}")
class NotASink:
def send(self, ticket_id: int, message: str) -> None:
print(f"#{ticket_id} {message}")
def fire(sink: TicketSink, ticket_id: int) -> None:
sink.send(ticket_id, "fired")
def main() -> None:
fire(Printer(), 7)
print(isinstance(Printer(), TicketSink))
print(isinstance(NotASink(), TicketSink))
if __name__ == "__main__":
main()Run:
uv run python ticket_sink_abc.pyOutput:
#7 fired
True
False
fire(NotASink(), 7) still runs. Annotations are not runtime checks. isinstance is the ABC’s actual gate. If you needed that gate, you wanted an ABC. If you only needed a checker and a test fake, you wanted a protocol.
Case 3: an incomplete ABC fails at construction
Save as abc_incomplete.py. Forgetting send is a TypeError when you call Broken(), not later when you call send.
# abc_incomplete.py
from abc import ABC, abstractmethod
class TicketSink(ABC):
@abstractmethod
def send(self, ticket_id: int, message: str) -> None:
raise NotImplementedError
class Broken(TicketSink):
pass
def main() -> None:
try:
Broken()
except TypeError as e:
print(e)
if __name__ == "__main__":
main()Run:
uv run python abc_incomplete.pyOutput:
Can't instantiate abstract class Broken without an implementation for abstract method 'send'
That is the ABC’s best feature: a missing method is loud. A protocol cannot do this at runtime without extra tools.
Case 4: isinstance on a protocol, if you must
Save as runtime_sink.py. @runtime_checkable makes isinstance look at method names, not the MRO. It does not check signatures.
# runtime_sink.py
from typing import Protocol, runtime_checkable
@runtime_checkable
class TicketSink(Protocol):
def send(self, ticket_id: int, message: str) -> None: ...
class Printer:
def send(self, ticket_id: int, message: str) -> None:
print(f"#{ticket_id} fired")
def main() -> None:
p = Printer()
print(isinstance(p, TicketSink))
p.send(7, "fired")
if __name__ == "__main__":
main()Run:
uv run python runtime_sink.pyOutput:
True
#7 fired
Use this sparingly. A function that takes TicketSink already told the checker. Runtime isinstance against a protocol is a sniff test, not a proof.
The trap
An ABC hierarchy so every sink shares log, retry, format, and send. Tests now need a stub subclass. A protocol with those four methods is the same trap in structural clothing.
Keep TicketSink at send. Logging belongs in the printer, or in a wrapper function. If two sinks share a chunk of real code, a function is enough. An ABC with a concrete helper is fine when the helper is small and the subclasses are yours.
The other trap: using an ABC because a tutorial started with class Animal(ABC). A desk ticket is not a taxonomy.
The boring rule
- Default seam: a
Protocolwith one or two methods. - Tests: a real tiny class with those methods, not a mock of a concrete printer.
- ABC when you own the subclasses and you want construction to fail if a method is missing.
@runtime_checkableonly when some code path truly needsisinstance.- Do not require inheritance so a checker will accept a fake.
- Do not put twelve methods on either kind of interface.
Try this
- Add a
FileSinkclass toticket_sink_protocol.pythat appends a line to a list namedlines(same asMemorySink). Callfireon it. You do not need a new protocol. - Make
NotASinksubclassTicketSinkinticket_sink_abc.pyand confirmisinstancebecomesTrue. - Add a second abstract method
close(self) -> Noneto the ABC. SeePrinter()fail until you implementclose. - Drop
@runtime_checkablefromruntime_sink.pyand wrapisinstanceintry/except TypeError. Print the exception — instance checks need the decorator.