Abstract base classes and protocols

Intermediate50 min

Define what an interface looks like in Python, using duck typing, abstract base classes that refuse incomplete subclasses, and Protocols that checkers verify without inheritance.

What you will learn
  • Explain duck typing and its trade-off
  • Define an abstract base class with @abstractmethod, and explain when instantiation fails
  • Write a typing.Protocol and use it in a type hint without inheriting from it
  • Choose between an ABC and a Protocol for a given design
  • Combine @abstractmethod with @override
On this page

When several classes should be usable in the same place, the code that uses them needs to know what they have in common. That shared promise is an interface: a set of methods and what they mean. Python offers three ways to express one, from the loosest to the strictest.

Duck typing

If it walks like a duck and quacks like a duck, treat it as a duck. Python does not ask for a family tree. A function simply calls the method it needs:

c24_duck.py
class Duck:
def speak(self):
return "Quack"
class Robot:
def speak(self):
return "Beep"
def announce(thing):
print(thing.speak())
announce(Duck())
announce(Robot())
Output
Quack
Beep

Duck and Robot share no parent. announce works with both because both have a speak method. This is duck typing, and it is how most Python code is written. It needs no ceremony, and it is the reason the language feels flexible.

The cost is that mistakes surface late, when the call is made:

c24_duck_fail.py
class Stone:
pass
def announce(thing):
print(thing.speak())
try:
announce(Stone())
except AttributeError as error:
print("AttributeError:", error)
Output
AttributeError: 'Stone' object has no attribute 'speak'

Nothing told the author of Stone that speak was expected. The next two tools make the expectation explicit, one at run time and one for type checkers.

Abstract base classes

An abstract base class (ABC) is a parent class that declares methods its subclasses must provide. Inherit from ABC and mark each required method with @abstractmethod:

c24_abc.py
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
"""Return the area as a number."""
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side**2
try:
Shape()
except TypeError:
print("TypeError: cannot create an abstract Shape")
print(Square(3).area())
print(isinstance(Square(3), Shape))
Output
TypeError: cannot create an abstract Shape
9
True

Shape cannot be instantiated, because it is incomplete. Square supplies area, so it is concrete and works normally. Because Square inherits from Shape, isinstance(Square(3), Shape) is True. The body of the abstract method is only a docstring, which says what implementers should do.

The wording of the TypeError message varies between Python versions, so the examples catch the exception type and print their own text.

The check happens when you create an instance. A subclass that forgets one of the required methods stays abstract:

c24_abc_missing.py
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
"""Return the area as a number."""
@abstractmethod
def perimeter(self):
"""Return the perimeter as a number."""
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side**2
try:
Square(3)
except TypeError:
print("TypeError: Square is still abstract")
print(sorted(Shape.__abstractmethods__))
Output
TypeError: Square is still abstract
['area', 'perimeter']

Square implemented area but not perimeter, so Square(3) failed, before any method could be called with half a class. The set __abstractmethods__ lists what is still missing. This is the main benefit over duck typing: the error comes at the moment of creation, and it names the missing piece.

Abstract and concrete together

An ABC can mix abstract methods with ordinary ones. The ordinary methods are written once and use the abstract ones, which gives you a fixed procedure with steps the subclasses fill in:

c24_abc_template.py
from abc import ABC, abstractmethod
class Report(ABC):
@abstractmethod
def body(self):
"""Return the report text."""
def render(self):
return f"=== report ===\n{self.body()}\n=== end ==="
class SalesReport(Report):
def body(self):
return "sales: 42"
print(SalesReport().render())
Output
=== report ===
sales: 42
=== end ===

Marking implementations with @override

The @override decorator from the inheritance lesson fits naturally here: it states that a method implements something from the parent, and a type checker confirms that the parent has it. Requires version 3.12 or later

c24_abc_override.py
from abc import ABC, abstractmethod
from typing import override
class Greeter(ABC):
@abstractmethod
def greet(self, name):
"""Return a greeting."""
class Formal(Greeter):
@override
def greet(self, name):
return f"Good day, {name}."
print(Formal().greet("Asha"))
Output
Good day, Asha.

