Python Type Hints - Duck typing with Protocol

2021-05-18 Quack to the future.

Duck typing says “if it quacks like a duck, treat it like a duck.” Type checking seems at odds with duck typing: we restrict variables to named types (classes), allowing only that type or subtypes. This seems like it would stop us from passing in arbitrary objects that can “quack the right way”. But since PEP 0544, we can declare “duckish” types with typing.Protocol.

Take this example:

class Duck:
    def quack(self) -> str:
        return "Quack."


def sonorize(duck: Duck) -> None:
    print(duck.quack())


sonorize(Duck())

Here we have a vanilla Duck class and a function that accepts that class. sonorize() is quite restrictive, and could better serve users by being duck-typed. It could accept anything with a correctly typed quack() method and function just fine.

The way to specify this is with a typing.Protocol type. A protocol type contains a set of typed methods and variables. If an object has those methods and variables, it will match the protocol type.

typing.Protocol was added in Python 3.8. On prior versions of Python, you can use typing_extensions.Protocol, and migrate when you upgrade. Mypy has supported Protocol since version 0.530 (October 2017).

In our example, we can define a protocol for objects that can quack:

from typing import Protocol


class Quacker(Protocol):
    def quack(self) -> str:
        ...

We use class syntax to define a protocol, and add relevant members in the body with type hints. The methods will never be executed, so it’s standard to use Python’s ellipsis symbol for their bodies.

With the protocol defined, we can use it in sonorize() to match any object with a quack() method that returns a string. To combine it into a full example:

from typing import Protocol


class Quacker(Protocol):
    def quack(self) -> str:
        ...


class Duck:
    def quack(self) -> str:
        return "Quack."


class MegaDuck:
    def quack(self) -> str:
        return "QUACK!"


def sonorize(duck: Quacker) -> None:
    print(duck.quack())


sonorize(Duck())
sonorize(MegaDuck())

Great!

The Python documentation calls use of Protocol structural subtyping, meaning the type checker compares types based upon their internal structure. This is opposed to nominal subtyping, meaning that the type checker compares types based on whether they are the named type, or a subclass.

The abstract base classes in Python’s collections.abc module are useful for common protocol checks. For example, you can use Sized to accept any type that works with len(), and Iterable[int] to accept any iterable of integers.

Fin

May types help you get all your ducks in a row,

—Adam


Want better tests? Check out my book Speed Up Your Django Tests which teaches you to write faster, more accurate tests.


Subscribe via RSS, Twitter, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: mypy, python