Composition over Inheritance

Why Deep Inheritance Hierarchies Hurt Research Code

In research, you constantly recombine components: try a new denoiser with an old solver, swap the measurement matrix, change the loss function. Deep inheritance hierarchies make this painful because:

  1. Combinatorial explosion: 3 solvers x 3 denoisers x 2 measurement models = 18 subclasses
  2. Fragile base class problem: changing the parent breaks all children
  3. Hidden coupling: children depend on parent implementation details

Composition β€” building objects by plugging together independent components β€” solves all three problems.

Definition:

Composition

Composition is the design principle of building complex objects by combining simpler, independent components rather than inheriting from a base class. Components are stored as instance attributes:

class CompressedSensingSolver:
    """Solver built by composing independent components."""

    def __init__(
        self,
        operator: ForwardOperatorProtocol,
        denoiser: DenoiserProtocol,
        config: SimulationConfig,
    ):
        self.operator = operator
        self.denoiser = denoiser
        self.config = config

    def solve(self, y: np.ndarray) -> np.ndarray:
        m, n = self.operator.shape
        x = np.zeros(n)
        for _ in range(self.config.max_iterations):
            residual = self.operator.forward(x) - y
            gradient = self.operator.adjoint(residual)
            x = self.denoiser.denoise(x - 0.01 * gradient, sigma=0.1)
        return x

You can now mix and match any operator with any denoiser without creating new subclasses.

Definition:

Strategy Pattern

The strategy pattern defines a family of interchangeable algorithms and lets you swap them at runtime. In Python, strategies can be classes, functions, or any callable:

from typing import Protocol

class DenoiserProtocol(Protocol):
    def denoise(self, x: np.ndarray, sigma: float) -> np.ndarray: ...

class SoftThresholdDenoiser:
    def denoise(self, x: np.ndarray, sigma: float) -> np.ndarray:
        threshold = sigma * np.sqrt(2 * np.log(len(x)))
        return np.sign(x) * np.maximum(np.abs(x) - threshold, 0)

class WaveletDenoiser:
    def denoise(self, x: np.ndarray, sigma: float) -> np.ndarray:
        # Wavelet-based denoising
        coeffs = np.fft.fft(x)
        threshold = sigma * np.sqrt(2 * np.log(len(x)))
        coeffs[np.abs(coeffs) < threshold] = 0
        return np.real(np.fft.ifft(coeffs))

class BM3DDenoiser:
    def __init__(self, profile: str = "np"):
        self.profile = profile

    def denoise(self, x: np.ndarray, sigma: float) -> np.ndarray:
        # Simplified BM3D-like denoising
        from scipy.ndimage import gaussian_filter1d
        return gaussian_filter1d(x, sigma=sigma)

All three denoisers satisfy DenoiserProtocol and can be plugged into the solver without any code changes.

Definition:

Dependency Injection (DI)

Dependency injection is the practice of passing dependencies (components, services, configurations) to an object from outside rather than having it create them internally. This makes code testable and flexible:

# WITHOUT DI: hard to test, hard to change
class BadSolver:
    def __init__(self):
        self.operator = GaussianMatrix(100, 500)  # hardcoded!
        self.denoiser = SoftThresholdDenoiser()    # hardcoded!

# WITH DI: testable, flexible
class GoodSolver:
    def __init__(self, operator, denoiser, config):
        self.operator = operator      # injected
        self.denoiser = denoiser      # injected
        self.config = config          # injected

DI enables unit testing with mock objects, swapping backends (CPU/GPU), and parameter sweeps over different component combinations.

Example: Strategy Pattern: Swappable Denoisers

Build a compressed sensing solver that accepts any denoiser conforming to a protocol. Run the same solver with three different denoisers and compare convergence.

Example: Dependency Injection for Testable Simulations

Show how dependency injection makes simulation code testable by allowing mock components in unit tests.

Composition vs Inheritance

Compare how composition and inheritance scale as you add more component variants. See the combinatorial explosion of subclasses vs. the linear growth of composed components.