Protocols: interfaces without inheritance

An ABC forces every implementer to inherit from it. That is a problem when the class comes from a library you cannot change, or when you would rather not tie unrelated classes to a shared parent. A protocol describes the required methods, and any class that has them counts as a match, with no inheritance at all. typing.Protocol arrived in Python 3.8, so every supported version has it.

c24_protocol.py
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle:
def draw(self) -> str:
return "()"
class Label:
def __init__(self, text):
self.text = text
def draw(self) -> str:
return f"[{self.text}]"
def render(items: list[Drawable]) -> str:
return " ".join(item.draw() for item in items)
print(render([Circle(), Label("hi"), Circle()]))
Output
() [hi] ()

Three things are new here:

  • class Drawable(Protocol) declares the shape. The method body is ..., the literal Ellipsis, which is a conventional “nothing here” for a signature.
  • -> str and list[Drawable] are type hints, which you met in the type hints lesson. They tell readers and checkers what to expect.
  • Circle and Label never mention Drawable. They match because they have a draw method with a compatible signature.

At run time, Python does not check any of this. The script above would run identically with no annotations at all. The value comes from a type checker such as mypy or pyright: pass an object without a draw method to render, and it reports the mistake before the program runs. A protocol is, in effect, a duck-typing promise that tools can verify.

Checking at run time

A normal protocol cannot be used with isinstance. Decorate it with @runtime_checkable when you need that:

c24_runtime.py
import io
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class Connection:
def close(self) -> None:
print("closed")
class Brick:
pass
print(isinstance(Connection(), Closeable))
print(isinstance(Brick(), Closeable))
print(isinstance(io.StringIO(), Closeable))
Output
True
False
True

The check is shallow. It confirms that the names exist, and nothing more:

c24_runtime_shallow.py
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class Odd:
def close(self, a, b, c): # wrong shape, right name
return "not what the protocol described"
print(isinstance(Odd(), Closeable))
Output
True

Odd.close takes three required arguments, which does not match the protocol, but isinstance said True because it only looked for the name close. Treat the run-time check as a convenience, and leave the full verification to the type checker.

ABC or Protocol?

ABC Protocol
Implementers must inherit yes no
Missing methods detected at instantiation, by Python by a type checker
Can provide shared concrete methods yes not as a goal
Works with classes you cannot edit no yes

Choose an ABC when you control the classes and want Python to refuse incomplete ones, or when you have shared behaviour to inherit. Choose a Protocol when you describe what a function needs from its arguments, especially for code that accepts types from several sources. Plain duck typing remains a good default for small programs.

Common pitfalls

Forgetting ABC. Using @abstractmethod in a class that does not inherit from ABC enforces nothing, and you can instantiate it freely. The parent must be ABC, or have ABCMeta as its metaclass.

Expecting a Protocol to be enforced at run time. Calling render with a non-drawable raises AttributeError when .draw() is reached, as with any duck-typed code. Only a type checker reports it earlier.

Over-abstracting. An interface with one implementation, or an ABC for a hierarchy of two classes, adds files and names for no gain. Add the abstraction when the second or third implementation appears.

Putting logic in the abstract method body and not calling it. An abstract method can have a body, but subclasses must call it explicitly with super().method() to run it. Most abstract methods are better left as a docstring.

ExerciseA plugin interface

Design a text-processing plugin system.

  • Plugin is an ABC with a class attribute name = "plugin" and an abstract method run(self, text).
  • Upper (name upper) returns the text in upper case. Reverse (name reverse) returns it reversed.
  • apply_all(plugins, text) passes the text through each plugin in turn, printing <name>: <text> after each step, and returns the final text.

Run apply_all([Upper(), Reverse()], "hello"), print the result, and then show that Plugin() cannot be created by printing Plugin is abstract when it raises TypeError. Expected output:

