Abstract Base Classes and Interfaces

An abstract base class states what subclasses must implement, and refuses to let an incomplete one be created. It is how Python enforces a contract.

The problem

class Exporter:
    def export(self, data):
        raise NotImplementedError("subclasses must implement export")


class CsvExporter(Exporter):
    def exprot(self, data):        # a typo - the real method is never overridden
        return "a,b,c"


e = CsvExporter()
# e.export([])       # NotImplementedError, discovered only when called

The class was created successfully. The mistake surfaces later, possibly in production, possibly on a rare branch. An abstract base class moves that failure to the moment the object is created.

abc

from abc import ABC, abstractmethod


class Exporter(ABC):
    """Anything that can turn records into text."""

    @abstractmethod
    def export(self, data):
        """Return the records as a string."""

    @abstractmethod
    def extension(self):
        """Return the file extension, without a dot."""


class CsvExporter(Exporter):
    def export(self, data):
        return "\n".join(",".join(str(v) for v in row) for row in data)

    def extension(self):
        return "csv"


class Incomplete(Exporter):
    def export(self, data):
        return ""


print(CsvExporter().export([[1, 2], [3, 4]]))

# Exporter()          # TypeError: Can't instantiate abstract class Exporter
# Incomplete()        # TypeError: ... with abstract method extension

The error now happens at Incomplete(), naming the missing method. That is a much better place to find out.

Abstract classes can contain real code

from abc import ABC, abstractmethod


class Report(ABC):
    def __init__(self, title, rows):
        self.title = title
        self.rows = rows

    @abstractmethod
    def format_row(self, row):
        """Format a single row. Subclasses decide how."""

    def header(self):                     # shared, concrete
        return f"=== {self.title} ==="

    def render(self):                     # the template method
        lines = [self.header()]
        lines.extend(self.format_row(row) for row in self.rows)
        lines.append(f"({len(self.rows)} rows)")
        return "\n".join(lines)


class PlainReport(Report):
    def format_row(self, row):
        return "  ".join(str(value) for value in row)


class PipeReport(Report):
    def format_row(self, row):
        return "| " + " | ".join(str(value) for value in row) + " |"


rows = [["Meera", 92], ["Arun", 78]]
print(PlainReport("Scores", rows).render())
print()
print(PipeReport("Scores", rows).render())

This is the template method pattern: the base class fixes the overall algorithm and leaves specific steps to subclasses. Everything shared is written once.

Abstract properties and other combinations

from abc import ABC, abstractmethod


class Vehicle(ABC):
    @property
    @abstractmethod
    def wheels(self):
        """How many wheels this vehicle has."""

    @classmethod
    @abstractmethod
    def from_registration(cls, code):
        """Build an instance from a registration code."""

    @staticmethod
    @abstractmethod
    def category():
        """The vehicle category."""


class Car(Vehicle):
    @property
    def wheels(self):
        return 4

    @classmethod
    def from_registration(cls, code):
        return cls()

    @staticmethod
    def category():
        return "passenger"


print(Car().wheels, Car.category())

The order matters: @abstractmethod must be the innermost decorator, directly above the function.

Duck typing with a formal check

from typing import Protocol, runtime_checkable


@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> str:
        ...


class Circle:                     # note: it does NOT inherit from Drawable
    def draw(self):
        return "circle"


class Square:
    def draw(self):
        return "square"


class Blob:
    pass


for shape in [Circle(), Square(), Blob()]:
    if isinstance(shape, Drawable):
        print(shape.draw())
    else:
        print(f"{type(shape).__name__} cannot be drawn")

A Protocol describes a shape rather than an ancestry. Classes match it simply by having the right methods - which is duck typing, made checkable by tools and, with runtime_checkable, by isinstance. It is the modern alternative to an abstract base class when you do not control the classes involved.

ABCProtocol
Subclass must inheritYesNo
Enforced atInstantiationType check time
Can share codeYesNot usefully
Works on third party classesNoYes
Use whenYou own the hierarchyYou describe a capability

The standard library uses ABCs everywhere

from collections.abc import Sequence, Mapping, Iterable, Sized

