🐍 Python - Abstract Base Classes
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 theBase
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