« Back to Index

[Python Interfaces via Protocols and Abstract Base Classes (with Metaclasses)]

View original Gist on GitHub

Tags: #python #interfaces #protocols #design #collections #abc #iterator #sized #metaclasses #abstract

Python Interfaces via Protocols and Abstract Base Classes (with Metaclasses).md

Python is a strongly typed dynamic language, and so it has no support for the interface keyword.

Note: some languages are weakly typed (JavaScript), some are strongly typed (Python) and some are statically typed (Go, Rust). Being strongly typed means you can’t perform operations inappropriate to the type, so for example: in Python you can’t add a number typed variable with a string typed variable.

Instead, Python provides ‘protocols’ which are a bit like the interface support in Go. They’re not strictly enforced, but if you implement specific magic methods you’ll find a selection of builtin Python functions become available to use on objects they otherwise wouldn’t necessarily support.

Example

This code doesn’t work:

class Team:
     def __init__(self, members):
         self.members = members

t = Team(['foo', 'bar', 'baz'])

t.members  # ['foo', 'bar', 'baz']

len(t)  # TypeError: object of type 'Team' has no len()

But if we implement the __len__ magic method, we are now telling Python that we support the Sized protocol:

class Team:
     def __init__(self, members):
         self.members = members
         
     def __len__(self):
         return len(self.members)

t = Team(['foo', 'bar', 'baz'])

t.members  # ['foo', 'bar', 'baz']

len(t)  # 3

There are many different protocols, such as: collections.abc.Iterator which if we were to implement the __iter__ and __next__ magic methods, then we’d be able to use a for loop construct on our object:

class Team:
    def __init__(self, members):
        self.members = members

    def __iter__(self, max=0):
        self.n = 0
        return self

    def __next__(self):
        if self.n < len(self.members):
            index = self.n
            self.n += 1
            return self.members[index]
        else:
            raise StopIteration

t = Team(['foo', 'bar', 'baz'])

for member in t:
    print(f't member: {member}')

t member: foo
t member: bar
t member: baz

Abstract Base Classes (ABCs)

Protocols are useful but sometimes you require something that does indeed behave more like a traditional ‘interface’, and that’s where ABCs can help us.

They are best explained by way of an example:

import abc

class Foo(abc.ABC):
    @abc.abstractmethod
    def bar(self):
        pass
        
class Thing(Foo):
    pass
    
t = Thing()  # TypeError: Can't instantiate abstract class Thing with abstract methods bar

Note: we’re subclassing directly from abc.ABC where that parent class is setting abc.ABCMeta as a metaclass on itself (e.g. something like class ABC(metaclass=ABCMeta)) and so we could do that directly with our Foo class like so: class Foo(metaclass=abc.ABCMeta). Read below for more information on metaclasses.

So we can see from the above example code that we’ve enforced the Foo ‘interface’ onto the Thing object. If we want Thing to truly be a type of Foo, then it’ll need to provide a concrete implementation of the bar method that the Foo ‘interface’ has defined, like so:

import abc

class Foo(abc.ABC):
    @abc.abstractmethod
    def bar(self):
        pass
        
class Thing(Foo):
    def bar(self):
        print('this is bar, i am a foo type')
    
t = Thing()  # no exception raised

t.bar()  # this is bar, i am a foo type

We can now also compare types using: isinstance(t, Foo). Meaning if you have two classes and both of them implement the bar method, it doesn’t automatically mean they are compatible types:

import abc

class Foo(abc.ABC):
    @abc.abstractmethod
    def bar(self):
        pass

class Bar(abc.ABC):
    @abc.abstractmethod
    def bar(self):
        pass
        
class ThingA(Foo):
    def bar(self):
        print('this is bar, i am a foo type')
        
class ThingB(Bar):
    def bar(self):
        print('this is bar, i am a bar type')
    
ta = ThingA()
tb = ThingB()

isinstance(ta, Foo)  # True
isinstance(ta, Bar)  # False

isinstance(tb, Foo)  # False
isinstance(tb, Bar)  # True

Do ABCs give us the full benefit of traditional interfaces? No. But they do help us move closer in that direction.

Metaclasses

Metaclasses define default behaviours for an instance of a class.

Doing this manually would look something like this:

def new(cls):
    x = object.__new__(cls)
    x.attr = 100
    return x
    
class Foo:
    pass

Foo.__new__ = new

f = Foo()
f.attr  # 100

But this can also be done using a new ‘meta’ class and the metaclass keyword argument:

class Meta(type):
    def __new__(cls, name, bases, dct):
        x = super().__new__(cls, name, bases, dct)
        x.attr = 100
        return x
        
class Foo(metaclass=Meta):
    pass
    
f = Foo()
f.attr  # 100

In the same way that a class functions as a template for the creation of objects, a metaclass functions as a template for the creation of classes. Metaclasses are sometimes referred to as class factories.

Do you need a metaclass for this silly example of ensuring the attr attribute is assigned to a unique class type? No. You could just use a class level variable:

class Base:
    attr = 100

class X(Base):
    pass

class Y(Base):
    pass

class Z(Base):
    pass
    
X.attr  # 100
Y.attr  # 100
Z.attr  # 100

But it’s good to know about metaclasses as they underpin the collections.abc implementation of abc.ABCMeta.

References