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, Human
:
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 Human
. The .decode()
method is completely opaque to .decode_many()
, and .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, Email
. Suppose it is defined as follows:
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"
Having a .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]
Our original 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 .decode_many()
:
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 Email
, such that we are not duplicating code.
A Note on Formality
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.
Extending 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 None
. Our .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)
Now both Human
and Email
may be optionally decoded, and we did not even have to touch their definitions. Let's go even further. Suppose we want to be able to load Human
and Email
from raw JSON-serialised 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 Email
or Human
, we have added new capabilities to both. Not quite a free lunch, but so easy that we could call it one.
Top comments (0)