d

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.

15 St Margarets, NY 10033
(+381) 11 123 4567
ouroffice@aware.com

 

KMF

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

Previous Next
Close
Test Caption
Test Description goes like this