DEV Community

Cover image for Practical applications of protocols in Python
Hugh Jeremy
Hugh Jeremy

Posted on

Practical applications of protocols in Python

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']
        )
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]

Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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)