ruk·si

🐍 Python
Abstract Base Classes

Updated at 2018-06-11 00:56

Abstract base classes (ABCs) define interfaces in Python.

  • Use the word Base as prefix or suffix in the class name e.g. BaseService if it makes sense. Avoid using the Base if there is more logical naming e.g. Animal > Snake.
  • Instantiating the abstract base class should crash.
  • Using an unimplemented base class method on the subclass should fail.

Use abc module to define your abstract classes. Your classes will generate a lot more informative errors.

  • Python 3.4+ => extend abc.ABC
  • Python 3 => extend metaclass=abc.ABCMeta
  • Python 2 => add __metaclass__ = abc.ABCMeta class attribute

Creating ABCs is for framework builders, not for everyday development. The risk of over-engineering with ABCs is very high. Use duck typed interfaces instead by implementing specific methods e.g. __getitem__ and __len__ for sequences.

Extend from standard library abstract base class if it implements the interface.

from collections import MutableSequence
from typing import NamedTuple

class Card(NamedTuple):
    rank: str
    suit: str

class Deck(MutableSequence):
    ranks = [str(n) for n in range(2, 11)] + list('JQKA')
    suits = 'spades diamonds clubs hearts'.split()

    def __init__(self) -> None:
        self._cards = [Card(rank, suit)
                       for rank in self.ranks
                       for suit in self.suits]

    def __len__(self) -> int:
        return len(self._cards)

    def __getitem__(self, position: int) -> Card:
        return self._cards[position]

    def __setitem__(self, position: int, card: Card) -> None:
        self._cards[position] = card

    def __delitem__(self, position: int) -> None:
        del self._cards[position]

    def insert(self, position: int, card: Card) -> None:
        self._cards.insert(position, card)

d = Deck()
assert d[0].rank == '2'
d.reverse()
assert d[0].rank == 'A'

assert len(d) == 52
assert d.pop().rank == '2'
assert len(d) == 51

Python checks abstract class restrictions only when you try to initiate the class.

from collections import MutableSequence

class BadDeck(MutableSequence):

    def __init__(self) -> None:
        self._cards = []

d = BadDeck()
# TypeError: Can't instantiate abstract class BadDeck with
# abstract methods __delitem__, __getitem__, __len__, __setitem__, insert

Subclass hooks allow implementing an interface without specific declaration. Note that not all ABC provide a subclass hook. Rarely useful, but good to know

import abc

class Foobar(abc.ABC):

    @abc.abstractmethod
    def __len__(self) -> int:
        return 0

    @classmethod
    def __subclasshook__(cls, c: type) -> bool:
        if cls is Foobar:
            if any('__len__' in B.__dict__ for B in c.__mro__):
                return True
        return NotImplemented

class Struggle:

    def __len__(self) -> int:
        return 23

assert issubclass(Struggle, Foobar)
assert isinstance(Struggle(), Foobar)
# thus Struggle works as a "Foobar" (typing.Sized) without explicit mention

Full ABC example.

# INTERFACE

import abc
from typing import Any, Iterable, Tuple

class TombolaBase(abc.ABC):

    @abc.abstractmethod
    def load(self, iterable: Iterable):
        """ Add items from an iterable """

    @abc.abstractmethod
    def pick(self) -> Any:
        """
        Remove and return a random item.

        Should raise 'LookupError' when nothing to return.
        """

    def is_loaded(self) -> bool:
        """ Return 'True' if there is at least one item. """
        return bool(self.inspect())

    def inspect(self) -> Tuple:
        """ Return a sorted tuple with current items. """
        items = []
        while True:
            try:
                items.append(self.pick())
            except LookupError:
                break
        self.load(items)
        return tuple(sorted(items))

# IMPLEMENTATION #1

import random
from typing import Any, Iterable, List

class BingoCage(TombolaBase):
    _randomizer: random.SystemRandom
    _items: List

    def __init__(self, items: Iterable) -> None:
        self._randomizer = random.SystemRandom()
        self._items = []
        self.load(items)

    def load(self, items: Iterable) -> None:
        self._items.extend(items)
        self._randomizer.shuffle(self._items)

    def pick(self) -> Any:
        try:
            return self._items.pop()
        except IndexError:
            raise LookupError(f'pick from empty {self.__class__.__name__}')

    def __call__(self) -> Any:
        return self.pick()

# IMPLEMENTATION #2

import random
from typing import Any, Iterable, Tuple, List

class LotteryBlower(TombolaBase):
    _balls: List

    def __init__(self, iterable: Iterable) -> None:
        self._balls = list(iterable)

    def load(self, iterable) -> None:
        self._balls.extend(iterable)

    def pick(self) -> Any:
        try:
            position = random.randrange(len(self._balls))
        except ValueError:
            raise LookupError(f'pick from empty {self.__class__.__name__}')
        return self._balls.pop(position)

    def is_loaded(self) -> bool:
        return bool(self._balls)

    def inspect(self) -> Tuple:
        return tuple(sorted(self._balls))

assert TombolaBase.__subclasses__() == [BingoCage, LotteryBlower]

# testing that all subclasses implement the interface properly
for cls in TombolaBase.__subclasses__():
    balls = list(range(3))
    inst = cls(balls)
    assert inst.is_loaded()
    assert inst.inspect() == (0, 1, 2)

    picks = []
    picks.append(inst.pick())
    picks.append(inst.pick())
    picks.append(inst.pick())

    assert not inst.is_loaded()
    assert sorted(picks) == balls

Virtual subclass makes a class look like the class but you don't have to implement all the abstract methods.

@TombolaBase.register
class TombolaList(list):
    pass

assert issubclass(TombolaList, TombolaBase)
assert isinstance(TombolaList(range(100)), TombolaBase)

assert list(TombolaBase._abc_registry) == [TombolaList]

Source

  • Python Tricks The Book, Dan Bader
  • Fluent Python, Luciano Ramalho