upper: HELLO
reverse: OLLEH
OLLEH
Plugin is abstract
Show solution
c24_ex_plugins.py
from abc import ABC, abstractmethod
class Plugin(ABC):
name = "plugin"
@abstractmethod
def run(self, text):
"""Return the transformed text."""
class Upper(Plugin):
name = "upper"
def run(self, text):
return text.upper()
class Reverse(Plugin):
name = "reverse"
def run(self, text):
return text[::-1]
def apply_all(plugins, text):
for plugin in plugins:
text = plugin.run(text)
print(f"{plugin.name}: {text}")
return text
result = apply_all([Upper(), Reverse()], "hello")
print(result)
try:
Plugin()
except TypeError:
print("Plugin is abstract")
Output
upper: HELLO
reverse: OLLEH
OLLEH
Plugin is abstract

The abstract run guarantees that every concrete plugin has one, and apply_all relies on that. name is an ordinary class attribute that subclasses override. The [::-1] slice makes a reversed copy of the string.

ExerciseThe same idea as a Protocol

Define class Exporter(Protocol) with one method, export(self, rows: list[dict]) -> str. Write two unrelated classes that match it: CsvExporter, which joins each row’s values with commas, one row per line, and CountExporter, which returns text like 2 rows. Write save(exporter: Exporter, rows: list[dict]) -> None, which prints the result of exporter.export(rows).

Call save with each exporter on [{"id": 1, "name": "pen"}, {"id": 2, "name": "ink"}]. Expected output:

1,pen
2,ink
2 rows
Show solution
c24_ex_protocol.py
from typing import Protocol
class Exporter(Protocol):
def export(self, rows: list[dict]) -> str: ...
class CsvExporter:
def export(self, rows: list[dict]) -> str:
lines = [",".join(str(v) for v in row.values()) for row in rows]
return "\n".join(lines)
class CountExporter:
def export(self, rows: list[dict]) -> str:
return f"{len(rows)} rows"
def save(exporter: Exporter, rows: list[dict]) -> None:
print(exporter.export(rows))
rows = [{"id": 1, "name": "pen"}, {"id": 2, "name": "ink"}]
save(CsvExporter(), rows)
save(CountExporter(), rows)
Output
1,pen
2,ink
2 rows

Neither exporter mentions Exporter. Both match because they have export with the right signature. If you ran a type checker, it would also accept any third class with the same method, and it would reject one without.

ExercisePredict which classes are abstract

Animal has two abstract methods. Fish implements one, and Dog extends Fish with the other. Write what each pass of the loop prints before you run it.

c24_ex_predict.py
from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def speak(self):
pass
@abstractmethod
def move(self):
pass
class Fish(Animal):
def move(self):
return "swim"
class Dog(Fish):
def speak(self):
return "woof"
for cls in [Fish, Dog]:
try:
animal = cls()
print(cls.__name__, animal.speak(), animal.move())
except TypeError:
print(cls.__name__, "is abstract")
Show solution
Fish is abstract
Dog woof swim

Fish implements move but not speak, so one abstract method remains and Fish() raises TypeError. Dog inherits move from Fish and adds speak, which leaves nothing abstract, so it can be created. Abstract methods are inherited as requirements until some class in the chain fills each one.

Summary

  • An interface is the set of methods other code relies on. Duck typing leaves it implicit, and ABCs and Protocols make it explicit.
  • An ABC inherits from ABC and marks required methods with @abstractmethod. Python refuses to create an instance while any remain unimplemented.
  • A typing.Protocol lists the methods a function expects. Classes match by having them, with no inheritance, and a type checker verifies it.
  • @runtime_checkable allows isinstance on a protocol, but it only checks that the names exist.
  • @override Requires version 3.12 or later can mark the methods that implement an abstract one.
  • Use an ABC when you control the classes and want enforcement or shared code. Use a Protocol to describe requirements on code you do not control.
  • Going further: the advanced lesson Protocols and structural typing goes deeper into how Protocols are matched and checked.