
Create Your Own Abstract Base Class in Python
We have two general paths to creating classes that are similar: we can leverage duck typing or we can define common abstractions. When we leverage duck typing, we can formalize the related types by creating a type hint using a protocol definition to enumerate the common methods or Union[]
to enumerate the common types. There are many influencing factors that suggest one or the other approach. While duck typing offers the most flexibility, we may sacrifice the ability to use mypy. An abstract base class definition can be wordy and potentially confusing.
This article is an excerpt from my book Python Object-Oriented Programming, Fourth Edition. I wrote this book along with Dusty Phillips as the Co-Author. This book describes the object-oriented approach to creating programs in Python with examples. This excerpt is taken from the chapter that covers Abstract Base Classes and Operator Overloading.
We will learn to create an abstract base class with an example. We will build a simulation of games that involve polyhedral dice.
These are the dice including four, six, eight, twelve, and twenty sides. The six-sided dice are conventional cubes. Some sets of dice include 10-sided dice, which are cool, but aren’t – technically – a regular polyhedron; they’re two sets of five “kite-shaped” faces. One question that comes up is how best to simulate rolls of these differently shaped dice. There are three readily available sources of random data in Python:
- the
random
module, - the
os
module, and - the
secrets
module.
If we turn to third-party modules, we can add cryptographic libraries like pynacl, which offer yet more random number capabilities.
Rather than bake the choice of random number generator into a class, we can define an abstract class that has the general features of a die. A concrete subclass can supply the missing randomization capability. The random
module has a very flexible generator. The os
module’s capability is limited but involves using an entropy collector to increase randomness. Flexibility and high entropy are generally combined by cryptographic generators.
To create our dice-rolling abstraction, we’ll need the abc
module. This is distinct from the module. The abc
module has the foundational definitions for abstract classes:
import abc
class Die (abc.ABC):
def __init__(self) -> None:
self.face: int
self.roll()
@abc.abstractmethod
def roll(self) -> None:
...
def __repr__(self) -> str:
return f"{self.face}"
We’ve defined a class that inherits from the abc. ABC
class. Using ABC as the parent class assures us that any attempt to create an instance of the Die
class directly will raise a TypeError
exception. This is a runtime exception; it’s also checked by mypy. We’ve marked a method, roll()
as abstract with the @abc.abstract
decorator. This isn’t a very complex method, but any subclass should match this abstract definition. This is only checked by mypy. Of course, if we make a mess of the concrete implementation, things are likely to break at runtime. Consider this mess of code:
>>>classBad(Die):
... defroll(self, a: int, b: int) -> float:
... return (a+b)/2
This will raise a TypeError
exception at runtime. The problem is caused by the base class __init__()
not providing the a and b parameters to this strange-looking roll()
method. This is a valid Python code, but it doesn’t make sense in this context. The method will also generate mypy errors, providing ample warning that the method definition doesn’t match the abstraction. Here’s what two proper extensions to the Die
class look like:
class D4(Die):
def roll(self) -> None:
self.face = random.choice((1, 2, 3, 4))
class D6(Die):
def roll(self) -> None:
self.face = random.randint(1, 6)
We’ve provided methods that provide a suitable definition for the abstract placeholder in the Die
class. They use vastly different approaches to selecting a random value. The four-sided die uses random.choice()
. The six-sided die – the common cube most people know – uses random.randint()
.
Let’s go a step further and create another abstract class. This one will represent a handful of dice. Again, we have a number of candidate solutions, and we can use an abstract class to defer the final design choices. The interesting part of this design is the differences in the rules for games with handfuls of dice. In some games, the rules require the player to roll all the dice.
The rules for a lot of games with two dice require the player to roll both dice. In other games, the rules allow players to save dice, and re-roll selected dice. In some games, like Yacht, the players are allowed at most two re-rolls. In other games, like Zilch, they are allowed to re-roll until they elect to save their score or roll something invalid and lose all their points, scoring zilch (hence the name). These are dramatically different rules that apply to a simple list of Die instances. Here’s a class that leaves the roll implementation as an abstraction:
class Dice(abc.ABC):
def __init__(self, n: int, die_class: Type[Die]) -> None:
self.dice = [die_class() for _ in range(n)]
@abc.abstractmethod
def roll(self) -> None:
...
@property
def total(self) -> int:
return sum(d.face for d in self.dice)
The __init__()
method expects an integer, n, and the class used to create Die instances, named die_class
. The type hint is Type[Die]
, telling mypy to be on the lookout for any subclass of the abstract base class Die
.
We don’t expect an instance of any of the Die
subclasses; we expect the class object itself. We’d expect to see SomeDice(6, D6)
to create a list of six instances of the D6
class. We’ve defined the collection of Die instances as a list because that seems simple. Some games will identify dice by their position when saving some dice and rerolling the remainder of the dice, and the integer list indices seem useful for that. This subclass implements the roll-all-the-dice rule:
class SimpleDice(Dice):
def roll(self) -> None:
for d in self.dice:
d.roll()
Each time the application evaluates roll(), all the dice are updated. It looks like this:
>>> sd = SimpleDice(6, D6)
>>> sd.roll()
>>> sd.total
23
The object, sd, is an instance of the concrete class, SimpleDice
, built from the abstract class, Dice
. The instance of SimpleDice
contains six instances of the D6
class. This, too, is a concrete class built from the abstract class Die
. Here’s another subclass that provides a dramatically different set of methods. Some of these, fill in the spaces left by abstract methods. Others, however, are unique to the subclass:
class YachtDice(Dice):
def __init__(self) -> None:
super().__init__(5, D6)
self.saved: Set[int] = set()
def saving(self, positions: Iterable[int]) ->
"YachtDice":
if not all(0 <= n < 6 for n in positions):
raise ValueError("Invalid position")
self.saved = set(positions)
return self
def roll(self) -> None:
for n, d in enumerate(self.dice):
if n not in self.saved:
d.roll()
self.saved = set()
We’ve created a set of saved positions. This is initially empty. We can use the saving()
method to provide an iterable collection of integers as positions to save. It works like this:
>>> sd = YachtDice()
>>> sd.roll()>>> sd.dice
[2, 2, 2, 6, 1]
>>> sd.saving([0, 1, 2]).roll()
>>> sd.dice
[2, 2, 2, 6, 6]
We improved the hand from three of a kind to a full house.
In both cases, the Die
class and the Dice
class, it’s not clear that the abc.ABC
base class and the presence of @abc.abstractmethod
decoration is dramatically better than providing a concrete base class with a common set of default definitions.
In some languages, the abstraction-based definition is required. In Python, because of duck typing, abstraction is optional. In cases where it clarifies the design intent, use it. In cases where it seems fussy and little more than overhead, set it aside. Because it’s used to define the collections, we’ll often use the collection.abc
names in type hints to describe the protocols objects must follow. In less common cases, we’ll leverage the collections.abc
abstractions to create our own unique collections.
Hope this helps you!
Credit: Source link