Beginner · Lesson 40 of 94

Abstract base classes and interfaces

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

Duck typing trusts that an object has the right methods. Sometimes trust is not enough: you are designing a family of classes that other people will implement, and you want a missing method to fail loudly at the moment the class is created, not the first time a customer hits the missing branch. An abstract base class does that. It names the methods every subclass must provide, and Python refuses to create an instance of any subclass that has not provided them.

Abstract base classes and interfaces: Shape (ABC); @abstractmethod area(); @abstractmethod perimeter(); describe() implemented → Circle: area, perimeter ✓ → Square: area, perimeter ✓ → HalfDone: area only ✗ → TypeError on instantiation
Abstract base classes and interfaces · Figure 1 · Open for full size

The mechanics are small. Inherit from abc.ABC, and mark the required methods with @abstractmethod; their bodies can be ... or a docstring. The base class itself cannot be instantiated either, which is correct: a "shape" with no specific kind has no area. A subclass becomes concrete the moment it overrides every abstract method, and only then does Subclass() work.

An abstract base class is more than a checklist. It can carry real, shared implementations that use the abstract methods: a describe() in the base that calls self.area() works for every subclass without knowing which. That combination, abstract requirements plus concrete helpers built on them, is the template pattern, and it is the main reason to reach for an ABC over a plain informal convention.

You want Tool
enforce that subclasses implement methods ABC + @abstractmethod
share behavior built on those methods concrete methods in the ABC
accept any object with the right methods, no inheritance typing.Protocol (checked by mypy)
check at runtime whether an object matches isinstance(obj, MyABC), or hasattr

Python's standard library uses ABCs everywhere: collections.abc.Iterable, Sized, Mapping. isinstance(x, collections.abc.Iterable) is the right way to ask "can I loop over this?" because it consults those contracts rather than a fixed list of types. When you read a library and see ABC in a class list, you are looking at its contract with you.

typing.Protocol expresses the same idea without inheritance: you declare a class with the required method signatures, and any object that has them satisfies the protocol, checked by a static type checker rather than at runtime. It is duck typing made explicit and verifiable. The typing lessons cover it properly; for now know that an ABC enforces at instantiation time, a Protocol at check time, and both write down the contract that duck typing left implicit.

Abstract base classes and interfaces: duck typing; implicit, runtime AttributeError → ABC; explicit, TypeError at instantiation → Protocol; explicit, mypy reports before running
Abstract base classes and interfaces · Figure 2 · Open for full size

Code

An abstract base class with two required methods and one shared helper. The complete subclass works; the incomplete one is refused at instantiation, naming what is missing.

# shapes_abc.py
import math
from abc import ABC, abstractmethod


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

    @abstractmethod
    def perimeter(self):
        """Return the perimeter."""

    def describe(self):
        return f"{type(self).__name__}: area {self.area():.2f}, perimeter {self.perimeter():.2f}"


class Circle(Shape):
    def __init__(self, r):
        self.r = r

    def area(self):
        return math.pi * self.r ** 2

    def perimeter(self):
        return 2 * math.pi * self.r


class HalfDone(Shape):
    def area(self):
        return 0


print(Circle(1).describe())
print(isinstance(Circle(1), Shape))
HalfDone()
Output
Circle: area 3.14, perimeter 6.28
True
Traceback (most recent call last):
  File "shapes_abc.py", line 36, in <module>
    HalfDone()
    ~~~~~~~~^^
TypeError: Can't instantiate abstract class HalfDone without an implementation for abstract method 'perimeter'

The base class cannot be instantiated either, and the standard library's ABCs answer capability questions for any object.

# abc_checks.py
from abc import ABC, abstractmethod
from collections.abc import Iterable, Sized, Mapping


class Plugin(ABC):
    @abstractmethod
    def run(self):
        ...


try:
    Plugin()
except TypeError as err:
    print("abstract:", str(err)[:40], "...")

for value in ([1, 2], "abc", 42, {"k": 1}):
    print(f"{type(value).__name__:<5} iterable={isinstance(value, Iterable)!s:<5} sized={isinstance(value, Sized)!s:<5} mapping={isinstance(value, Mapping)}")
Output
abstract: Can't instantiate abstract class Plugin  ...
list  iterable=True  sized=True  mapping=False
str   iterable=True  sized=True  mapping=False
int   iterable=False sized=False mapping=False
dict  iterable=True  sized=True  mapping=True

A plugin system: the ABC is the contract, concrete plugins fill it in, and a runner uses only the contract. Adding a plugin means writing one class.

# plugins.py
from abc import ABC, abstractmethod


class Exporter(ABC):
    extension = "txt"

    @abstractmethod
    def render(self, rows):
        """Return the file contents for these rows."""

    def export(self, rows, name):
        filename = f"{name}.{self.extension}"
        return filename, self.render(rows)


class CsvExporter(Exporter):
    extension = "csv"

    def render(self, rows):
        return "\n".join(",".join(str(v) for v in row) for row in rows)


class MarkdownExporter(Exporter):
    extension = "md"

    def render(self, rows):
        return "\n".join("| " + " | ".join(str(v) for v in row) + " |" for row in rows)


rows = [("name", "score"), ("ada", 91), ("linus", 67)]
for exporter in (CsvExporter(), MarkdownExporter()):
    filename, body = exporter.export(rows, "report")
    print(filename)
    print(body)
Output
report.csv
name,score
ada,91
linus,67
report.md
| name | score |
| ada | 91 |
| linus | 67 |

A preview of Protocol: the same contract, no inheritance. Square never mentions HasArea, yet it satisfies it, and runtime_checkable lets isinstance confirm that.

# protocol_preview.py
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 ** 2


def total(shapes: list[HasArea]) -> float:
    return sum(s.area() for s in shapes)


print(isinstance(Square(2), HasArea), isinstance("text", HasArea))
print(total([Square(2), Square(3)]))
Output
True False
13

Try it yourself

Create storage.py with an abstract Storage class requiring save(key, value) and load(key), plus a concrete has(key) helper that uses load. Implement MemoryStorage with a dictionary and FileStorage that writes one file per key using pathlib. Write a function that takes any Storage, saves two values, and reads them back, then run it against both. Try to instantiate Storage directly and read the error.

Common mistakes

What's next

Contracts are explicit now. Next you will learn the design skill that ties the OOP module together: composing objects instead of inheriting, and keeping classes small.

Course overview