Parameters

Strategy Pattern Animation

Watch how different denoiser strategies affect the convergence of an iterative solver on a sparse recovery problem. Each frame shows one iteration of the algorithm with the current estimate overlaid on the true signal.

Parameters
0.01

Inheritance vs Composition Architecture

Inheritance vs Composition Architecture
Left: inheritance creates a combinatorial explosion of subclasses. Right: composition combines independent components linearly.

Common Mistake: The God Class Anti-Pattern

Mistake:

class Simulation:
    """Does EVERYTHING: generates data, solves, plots, saves..."""

    def __init__(self):
        self.A = np.random.randn(100, 500)
        self.denoiser_type = "soft"
        self.solver_type = "ista"
        # ... 20 more attributes ...

    def generate_data(self): ...
    def solve(self): ...
    def plot_results(self): ...
    def save_to_disk(self): ...
    def compute_metrics(self): ...

Correction:

Split into focused, composable components:

class DataGenerator:
    def generate(self, config) -> tuple[np.ndarray, np.ndarray]: ...

class Solver:
    def solve(self, A, y) -> np.ndarray: ...

class MetricsComputer:
    def compute(self, x_true, x_hat) -> dict: ...

class ResultSaver:
    def save(self, results, path) -> None: ...

# Compose in a thin orchestrator
class Experiment:
    def __init__(self, generator, solver, metrics, saver):
        self.generator = generator
        self.solver = solver
        self.metrics = metrics
        self.saver = saver

    def run(self, config):
        A, y, x_true = self.generator.generate(config)
        x_hat = self.solver.solve(A, y)
        results = self.metrics.compute(x_true, x_hat)
        self.saver.save(results, config.output_path)
        return results

Why This Matters: Composition in Wireless Communication Systems

Modern wireless systems are naturally compositional. A receiver pipeline composes: channel estimator, equalizer, demodulator, and decoder. The strategy pattern lets you swap any component:

  • Channel estimator: LS, MMSE, DNN-based
  • Equalizer: ZF, MMSE, iterative (LMMSE-PIC)
  • Decoder: Turbo, LDPC, Polar

In Chapter 20 (OFDM Systems), we build a complete OFDM transceiver using composition, allowing easy experiments with different component combinations.

See full treatment in Chapter 20

Quick Check

You have 4 solver algorithms, 3 denoisers, and 2 measurement operators. How many classes do you need with inheritance vs composition?

Inheritance: 9 classes, Composition: 24 classes

Inheritance: 24 classes, Composition: 9 classes

Both need 24 classes

Both need 9 classes

Composition

A design principle where complex objects are built by combining simpler, independent components as instance attributes, rather than through inheritance hierarchies.

Related: Strategy Pattern, Dependency Injection

Strategy Pattern

A design pattern that defines a family of interchangeable algorithms encapsulated behind a common interface, allowing the algorithm to vary independently from the client code.

Related: Composition, Dependency Injection

Dependency Injection

The practice of passing dependencies (components, services) to an object from outside rather than having the object create them internally. Enables testing with mock objects and flexible configuration.

Related: Composition, Strategy Pattern

Strategy Pattern

python
Strategy pattern implementation for swappable denoisers and solvers in a compressed sensing pipeline.
# Code from: ch03/python/strategy_pattern.py
# Load from backend supplements endpoint

Dependency Injection

python
Dependency injection pattern for testable simulation code with mock components.
# Code from: ch03/python/dependency_injection.py
# Load from backend supplements endpoint

SimulationConfig Pattern

python
Complete SimulationConfig dataclass pattern with serialization, validation, and parameter sweeps.
# Code from: ch03/python/simulation_config.py
# Load from backend supplements endpoint

Key Takeaway

Prefer composition over inheritance in scientific code. The strategy pattern and dependency injection let you swap algorithms, backends, and configurations without modifying existing code. This avoids the combinatorial explosion of subclasses and makes code testable with mock components.