Codetail

Article 6 of 8

Abstraction with ABCs

Contracts that enforce themselves.

16 min read

Duck typing works, until it doesn't

Duck typing is powerful, but it offers no guarantees. If you define a NotificationService that all notification backends should implement, nothing stops someone from writing a backend that forgets one of the required methods. The error only shows up at runtime, when the missing method is actually called.

Python
1# A contract described in a comment is not enforced
2# All backends must implement: send(to, subject, body)
3
4class EmailBackend:
5 def send(self, to, subject, body):
6 print(f"Emailing {to}: {subject}")
7
8class SMSBackend:
9 def send(self, to, subject, body):
10 print(f"Texting {to}: {body}")
11
12class SlackBackend:
13 def notify(self, to, message): # oops, wrong method name
14 print(f"Slacking {to}: {message}")
15
16def notify_user(backend, user):
17 backend.send(user.email, "Hello", "Welcome!")
18
19# This fails at runtime, not at class definition time
20slack = SlackBackend()
21notify_user(slack, user) # AttributeError: 'SlackBackend' has no 'send'

For a small team or a personal project, this is often fine. For a library or a large codebase where multiple people implement the same interface, you want the error earlier. Ideally when someone defines the class, not when they call it in production.

Abstract base classes give you that earlier error.

ABC and @abstractmethod

Python's abc module provides ABC (Abstract Base Class) and the @abstractmethod decorator. When you mark a method as abstract, any class that inherits from your ABC but does not implement that method cannot be instantiated. The error happens at class instantiation, not at the call site.

Python
1from abc import ABC, abstractmethod
2
3class NotificationBackend(ABC):
4 @abstractmethod
5 def send(self, to: str, subject: str, body: str) -> None:
6 """Send a notification to the given address."""
7
8class EmailBackend(NotificationBackend):
9 def send(self, to, subject, body):
10 print(f"Emailing {to}: {subject}")
11
12class SlackBackend(NotificationBackend):
13 def notify(self, to, message): # wrong name, not implementing send
14 print(f"Slacking {to}: {message}")
15
16# EmailBackend is fine
17e = EmailBackend() # works
18
19# SlackBackend fails immediately, at instantiation
20s = SlackBackend() # TypeError

The error message is clear and tells you exactly what is missing. Compare that to the duck typing version, where you get AttributeError at some point during program execution, possibly deep in a call stack.

ABCs can share implementation too

Abstract methods define the contract. But the ABC can also provide concrete methods that are inherited by all subclasses. This is the distinction between an ABC and a pure interface.

Python
1from abc import ABC, abstractmethod
2
3class NotificationBackend(ABC):
4 @abstractmethod
5 def send(self, to: str, subject: str, body: str) -> None:
6 pass
7
8 def send_bulk(self, recipients: list, subject: str, body: str) -> None:
9 # Concrete method shared by all backends
10 for to in recipients:
11 self.send(to, subject, body)
12
13class EmailBackend(NotificationBackend):
14 def send(self, to, subject, body):
15 print(f"Emailing {to}: {subject}")
16
17e = EmailBackend()
18e.send_bulk(["alice@example.com", "bob@example.com"], "Hi", "Hello!")

send_bulk() is implemented once and works for every backend, because it calls self.send() which each backend implements its own way.

Protocol: structural subtyping

ABCs use nominal subtyping: to be considered a NotificationBackend, you must explicitly inherit from it. Python 3.8 introduced Protocol from the typing module, which uses structural subtyping: any class that has the right methods qualifies, regardless of what it inherits from.

Python
1from typing import Protocol
2
3class Sendable(Protocol):
4 def send(self, to: str, subject: str, body: str) -> None:
5 ...
6
7# No inheritance required
8class EmailBackend:
9 def send(self, to, subject, body):
10 print(f"Emailing {to}: {subject}")
11
12class SMSBackend:
13 def send(self, to, subject, body):
14 print(f"Texting {to}: {body}")
15
16def notify(backend: Sendable, to: str, subject: str, body: str) -> None:
17 backend.send(to, subject, body)
18
19# Both pass the type checker, neither inherits from Sendable
20notify(EmailBackend(), "alice@example.com", "Hi", "Hello")
21notify(SMSBackend(), "+1555000000", "Hi", "Hello")

Protocols are primarily a type-checking tool. A static type checker like mypy or pyright will flag code that passes the wrong type without you needing to add isinstance() checks everywhere.

runtime_checkable

By default, you cannot use isinstance(x, Sendable) at runtime. Add the @runtime_checkable decorator if you need that:

Python
1from typing import Protocol, runtime_checkable
2
3@runtime_checkable
4class Sendable(Protocol):
5 def send(self, to: str, subject: str, body: str) -> None:
6 ...
7
8class EmailBackend:
9 def send(self, to, subject, body):
10 print(f"Emailing {to}: {subject}")
11
12e = EmailBackend()
13print(isinstance(e, Sendable)) # True -- has .send()

ABC vs Protocol: when to use which

There is no single right answer, but there are clear defaults.

Use an ABC when:

  • You own the full hierarchy and want enforcement at class definition time.
  • The abstract base provides shared concrete methods that all subclasses should inherit.
  • You want isinstance() checks to reflect membership explicitly.

Use a Protocol when:

  • You are writing library or utility code and want to accept any compatible object.
  • The implementors do not or cannot inherit from your base class.
  • You want duck typing but with type-checker enforcement.
Python
1# ABC: you own everything, want enforcement and shared code
2from abc import ABC, abstractmethod
3
4class StorageBackend(ABC):
5 @abstractmethod
6 def read(self, key: str) -> bytes: ...
7
8 @abstractmethod
9 def write(self, key: str, data: bytes) -> None: ...
10
11 def exists(self, key: str) -> bool: # shared concrete method
12 try:
13 self.read(key)
14 return True
15 except KeyError:
16 return False
17
18# Protocol: you want to accept anything with a .read() method
19from typing import Protocol
20
21class Readable(Protocol):
22 def read(self, n: int = -1) -> bytes: ...
23
24def process(source: Readable) -> None:
25 data = source.read()
26 # works with open() file handles, BytesIO, network streams, anything

The Readable protocol in the example above already matches Python's built-in file objects, the BytesIO class, network sockets, and any third-party object with a read() method. None of them need to know about your Protocol. That flexibility is the whole point.