Abstract Base Classes and Protocols
Why Interfaces Matter in Scientific Code
Scientific code evolves fast: you swap NumPy for CuPy, replace one denoiser with another, or switch from simulated to real data. Well-defined interfaces let you make these swaps without rewriting downstream code.
Python offers two mechanisms for defining interfaces:
- Abstract Base Classes (ABCs): nominal subtyping β "you must inherit from this"
- Protocols: structural subtyping β "you must have these methods"
This section covers both and shows when to use each.
Definition: Abstract Base Class (ABC)
Abstract Base Class (ABC)
An abstract base class defines an interface that subclasses must implement. It cannot be instantiated directly:
from abc import ABC, abstractmethod
import numpy as np
class ForwardOperator(ABC):
"""Interface for linear forward operators A in y = Ax + n."""
@abstractmethod
def forward(self, x: np.ndarray) -> np.ndarray:
"""Apply forward operation: y = A @ x."""
...
@abstractmethod
def adjoint(self, y: np.ndarray) -> np.ndarray:
"""Apply adjoint operation: x = A.T @ y."""
...
@property
@abstractmethod
def shape(self) -> tuple[int, int]:
"""Return (m, n) shape of the operator."""
...
Attempting ForwardOperator() raises TypeError. Subclasses must implement
all @abstractmethod methods, or they too become abstract.
Definition: Protocol (Structural Subtyping)
Protocol (Structural Subtyping)
A typing.Protocol defines an interface based on structure (what methods
and attributes exist) rather than inheritance (what class you inherit from).
This is Python's formalization of duck typing:
from typing import Protocol, runtime_checkable
import numpy as np
@runtime_checkable
class ForwardOperatorProtocol(Protocol):
"""Any object with forward(), adjoint(), and shape works."""
def forward(self, x: np.ndarray) -> np.ndarray: ...
def adjoint(self, y: np.ndarray) -> np.ndarray: ...
@property
def shape(self) -> tuple[int, int]: ...
Any class that has forward(), adjoint(), and shape satisfies this
protocol β no inheritance required. With @runtime_checkable, you can
use isinstance(obj, ForwardOperatorProtocol) at runtime.
Definition: Nominal vs. Structural Subtyping
Nominal vs. Structural Subtyping
-
Nominal subtyping (ABCs): a type is a subtype if it explicitly inherits from the base.
class GaussianMatrix(ForwardOperator)is a subtype ofForwardOperatorby declaration. -
Structural subtyping (Protocols): a type is a subtype if it has the required attributes and methods, regardless of inheritance. A CuPy-based operator class that happens to have
forward(),adjoint(), andshapesatisfiesForwardOperatorProtocoleven if it has never heard of it.
Protocols are more flexible for library boundaries (you cannot control what third-party code inherits from), while ABCs are better for enforcing contracts within your own codebase.
Theorem: ABC Completeness Guarantee
If class C inherits from an ABC B and does not implement all abstract
methods of B, then C is itself abstract and C() raises TypeError
at instantiation time β not at method call time.
This provides a fail-fast guarantee: incomplete implementations are caught at object creation, not deep inside a simulation loop.
ABCs shift errors from runtime method calls to instantiation. This is especially valuable in scientific computing where a simulation might run for hours before hitting an unimplemented method.
Theorem: Protocol Structural Conformance
A class conforms to Protocol if and only if for every method
signature declared in ,
has a method whose parameter types are contravariant (supertypes
of are acceptable) and whose return type is covariant (a subtype
of is acceptable).
For @runtime_checkable protocols, isinstance checks only verify that
the methods exist (by name), not that their signatures match.
Static type checkers (mypy, pyright) verify full signature compatibility.
Runtime isinstance checks are a weaker "does the method exist?" test.
Use both for defense in depth: protocols for static analysis, runtime
checks for assertions at API boundaries.
Example: ForwardOperator with Multiple Backends
Implement the ForwardOperatorProtocol for three backends: a dense NumPy
matrix, a structured (random Gaussian) operator, and a CuPy GPU operator.
Show that all three work with the same solver code.
Dense NumPy implementation
class DenseOperator:
"""Forward operator using an explicit dense matrix."""
def __init__(self, matrix: np.ndarray):
self._matrix = matrix
def forward(self, x: np.ndarray) -> np.ndarray:
return self._matrix @ x
def adjoint(self, y: np.ndarray) -> np.ndarray:
return self._matrix.T @ y
@property
def shape(self) -> tuple[int, int]:
return self._matrix.shape
Structured operator (no matrix stored)
class GaussianOperator:
"""Random Gaussian operator β generates on the fly from a seed."""
def __init__(self, m: int, n: int, seed: int = 42):
self._m, self._n, self._seed = m, n, seed
def forward(self, x: np.ndarray) -> np.ndarray:
rng = np.random.default_rng(self._seed)
A = rng.standard_normal((self._m, self._n)) / np.sqrt(self._m)
return A @ x
def adjoint(self, y: np.ndarray) -> np.ndarray:
rng = np.random.default_rng(self._seed)
A = rng.standard_normal((self._m, self._n)) / np.sqrt(self._m)
return A.T @ y
@property
def shape(self) -> tuple[int, int]:
return (self._m, self._n)
Using protocols for backend-agnostic code
def solve_lasso(operator: ForwardOperatorProtocol,
y: np.ndarray,
lam: float = 0.1,
n_iters: int = 100) -> np.ndarray:
"""Solve LASSO using any object conforming to ForwardOperatorProtocol."""
m, n = operator.shape
x = np.zeros(n)
for _ in range(n_iters):
residual = operator.forward(x) - y
gradient = operator.adjoint(residual)
x = soft_threshold(x - 0.01 * gradient, 0.01 * lam)
return x
# Works with both β no inheritance required!
dense_op = DenseOperator(np.random.randn(50, 100))
gauss_op = GaussianOperator(50, 100)
x1 = solve_lasso(dense_op, y)
x2 = solve_lasso(gauss_op, y)
Protocol Dispatch Demo
Compare how different ForwardOperator implementations (dense matrix, structured operator) perform on the same compressed sensing problem. See how structural subtyping lets you swap backends seamlessly.
Parameters
ABC vs Protocol: When to Use Each
| Feature | ABC (abc.ABC) | Protocol (typing.Protocol) |
|---|---|---|
| Subtyping style | Nominal (must inherit) | Structural (duck typing) |
| Instantiation check | TypeError at init if abstract methods missing | No instantiation check (static analysis only) |
| Runtime isinstance | Always works | Only with @runtime_checkable |
| Third-party classes | Cannot add ABC to classes you don't own | Any class with matching methods conforms |
| Mixin behavior | Can include concrete methods and state | Cannot include implementation (interface only) |
| Best for | Internal APIs you control | Library boundaries, multi-backend support |
| Type checker support | Full (mypy, pyright) | Full (mypy, pyright) |
Common Mistake: runtime_checkable Only Checks Method Existence
Mistake:
@runtime_checkable
class Transformer(Protocol):
def transform(self, x: np.ndarray) -> np.ndarray: ...
class BadTransformer:
def transform(self, x: int) -> str: # Wrong signature!
return str(x)
isinstance(BadTransformer(), Transformer) # True! Signature not checked.
Correction:
Use @runtime_checkable for quick existence checks, but rely on static
type checkers (mypy/pyright) for full signature verification:
$ mypy my_code.py
error: Argument 1 to "solve" has incompatible type "BadTransformer";
expected "Transformer"
For critical runtime checks, add explicit assertions:
import inspect
sig = inspect.signature(obj.transform)
assert len(sig.parameters) == 2 # self + x
Historical Note: Duck Typing: 'If It Quacks Like a Duck'
PEP 544 (2019)The term "duck typing" comes from James Whitcomb Riley's principle: "When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck." Python has always embraced duck typing β you can pass any object to a function as long as it has the right methods.
typing.Protocol (PEP 544, Python 3.8) formalized this by giving
duck typing support in static type checkers. Before protocols, you
had to choose between duck typing (no type checking) and ABCs
(forced inheritance).
Quick Check
You are writing a library that accepts user-defined forward operators from third-party packages. Which interface mechanism should you prefer?
abc.ABC β it forces correct implementation
typing.Protocol β it uses structural subtyping
Neither β just use try/except at runtime
Use both ABC and Protocol for the same interface
Protocols check for the presence of methods regardless of inheritance. Third-party classes automatically conform if they have the right methods.
Abstract Base Class (ABC)
A class that cannot be instantiated and defines abstract methods that subclasses must implement. Created by inheriting from abc.ABC and using @abstractmethod.
Related: Protocol
Protocol
A typing.Protocol subclass that defines a structural interface. Any class with matching method signatures conforms to the protocol without explicit inheritance.
Related: Abstract Base Class (ABC)
ABC and Protocol Examples
# Code from: ch03/python/abc_protocols.py
# Load from backend supplements endpointKey Takeaway
Use ABCs when you control the class hierarchy and want fail-fast guarantees (missing methods caught at instantiation). Use Protocols when you need to accept objects from third-party code or support multiple backends without forcing inheritance. Both provide full static type checker support.