Skip to content

Polymorphism and protocols

~20 min ยท prerequisite: Inheritance and super

What you will be able to do

  • Write one loop that calls the same method on objects of different classes.
  • Define an abstract base class with abc that requires its subclasses to provide a method.
  • Describe what an object must support with a typing.Protocol, and check it with isinstance.

The idea

Polymorphism means "many forms." In programming, it means that one name can work on objects of different classes, and each object does the right thing for its own class. A drawing loop can call draw() on rectangles and circles alike, and each shape draws itself.

Python relies on duck typing, a term from the Python glossary. If an object has the method you call, you can use it, whatever its class is. A function that calls .area() on its input works with any object that has an area method.

Sometimes you want Python to check that a class provides a method. An abstract base class, or ABC, does this. You create one by inheriting from ABC in the abc module and marking required methods with @abstractmethod. Python then refuses to create an object from a subclass that does not provide every abstract method. A protocol describes the methods an object must have, without requiring inheritance. You write a class that inherits from typing.Protocol and lists the method signatures. Any class with matching methods satisfies it. Add @runtime_checkable if you want isinstance to test for it. That check only confirms that the method names exist.

Use an ABC when related classes should share a required structure, and a protocol when unrelated classes should support the same operations.

Worked example

from abc import ABC, abstractmethod


class Shape(ABC):
    @abstractmethod
    def area(self):
        """Return the area of the shape."""


class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height


class Triangle(Shape):
    def __init__(self, base, height):
        self.base = base
        self.height = height

    def area(self):
        return self.base * self.height / 2


shapes = [Rectangle(2, 3), Triangle(3, 4)]
total = 0
for shape in shapes:
    total += shape.area()
print(total)

The output is 12.0. Line by line, this is what Python does:

  1. class Shape(ABC): creates an abstract base class. The @abstractmethod line marks area as required for every subclass.
  2. Rectangle and Triangle each provide their own area, so both can be created.
  3. In the loop, shape.area() runs the method that belongs to each object's class. The results are 6 and 6.0.
  4. total adds them, and print shows 12.0, because a float appears in the sum.

Try it

The first block uses an ABC and tries to create the base class. The second uses a protocol that no class inherits from. The third shows duck typing in a function.

from abc import ABC, abstractmethod


class Shape(ABC):
    @abstractmethod
    def area(self):
        pass


class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height


class Triangle(Shape):
    def __init__(self, base, height):
        self.base = base
        self.height = height

    def area(self):
        return self.base * self.height / 2


shapes = [Rectangle(2, 3), Triangle(3, 4)]
total = 0
for shape in shapes:
    total += shape.area()
assert total == 12.0

blocked = False
try:
    Shape()
except TypeError:
    blocked = True
assert blocked
from typing import Protocol, runtime_checkable


@runtime_checkable
class HasArea(Protocol):
    def area(self) -> float: ...


class Square:
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side * self.side


class Label:
    def __init__(self, text):
        self.text = text


assert isinstance(Square(2), HasArea)
assert not isinstance(Label("x"), HasArea)
assert Square(3).area() == 9
class Dog:
    def speak(self):
        return "Woof"


class Robot:
    def speak(self):
        return "Beep"


def announce(thing):
    return thing.speak()


assert announce(Dog()) == "Woof"
assert announce(Robot()) == "Beep"

Common mistakes

  1. Creating the abstract base class directly. Writing Shape() gives:

    TypeError: Can't instantiate abstract class Shape without an implementation for abstract method 'area'
    

    Create a subclass that defines area, such as Rectangle.

  2. Forgetting a required method in a subclass. A subclass of Shape that does not define area gives the same error, with the subclass name in the message.

  3. Calling a method that the object does not have. Passing a number to announce from the third block gives:

    AttributeError: 'int' object has no attribute 'speak'
    
  4. Using isinstance with a protocol that is not runtime-checkable. Without the @runtime_checkable line, isinstance(1, HasArea) gives:

    TypeError: Instance and class checks can only be used with @runtime_checkable protocols
    

    Add the decorator above the class.

Practice

  1. Create Square(Shape) with an __init__ that takes side, and an area method. Assert that Square(3).area() is 9.
  2. Write a function total_area(shapes) that returns the sum of area() for each item in a list. Assert that total_area([Square(2), Square(1)]) is 5, using the Square you wrote in exercise 1.
  3. Create a protocol Named with a method name(self) -> str. Create a class Cat with a name method that returns "cat", and assert that isinstance(Cat(), Named) is True. Then assert that isinstance(5, Named) is False.
  4. Create two classes, Dog and Robot, each with a speak method. Write a loop that collects the result of speak() for each object in a list, and assert that the list is ["Woof", "Beep"].
  5. Stretch: create an abstract base class Storage with abstract methods save(self, item) and load(self). Create ListStorage(Storage) that keeps items in a list. Assert that saving "a" and "b" and then calling load() returns ["a", "b"], and that Storage() raises TypeError.
Solution
  1. ```python from abc import ABC, abstractmethod

class Shape(ABC): @abstractmethod def area(self): pass

class Square(Shape): def init(self, side): self.side = side

   def area(self):
       return self.side * self.side

assert Square(3).area() == 9 ```

  1. ```python class Square: def init(self, side): self.side = side

    def area(self): return self.side * self.side

def total_area(shapes): total = 0 for shape in shapes: total += shape.area() return total

assert total_area([Square(2), Square(1)]) == 5 ```

  1. ```python from typing import Protocol, runtime_checkable

@runtime_checkable class Named(Protocol): def name(self) -> str: ...

class Cat: def name(self): return "cat"

assert isinstance(Cat(), Named) assert not isinstance(5, Named) ```

  1. ```python class Dog: def speak(self): return "Woof"

class Robot: def speak(self): return "Beep"

results = [] for thing in [Dog(), Robot()]: results.append(thing.speak()) assert results == ["Woof", "Beep"] ```

  1. ```python from abc import ABC, abstractmethod

class Storage(ABC): @abstractmethod def save(self, item): pass

   @abstractmethod
   def load(self):
       pass

class ListStorage(Storage): def init(self): self.items = []

   def save(self, item):
       self.items.append(item)

   def load(self):
       return self.items

store = ListStorage() store.save("a") store.save("b") assert store.load() == ["a", "b"]

blocked = False try: Storage() except TypeError: blocked = True assert blocked ```

Recap

  • Polymorphism lets one method name work on objects of different classes, each running its own version.
  • An abstract base class (ABC with @abstractmethod) stops you from creating a subclass that leaves out a required method.
  • A Protocol describes required methods, and @runtime_checkable lets isinstance check for them.

Sources