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:

  1. Abstract Base Classes (ABCs): nominal subtyping β€” "you must inherit from this"
  2. Protocols: structural subtyping β€” "you must have these methods"

This section covers both and shows when to use each.

Definition:

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)

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 subtyping (ABCs): a type is a subtype if it explicitly inherits from the base. class GaussianMatrix(ForwardOperator) is a subtype of ForwardOperator by 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(), and shape satisfies ForwardOperatorProtocol even 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 CC conforms to Protocol PP if and only if for every method signature m(a1:T1,…,ak:Tk)β†’Rm(a_1: T_1, \ldots, a_k: T_k) \to R declared in PP, CC has a method mm whose parameter types are contravariant (supertypes of TiT_i are acceptable) and whose return type is covariant (a subtype of RR 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.

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

FeatureABC (abc.ABC)Protocol (typing.Protocol)
Subtyping styleNominal (must inherit)Structural (duck typing)
Instantiation checkTypeError at init if abstract methods missingNo instantiation check (static analysis only)
Runtime isinstanceAlways worksOnly with @runtime_checkable
Third-party classesCannot add ABC to classes you don't ownAny class with matching methods conforms
Mixin behaviorCan include concrete methods and stateCannot include implementation (interface only)
Best forInternal APIs you controlLibrary boundaries, multi-backend support
Type checker supportFull (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

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

python
Complete implementation of ForwardOperator using both ABC and Protocol approaches, with multiple backend examples.
# Code from: ch03/python/abc_protocols.py
# Load from backend supplements endpoint

Key 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.