loading...

Recognizing Ducks

tmr232 profile image Tamir Bahar Updated on ・1 min read

After multiple attempts at finding a funny narrative that holds for the entire article and failing miserably, I decided to go with the technical parts alone. Enough of my colleagues found it interesting, so I guess it will hold up without the jokes.

Python gives us multiple ways to check that the objects we pass to functions are of the types we expect. Each method has its advantages and disadvantages.

Just not caring

The first option is naturally to not care about types - just write your code, and hope for the best.
This is a viable method, and is often employed. It is especially fitting in short snippets or scripts you don't expect to maintain much. It just works with no overhead whatsoever.

def poke(duck):
    duck.quack()
    duck.walk()

Inheritance

Another option, as common in OOP languages, is to use inheritance. We can define an Anas class, and expect all of its derivatives to be sufficiently duck-like.

class Anas:
    def quack(self): pass
    def walk(self): pass

class Duck(Anas):
    def quack(self): print('Quack!')
    def walk(self): print('Walks like duck.')

class Mallard(Anas):
    def quack(self): print('Quack!')
    def walk(self): print('Walks like duck.')

def poke(duck):
    assert isinstance(duck, Anas)

Interfaces

While inheritance kinda gets the job done, a robotic duck is definitely not of the genus Anas, while it does implement all the characteristics we care about. So instead of hierarchical inheritance, we can use interfaces.

from abc import ABC, abstractmethod

class IDuck(ABC):
    @abstractmethod
    def quack(self): pass

    @abstractmethod
    def walk(self): pass

class Duck(IDuck):
    def quack(self): print('Quack!')
    def walk(self): print('Walks like duck.')

class RoboticDuck(IDuck):
    def quack(self): print('Quack!')
    def walk(self): print('Walks like duck.')

def poke(duck):
    assert isinstance(duck, IDuck)

Great. And if we don't control the types, we can always write adapters.

The Duck Test

But this is Python. We can do better.

As we know, Python uses duck-typing. So we should be able to use the Duck Test for types. In our example, every object implementing quack() and walk() is a duck. That's easy enough to check.

def is_a_duck(duck):
    for attr in ('quack', 'walk'):
        if not hasattr(duck, attr):
            return False
    return True

def poke(duck):
    assert is_a_duck(duck)
    duck.quack()
    duck.walk()

This works. But we list the isinstance(...) call. We can surely do better.

Metaclasses & Subclass Hooks

Metaclasses are wonderful constructs. As their name may suggest, they take part in the construction of classes. They even allow us to set hooks into basic Python mechanisms, like isinstance(...), using __subclasshook__.

from abc import ABC

def is_a_duck(duck):
    for attr in ('quack', 'walk'):
        if not hasattr(duck, attr):
            return False
    return True

class DuckChecker(ABC):
    @classmethod
    def __subclasshook__(cls, C):
        if cls is not DuckChecker:
            return NotImplemented
        return is_a_duck(C)

def poke(duck):
    assert isinstance(duck, DuckChecker)
    duck.quack()
    duck.walk()

And we're back in business. That said, is_a_duck is still a stringly-typed mess, and gonna be very painful to maintain.

Wouldn't it be nice if we could just use our IDuck interface to check for duck-ness?

Abstract Methods, Again!

Lucky for us - we can!

Among other things, the ABC parent class enumerates all @abstractmethods and stores them in the __abstractmethods__ member variable. This means that we can easily enumerate them in our subclass hook and check for them.

from abc import ABC, abstractmethod

class IDuck(ABC):
    @abstractmethod
    def quack(self): pass

    @abstractmethod
    def walk(self): pass

    @classmethod
    def __subclasshook__(cls, C):
        if cls is not IDuck:
            return NotImplemented
        for attr in cls.__abstractmethods__:
            if not hasattr(C, attr):
                return False
        return True

class Duck:
    def quack(self): print('Quack!')
    def walk(self): print('Walks like a duck.')

def poke(duck):
    assert isinstance(duck, IDuck)
    duck.quack()
    duck.walk()

poke(Duck())  # Quack!
              # Walks like a duck.

Awesome. Next step - separating the interface from the checking logic.

Protocols

Reading through Python documentation and nomenclature, you might have seen the term "protocol" here and there. It is Python's way to call duck-typed interfaces. So you could say we just created a "protocol checker". Now, we can separate it into a base-class.

from abc import ABC, abstractmethod

class Protocol(ABC):
    _is_protocol = True
    @classmethod
    def __subclasshook__(cls, C):
        if not cls._is_protocol:
            return NotImplemented
        for attr in cls.__abstractmethods__:
            if not hasattr(C, attr):
                return False
        return True

class IDuck(Protocol):
    @abstractmethod
    def quack(self): pass

    @abstractmethod
    def walk(self): pass

And that's it. That little _is_protocol flag is there for good reason. Usually, we'd check protocol-ness using isinstance(...). In this case, however, we're hooking into that mechanism and that would lead to infinite recursion.

We can now use our Protocol base-class freely to create new protocols as we need them, with friendly interface-like syntax. We're almost done.

This Dog is a Duck

In some cases, the protocol checks may not be what we want. The obvious reasons coming to mind are:

  1. We can't really check the desired semantics using the protocol trick.
  2. We want to wreck havoc.

For those cases (well, mostly for the first one) the ABC base class provides another trick. Instead of defining __subclasshook__ to check the interface, we can simple register classes as valid, "virtual subclasses".

from abc import ABC

class IDuck(ABC): pass

class Duck:
    def quack(self): print('Quack!')
    def walk(self): print('Walk like a duck.')

IDuck.register(Duck)

def poke(duck):
    assert isinstance(duck, IDuck)
    duck.quack()
    duck.walk()

Remember that this method puts all the pressure on the programmer. Writing IDuck.register(Dog) is the equivalent of saying "I vouch for this dog to be a duck". It might pass inspection, but won't necessarily yield the desired results.

Summary

In this article we covered multiple ways of checking the "duck-ness" of objects. From belonging to the Anas genus, to just placing a sticker on their head saying "Duck!". Some of those methods are more useful or applicable than others, but I still think it worthwhile to be familiar with all of them. Additionally, there are many topics not covered here, like static type checking.

Further Reading

The metaclass techniques demonstrated here are simplified versions of code from the abc and typing modules. I highly recommend going through those modules and their respective docs, at least at a glance, to extend your knowledge and cover up any holes left by my hand-wavy explanation.

Discussion

pic
Editor guide