Practical applications of protocols in Python
Tags: python, protocols, tutorial, programming
Published: 2020-06-24 03:54:15

---
title: Practical applications of protocols in Python
published: true
description: Take your free lunch.
tags: python, protocols, tutorial, programming
cover_image: https://dev-to-uploads.s3.amazonaws.com/i/ffipyx7ouovvknpml2fq.jpeg
---

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`:

```python
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:

```python
# 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:

```python
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:

```python
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:

```python
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()`:

```python
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:

```python
# 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:

```python
# 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.