There's no such thing as a free lunch. In object-oriented code, protocols offer a very close approximation. In this context, a protocol is a set of behaviours that, if exhibited by a type, guarantee certain other behaviours.
For example, consider a hypothetical class,
class Human: def __init__( self, name: str, unique_id: int ) -> None: self.name = name self.unique_id = unique_id return @classmethod def decode(cls: Type[H], data: Any) -> H: return cls( name=data['name'], unique_id=data['unique_id'] )
This class has an initialiser and a single class method, which we will assume is used to decode deserialised representations of Humans. For example, when they are retrieved from a database.
Suppose we want to be able to decode many
Human instances at once. Perhaps our database returns lists of
Human. We could add a new class method:
# continues `Human` definition from above @classmethod def decode_many(cls: Type[H], data: Any) -> List[H]: return [cls.decode(h) for h in data]
Easy enough. We can now call
Human.decode() on single instances, and
Human.decode_many() on lists.
Consider that nothing in the
.decode_many() function body depends on the actual implementation of
.decode() method is completely opaque to
.decode_many() does not need to be aware of the
.decode() implementation in order to do its job perfectly.
With that in mind, consider a second class,
class Email: def __init__( self, body: str, confirmed: bool ) -> None: self.body = body self.confirmed = confirmed return @classmethod def decode(cls: Type[E], data: Any) -> E: return cls( body=data['body'], confirmed=data['confirmed'] ) @classmethod def decode_many(cls: Type[E], data: Any) -> List[E]: return [cls.decode(e) for e in data]
It should be noticed that
Email.decode_many() is identical to
Human.decode_many(). We could write
.decode_many() in a generic form as follows:
def decode_many( a: Type[ATypeWithDecodeMethod], b: Any ) -> ATypeWithDecodeMethod: return [a.decode(c) for c in b]
In english: "Take any type having a
.decode() method, and call that method with all instances of data in this array of data, and return the resulting array"
.decode() method is a behaviour which, if exhibited by a type, guarantees another behaviour: The ability to be fed to
.decode_many(). We can use the word "protocol" to describe the requirement to have a
.decode() method, and a class exhibiting that requirement is said to conform to the protocol.
We can write such a protocol like so:
class Decodable: @classmethod def decode(cls: Type[D], data: Any) -> D: raise NotImplementedError @classmethod def decode_many(cls: Type[D], data: Any) -> List[D]: return [cls.decode(d) for d in data]
Human definition already has a
.decode() method. As such, in practice, it already conforms to the
Decodable protocol. We can identify is as conformant, such that it gains access to
class Human(Decodable): # definition remains otherwise unchanged from that # presented earlier in this article.
Now, we can call
Human.decode_many() and receive a list of
Human instances in return, despite the definition of
Human not including any
.decode_many() method. We can do the same with
This is a good time to pause consider the elephant in the room: What makes a "protocol" different from regular old class inheritance? There's no formal definition of a protocol in Python, so how can
Decodable be said to be a protocol?
The answer is that in this context, a "protocol" is what you as the programmer define it to be, in your own mind. It just so happens that Python's
class keyword provides the behaviours necessary to implement protocol relationships in Python code.
Some languages give explicit protocol tools: Swift has
protocol, Java has
abstract, and C# has
interface. Each one differs in nuanced ways, but all seek to identify code which defines behaviours that, if exhibited by a type, guarantee certain other behaviours.
A key to the effectiveness of protocols in Python is that the protocol should not have an initialiser. Any class adopting a protocol should be able to do so while retaining full responsibility for and control over its own initialisation.
Just like static type checking, property immutability, or any other Python paradigm, it is up to you whether you want to obey the rules you define for your protocols.
True joy arrives when we decide we want
Decodable objects to be able to do more stuff. Any new capabilities we add to the
Decodable protocol that do not change the protocol requirements are essentially free.
For example, suppose that our database may return optional data. That is, in response to a query we may receive some data or we may receive
.decode() method definition does not make such allowances. If it is fed
None it will crash.
We can add a new method like so:
# Continues the `Decodable` definition from above @classmethod def optionally_decode(cls: Type[D], data: Any) -> Optional[D]: if data is None: return None return cls.decode(data)
# Continues the `Decodable` definition from above @classmethod def deserialise(cls: Type[D], serial: str) -> D: return cls.decode(json.loads(serial))
Again, without touching
Human, we have added new capabilities to both. Not quite a free lunch, but so easy that we could call it one.