You are writing exchange(codec, text), which encodes some text and decodes
it again. Your team has written a few codecs, and another library supplies
more. You also want to give your own codecs a shared roundtrip helper.
Should Codec be a protocol or an ABC? Here you want both, one for each job.
from abc import ABC, abstractmethod
from typing import Protocol
class Codec(Protocol):
def encode(self, text: str) -> str: ...
def decode(self, blob: str) -> str: ...
class BaseCodec(ABC):
@abstractmethod
def encode(self, text: str) -> str: ...
@abstractmethod
def decode(self, blob: str) -> str: ...
def roundtrip(self, text: str) -> str:
return self.decode(self.encode(text))
class ReverseCodec(BaseCodec):
def encode(self, text: str) -> str:
return text[::-1]
def decode(self, blob: str) -> str:
return blob[::-1]
def exchange(codec: Codec, text: str) -> str:
return codec.decode(codec.encode(text))
print(exchange(ReverseCodec(), "hello")) # hello
print(ReverseCodec().roundtrip("hello")) # hello
A protocol says what the caller needs
Codec is a protocol. It lists the two methods that exchange calls. As the
first article in this track showed, any object with matching encode and
decode methods fits it. Its class does not have to inherit from Codec.
So a codec from another library works with exchange as it is.
An ABC shares code and makes subclasses define the rest
BaseCodec is an ABC, an abstract base class from the abc module. ABCs
use nominal typing, which matches by name. An object counts as a BaseCodec
only if its class inherits from it.
An ABC does two jobs. Every subclass inherits the ABC's ordinary methods,
such as roundtrip. And @abstractmethod marks the methods that each subclass must
define for itself. Python refuses to create an instance of a subclass while
any abstract method is still missing:
class HalfCodec(BaseCodec):
def encode(self, text: str) -> str:
return text
HalfCodec()
# TypeError: Can't instantiate abstract class HalfCodec without an
# implementation for abstract method 'decode'
Python makes that check when you create the object, and it looks only at
method names. mypy does more. It reports HalfCodec() before you run
anything, and it checks that each method's signature matches the base class.
Use both, one for each job
ReverseCodec inherits from BaseCodec, so it gets roundtrip without
writing it. It also fits Codec, because it has both methods. exchange
asks for Codec, so it accepts your codecs and a library's codecs alike.
roundtrip is not part of Codec, so exchange cannot call it. That is on
purpose. List in the protocol only the methods its callers use, and put
helpers on the base class.
Two common mistakes
Asking for the ABC when you only need its methods. Suppose exchange
took codec: BaseCodec. Then mypy would reject a library's VendorCodec,
even though it has matching encode and decode methods:
Argument 1 to "exchange" has incompatible type "VendorCodec"; expected "BaseCodec"
Accept the protocol instead.
Expecting a protocol to supply code. A class that only matches Codec
gets no methods from it. To share code, use inheritance. A later exercise in
this track shows that a class can also inherit from a protocol, and get the
protocol's method bodies that way.
Check your understanding
send_all(sink, messages) only calls sink.write(text), so should sink be your team's BaseSink ABC or a protocol?
Show answer
Hide answer
A protocol with one write method. send_all only calls write, so
asking for BaseSink would reject a test double or a library class that has
a matching write. Your own sinks can still inherit from BaseSink to get
its logging helpers, and they fit the protocol as well.
Choose by what the caller needs
| You want to | Use |
|---|---|
| Accept any object that has the right methods | A protocol |
| Share code, and make subclasses define certain methods | An ABC |
| Both | A protocol for callers, and an ABC that your own classes inherit |
Going further
Before you write a protocol, check whether the standard library already has
one. Python 3.14 added io.Reader[T] and io.Writer[T] for simple streams:
from io import BytesIO, Reader
def read_prefix(source: Reader[bytes]) -> bytes:
return source.read(4)
print(read_prefix(BytesIO(b"abcdef"))) # b'abcd'
SomeABC.register(SomeClass) makes isinstance(obj, SomeABC) return True
for a class that does not inherit from the ABC. This is virtual
registration. The class gets none of the ABC's methods, Python does not check
that it defines the abstract ones, and mypy ignores the registration.
Some ABCs in collections.abc answer isinstance by looking for a method,
through a hook called __subclasshook__. Iterable looks for __iter__.
That check can disagree with iter(). A class that defines only
__getitem__ works with iter(), yet isinstance(obj, Iterable) is False
for it.
See the typing specification for protocols
and the abc module documentation.