Abstract Base Classes and Structural Protocols
Abstract Base Classes and Structural Protocols
After reading this chapter, you will master Python’s two complementary interface design paradigms: nominal enforcement using Abstract Base Classes (abc.ABC), and structural static duck typing using Protocols (typing.Protocol from PEP 544). You will enforce runtime contracts, register virtual subclasses, implement @runtime_checkable interfaces, and avoid decorator ordering traps.
Mental model
Python provides two approaches for defining and enforcing contracts between components:
Nominal Subtyping (abc.ABC):
"Explicit Inheritance"
Class hierarchy defines type.
Checked at instantiation time via @abstractmethod.
┌─────────────────┐
│ StorageBackend │ (abc.ABC)
└────────┬────────┘
▲
│ inherits
┌────────┴────────┐
│ S3Storage │ (Concrete)
└─────────────────┘
Structural Subtyping (typing.Protocol - PEP 544):
"Static Duck Typing"
Shape and attributes define type.
No inheritance required! Zero coupling between libraries.
┌─────────────────┐ ┌─────────────────┐
│ LogWriter │ (Protocol) │ ConsoleLogger │ (No base class!)
│ - write(msg) │ <.......... │ - write(msg) │
└─────────────────┘ Matches └─────────────────┘
Comparison Matrix
┌──────────────────────────────┬───────────────────────────────┬───────────────────────────────┐
│ Property │ Abstract Base Class (abc.ABC) │ Protocol (typing.Protocol) │
├──────────────────────────────┼───────────────────────────────┼───────────────────────────────┤
│ Philosophy │ Nominal: Must inherit or reg. │ Structural: "Shape" matches │
│ Enforcement Point │ Runtime (at instantiation) │ Static (Mypy/Pyright) + opt. │
│ Third-party adaptability │ Requires ABC.register(cls) │ Automatic if signature fits │
│ Primary Use Case │ Framework base abstractions │ Decoupled library interfaces │
└──────────────────────────────┴───────────────────────────────┴───────────────────────────────┘
Minimal example
Save as interfaces_overview.py:
# interfaces_overview.py
from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable
# 1. Nominal Interface with abc.ABC
class MetricPublisher(ABC):
"""Nominal interface: Subclasses MUST implement publish_metric."""
@abstractmethod
def publish_metric(self, name: str, value: float) -> None:
"""Send a single metric measurement to the telemetry collector."""
class CloudWatchPublisher(MetricPublisher):
def publish_metric(self, name: str, value: float) -> None:
print(f"[CloudWatch] Published {name} = {value}")
# 2. Structural Interface with typing.Protocol (PEP 544)
@runtime_checkable
class Closable(Protocol):
"""Structural interface: Any object with a close() method satisfies this contract."""
def close(self) -> None: ...
class DatabaseConnection:
"""Note: Does NOT inherit from Closable, but satisfies the structural contract!"""
def close(self) -> None:
print("[Database] Connection pool cleanly closed.")
def terminate_resource(resource: Closable) -> None:
# Runtime verification via @runtime_checkable
if isinstance(resource, Closable):
resource.close()
def main() -> None:
# Test ABC enforcement
pub = CloudWatchPublisher()
pub.publish_metric("cpu_load", 42.5)
# Test Structural Protocol
db = DatabaseConnection()
terminate_resource(db)
if __name__ == "__main__":
main()Run via uv run python interfaces_overview.py:
[CloudWatch] Published cpu_load = 42.5
[Database] Connection pool cleanly closed.
Worked examples
Case 1: Enforcing Framework Contracts with Abstract Base Classes
When building an enterprise data ingestion pipeline, you want to enforce that every storage connector implements reading, writing, and health-checking before allowing the class to be instantiated:
# storage_connector.py
from abc import ABC, abstractmethod
from typing import Any
class StorageConnector(ABC):
"""Abstract driver interface for multi-cloud blob stores."""
def __init__(self, endpoint_url: str) -> None:
self.endpoint_url = endpoint_url
@abstractmethod
def upload_blob(self, key: str, payload: bytes) -> bool:
"""Upload raw bytes to cloud storage under key."""
@abstractmethod
def download_blob(self, key: str) -> bytes:
"""Fetch raw bytes from cloud storage."""
@property
@abstractmethod
def is_connected(self) -> bool:
"""Return True if connection to storage endpoint is active."""
class LocalFileStorage(StorageConnector):
def __init__(self, directory: str) -> None:
super().__init__(endpoint_url=f"file://{directory}")
self._active = True
@property
def is_connected(self) -> bool:
return self._active
def upload_blob(self, key: str, payload: bytes) -> bool:
print(f" [LocalFile] Stored {len(payload)} bytes to key '{key}'")
return True
def download_blob(self, key: str) -> bytes:
return b"mock_payload_content"
def main() -> None:
storage = LocalFileStorage("/var/data")
print(f"Storage Endpoint: {storage.endpoint_url}")
print(f"Connected: {storage.is_connected}")
storage.upload_blob("config.json", b'{"status": "ok"}')
if __name__ == "__main__":
main()Run:
uv run python storage_connector.pyOutput:
Storage Endpoint: file:///var/data
Connected: True
[LocalFile] Stored 16 bytes to key 'config.json'
Case 3: Virtual Subclasses via ABC.register()
Sometimes you want an existing third-party class (which you cannot edit) to be recognized as a subclass of your ABC without monkey-patching or subclassing it. Python allows registering virtual subclasses:
# virtual_subclass.py
from abc import ABC, abstractmethod
class SequenceStream(ABC):
@abstractmethod
def read_chunk(self, size: int) -> bytes:
raise NotImplementedError
class ThirdPartyHardwareBuffer:
"""A third-party class provided by a compiled vendor package."""
def read_chunk(self, size: int) -> bytes:
return b"\x00" * size
# Register ThirdPartyHardwareBuffer as a virtual subclass of SequenceStream!
SequenceStream.register(ThirdPartyHardwareBuffer)
def process_stream(stream: SequenceStream) -> None:
print(f"Processing verified stream: {type(stream).__name__}")
data = stream.read_chunk(4)
print(f" Read bytes: {data}")
def main() -> None:
hw = ThirdPartyHardwareBuffer()
# isinstance and issubclass now recognize the virtual inheritance!
print(f"Is hw an instance of SequenceStream? {isinstance(hw, SequenceStream)}")
print(f"Is class a subclass of SequenceStream? {issubclass(ThirdPartyHardwareBuffer, SequenceStream)}")
process_stream(hw)
if __name__ == "__main__":
main()Run:
uv run python virtual_subclass.pyOutput:
Is hw an instance of SequenceStream? True
Is class a subclass of SequenceStream? True
Processing verified stream: ThirdPartyHardwareBuffer
Read bytes: b'\x00\x00\x00\x00'
Pitfalls
Pitfall 1: Attempting to Instantiate an Incomplete ABC
If any method marked with @abstractmethod is not overridden in a subclass, CPython forbids instantiation and raises TypeError at construction time:
from abc import ABC, abstractmethod
class BaseWorker(ABC):
@abstractmethod
def process_job(self) -> None: pass
class IncompleteWorker(BaseWorker):
pass
# THE TRAP:
try:
w = IncompleteWorker()
except TypeError as err:
print(f"Caught expected error: {err}")Output:
Caught expected error: Can't instantiate abstract class IncompleteWorker without an implementation for abstract method 'process_job'
Pitfall 2: Inverting Decorator Order with @property and @abstractmethod
When creating an abstract property, @property must be the outermost decorator, and @abstractmethod must be the innermost:
from abc import ABC, abstractmethod
# THE TRAP (WRONG ORDER):
class BadClass(ABC):
@abstractmethod
@property # BUG: Python does not recognize this as an abstract property!
def name(self) -> str: pass
# THE FIX (CORRECT ORDER):
class GoodClass(ABC):
@property
@abstractmethod # Correct: @property wraps @abstractmethod
def name(self) -> str: passPitfall 3: Assuming @runtime_checkable Validates Types and Signatures
@runtime_checkable on a Protocol only checks that an attribute or method exists and is callable; it does not validate parameter types, return types, or argument counts at runtime!
from typing import Protocol, runtime_checkable
@runtime_checkable
class MathOp(Protocol):
def compute(self, a: int, b: int) -> int: ...
class BrokenMath:
# Has a method named 'compute', but signature takes ZERO arguments!
def compute(self) -> str:
return "not numbers"
b = BrokenMath()
# isinstance evaluates to True because it only checks for the presence of the method name!
print(f"isinstance check: {isinstance(b, MathOp)}") # True!Takeaway: Use static type checkers (mypy or pyright) to verify signatures; treat @runtime_checkable as a shallow existence check.
Exercises
- Define an ABC
CryptographicSignerwith abstract methodssign(payload: bytes) -> bytesandverify(payload: bytes, signature: bytes) -> bool. Implement a concrete subclassHMACSigner. - Define a
typing.ProtocolnamedRenderablethat requires arender_html() -> strmethod. Write two completely unrelated classes that conform to this protocol without inheriting from it. - Demonstrate the error raised when attempting to instantiate an ABC subclass that forgot to implement an abstract
@property. - Implement a custom
__subclasshook__on an ABC so that any class defining an.export()method is dynamically recognized as a subclass viaissubclass(). - Compare the runtime speed of
isinstance(obj, ABC)vsisinstance(obj, Protocol)across 1,000,000 iterations.
Further reading
- PEP 544: Protocols: Structural subtyping (static duck typing).
- PEP 3119: Introducing Abstract Base Classes.
- Luciano Ramalho: Fluent Python (Chapter 13: Interfaces, Protocols, and ABCs).
- Python Documentation:
abc— Abstract Base Classes.