print(isinstance([1, 2], Sequence))        # True
print(isinstance("abc", Sequence))         # True
print(isinstance({"a": 1}, Mapping))       # True
print(isinstance({1, 2}, Iterable))        # True
print(isinstance(42, Iterable))            # False


def process(data):
    if not isinstance(data, Iterable):
        raise TypeError("data must be iterable")
    return list(data)
from collections.abc import Sequence


class Playlist(Sequence):
    """Implement two methods and inherit the rest of the sequence behaviour."""

    def __init__(self, tracks):
        self._tracks = list(tracks)

    def __getitem__(self, index):
        return self._tracks[index]

    def __len__(self):
        return len(self._tracks)


p = Playlist(["a", "b", "c"])

print(len(p), p[1], "b" in p)          # 3 b True
print(list(reversed(p)))                # inherited
print(p.index("c"), p.count("a"))       # inherited
print([t.upper() for t in p])           # iteration, inherited

By inheriting from Sequence and writing two methods, the class gained __contains__, __iter__, __reversed__, index and count for free. This is the highest value use of abstract base classes in everyday code.

A worked example

from abc import ABC, abstractmethod


class Storage(ABC):
    """A place notes can be saved to and loaded from."""

    @abstractmethod
    def save(self, key, value):
        """Store value under key."""

    @abstractmethod
    def load(self, key):
        """Return the value for key, or None."""

    @abstractmethod
    def keys(self):
        """Return every stored key."""

    # Concrete behaviour built on the abstract methods
    def exists(self, key):
        return key in self.keys()

    def load_all(self):
        return {key: self.load(key) for key in self.keys()}

    def copy_to(self, other):
        for key, value in self.load_all().items():
            other.save(key, value)
        return other


class MemoryStorage(Storage):
    def __init__(self):
        self._data = {}

    def save(self, key, value):
        self._data[key] = value

    def load(self, key):
        return self._data.get(key)

    def keys(self):
        return list(self._data)


class FileStorage(Storage):
    def __init__(self, folder):
        from pathlib import Path
        self.folder = Path(folder)
        self.folder.mkdir(parents=True, exist_ok=True)

    def save(self, key, value):
        (self.folder / f"{key}.txt").write_text(value, encoding="utf-8")

    def load(self, key):
        path = self.folder / f"{key}.txt"
        return path.read_text(encoding="utf-8") if path.exists() else None

    def keys(self):
        return [p.stem for p in self.folder.glob("*.txt")]


memory = MemoryStorage()
memory.save("a", "first note")
memory.save("b", "second note")

print(memory.exists("a"), memory.exists("z"))
print(memory.load_all())


def summarise(store: Storage):
    """Works with any Storage, present or future."""
    return f"{type(store).__name__}: {len(store.keys())} notes"


print(summarise(memory))

exists, load_all and copy_to were written once and work for every backend. A new storage class needs three methods and inherits the rest.

Common mistakes

  • Forgetting to inherit from ABC, so @abstractmethod has no effect at all.
  • Putting @abstractmethod above @property instead of below it.
  • Creating an abstract class with a single implementation, which adds ceremony for nothing.
  • Using an ABC where duck typing or a Protocol would work.
  • Assuming NotImplementedError gives the same protection; it fails at call time, not creation time.
  • Adding a new abstract method to a released base class, breaking every existing subclass.

Best practices

  • Use an ABC when several classes must genuinely honour the same contract.
  • Put shared behaviour in the base class and leave only the varying steps abstract.
  • Inherit from collections.abc types to get whole protocols nearly free.
  • Use Protocol when you are describing a capability rather than owning a hierarchy.
  • Give every abstract method a docstring stating exactly what an implementation must do.

Practice

  1. Define an abstract Notifier with a send method and two concrete implementations.
  2. Show the exact error when a subclass forgets an abstract method.
  3. Build a class inheriting from collections.abc.Sequence and list everything it gained.
  4. Write a template method base class where subclasses supply only one formatting step.
  5. Define a Protocol and check three unrelated classes against it with isinstance.

Conclusion

An abstract base class turns "you must implement this" from a comment into an error at instantiation. Use one when several classes share a contract, put the common code in the base, and reach for a Protocol when you only need to describe a capability.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.