Ducktyping & Protocols

ยท 2 minute read

When writing a code, there are often times, where you need some function to operate on some object independetly without worrying about its type or what class excatly it is - just accessing some attributes or calling some methods. In dynamically typed languages, this is called ducktyping.

Ducktyping ๐Ÿ”—

“If it looks like a duck and quacks like a duck, it’s a duck.”

Since Python is dynamically typed, this is actually very easy to do:

"""Python3 ducktyping demo"""
from typing import Any

class Duck(object):
    def __init__(self, name):
        self.name = name

    def get_sound(self):
        return "Quack!"


def make_sound(animal):
    """Print the sound of the animal."""
    print(f"{animal.name} does '{animal.get_sound()}'")

if __name__ == "__main__":
    make_sound(Duck("Duck named Bob"))

The result when we run this script:

> python ducktyping.py
Duck named Bob does 'Quack!'

Cool! Excatly what we wanted. In fact, this will work for any object with .name attribute and .make_sound() callable. However, if we try to run this with some object other than the ones we’re ready for, exception is going to be raised:

"""Python3 ducktyping demo"""
from typing import Any

class Duck(object):
    def __init__(self, name):
        self.name = name

    def get_sound(self):
        return "Quack!"

class Stone(object):
    def __init__(self, name):
        self.name = name

def make_sound(animal):
    """Print the sound of the animal."""
    print(f"{animal.name} does '{animal.get_sound()}'")

if __name__ == "__main__":
    make_sound(Duck("Duck named Bob"))
    # Notice the new make_sound call with a Stone object,
    # which does not have a `get_sound` method:
    make_sound(Stone("ROCKy Balboa"))

Running this gives us:

> python ducktyping.py
Duck named Bob does 'Quack!'
Traceback (most recent call last):
  File "py3.py", line 21, in <module>
    make_sound(Stone("ROCKy Balboa"))
  File "py3.py", line 17, in make_sound
    print(f"{animal.name} does '{animal.make_sound()}'")
AttributeError: 'Stone' object has no attribute 'make_sound'

Ouch… The editor did not raise any warning, running the code was fine right until we encountered the second make_sound() call. But hey, these are the struggles of dynamically typed, interpreted languages. But what if we wanted to catch some of these errors before they actually happen? Is there a way to prevent this? Of course there is! Here’s where static type checkers come in! Actually, there was one available all along, we just didn’t use it ยฏ\_(ใƒ„)_/ยฏ. Since Python3.5, there’s a mypy bundled with Python distribution.

Let’s try to add some type annotations to this function to make our code less error-prone. First of, let’s try to add type annotation for the first parameter and return value. The type annotation itself will be just the Duck class:

def make_sound(animal: Duck) -> None:

This will be the new make_sound function declaration. Since the make_sound function does not return anything, we will say it returns None. Let’s try to run mypy from command line and provide the file with the code:

> mypy ducktyping.py
ducks_py3.py:21: error: Argument 1 to "make_sound" has incompatible type "Stone"; expected "Duck"

Woah! mypy just told us that we’re trying to call make_sound on a class Stone, which is not compatible with Duck! Neat. But don’t be fooled, this does not fix our problem. In fact, the code still works the same way:

> python ducktyping.py
Duck named Bob does 'Quack!'
Traceback (most recent call last):
  File "py3.py", line 21, in <module>
    make_sound(Stone("ROCKy Balboa"))
  File "py3.py", line 17, in make_sound
    print(f"{animal.name} does '{animal.make_sound()}'")
AttributeError: 'Stone' object has no attribute 'make_sound'

We still get the same error, but with mypy, we’re able to discover it before executing the code.

Protocols ๐Ÿ”—

But what if we try to add some other class that implements the name attribute and make_sound() method? Let’s implement a Dog class:

class Dog(object):
    def __init__(self, name):
        self.name = name

    def get_sound(self):
        return "Woof!"

and call the some function make_sound() on Dog("Doggo"):

> mypy ducktyping.py
ducks_py3.py:28: error: Argument 1 to "make_sound" has incompatible type "Dog"; expected "Duck"

This happened because mypy looks and the type annotation, sees that the expected type is a Duck, actual type is a Dog and evaluates it as incompatible. OOP fans might come up with a fairly simple solution - inheritance (also called nominal subtyping). This would work, but there is a bit simpler approach - structural subtyping. Instead of designing pretty complicated class hierarchy and then using the most abstract class as the type annotation, we can define a protocol - a class that just states all the attributes and methods we expect. This is the equivalent of static duck typing. Let’s add an AnimalProtocol class, which specifies all the attributes and methods we will be working with:

"""Python3 ducktyping demo"""
from typing_extensions import Protocol

class AnimalProtocol(Protocol):
    # notice the type annotation for `name` attribute
    name: str

    def make_sound(self) -> str:
        # we can omit the body of this method
        ...

class Duck(object):
    def __init__(self, name: str) -> None:
        self.name = name

    def make_sound(self) -> str:
        return "Quack!"

class Dog(object):
    def __init__(self, name: str) -> None:
        self.name = name

    def make_sound(self) -> str:
        return "Woof!"

def make_sound(animal: AnimalProtocol) -> None:
    """Print the sound of the animal."""
    print(f"{animal.name} does '{animal.make_sound()}'")

if __name__ == "__main__":
    make_sound(Duck("Duck named Bob"))
    make_sound(Dog("Doggo"))

Notice that we changed the type annotation of make_sound() function:

# previous declaration:
def make_sound(animal: Duck) -> None:

# current declaration:
def make_sound(animal: AnimalProtocol) -> None:

Running mypy on this code will not result in any error messages.

Writing Python2 compatible protocols ๐Ÿ”—

Since Protocols were introduced in Python3, we have to come up with a workaround when doing structural subtyping in Python2. Luckily, this can be done using abstract metaclasses:

"""AnimalProtocol in Python2"""
from abc import ABCMeta, abstractmethod

@add_metaclass(ABCMeta)
class AnimalProtocol(object):
    """Animal Protocol in Python2."""

    name = None  # type: str

    def make_sound(self):
        # type: () -> str
        return