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:
- Combinatorial explosion: 3 solvers x 3 denoisers x 2 measurement models = 18 subclasses
- Fragile base class problem: changing the parent breaks all children
- Hidden coupling: children depend on parent implementation details
Composition β building objects by plugging together independent components β solves all three problems.
Definition: Composition
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
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 (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.
Define the denoiser protocol and implementations
from typing import Protocol
import numpy as np
class Denoiser(Protocol):
def denoise(self, x: np.ndarray, sigma: float) -> np.ndarray: ...
class SoftThreshold:
"""Soft thresholding β optimal for sparse signals."""
def denoise(self, x, sigma):
t = sigma * np.sqrt(2 * np.log(len(x)))
return np.sign(x) * np.maximum(np.abs(x) - t, 0)
class HardThreshold:
"""Hard thresholding β keeps large coefficients exactly."""
def denoise(self, x, sigma):
t = sigma * np.sqrt(2 * np.log(len(x)))
return x * (np.abs(x) >= t)
class WienerFilter:
"""Wiener filter β MMSE estimator for Gaussian signals."""
def denoise(self, x, sigma):
power = np.mean(x**2)
return x * (power / (power + sigma**2))
Compose the solver with different strategies
class IterativeSolver:
def __init__(self, A, denoiser: Denoiser, n_iters=100, step_size=0.01):
self.A = A
self.denoiser = denoiser
self.n_iters = n_iters
self.step_size = step_size
def solve(self, y):
x = np.zeros(self.A.shape[1])
history = []
for _ in range(self.n_iters):
grad = self.A.T @ (self.A @ x - y)
x = self.denoiser.denoise(x - self.step_size * grad, sigma=0.1)
history.append(np.linalg.norm(self.A @ x - y))
return x, history
# Compare all three denoisers
A = np.random.randn(50, 100) / np.sqrt(50)
y = A @ x_true + 0.01 * np.random.randn(50)
for denoiser in [SoftThreshold(), HardThreshold(), WienerFilter()]:
solver = IterativeSolver(A, denoiser)
x_hat, history = solver.solve(y)
print(f"{denoiser.__class__.__name__}: final residual = {history[-1]:.4f}")
Example: Dependency Injection for Testable Simulations
Show how dependency injection makes simulation code testable by allowing mock components in unit tests.
Production code with injected dependencies
class SimulationPipeline:
def __init__(self, operator, denoiser, logger=None):
self.operator = operator
self.denoiser = denoiser
self.logger = logger or (lambda msg: None)
def run(self, x_true, noise_std=0.01):
y = self.operator.forward(x_true)
y += noise_std * np.random.randn(len(y))
x_hat = self._solve(y)
nmse = np.linalg.norm(x_hat - x_true)**2 / np.linalg.norm(x_true)**2
self.logger(f"NMSE = {10*np.log10(nmse):.1f} dB")
return x_hat, nmse
def _solve(self, y):
m, n = self.operator.shape
x = np.zeros(n)
for _ in range(50):
r = self.operator.adjoint(self.operator.forward(x) - y)
x = self.denoiser.denoise(x - 0.01 * r, sigma=0.1)
return x
Unit test with mock components
class MockOperator:
"""Identity operator for testing."""
def forward(self, x): return x
def adjoint(self, y): return y
@property
def shape(self): return (len(self._n), len(self._n))
def __init__(self, n):
self._n = list(range(n))
class MockDenoiser:
"""No-op denoiser for testing."""
def denoise(self, x, sigma):
return x
def test_pipeline_converges():
pipeline = SimulationPipeline(
operator=MockOperator(10),
denoiser=MockDenoiser(),
)
x_true = np.array([1, 0, 0, 2, 0, 0, 0, 3, 0, 0], dtype=float)
x_hat, nmse = pipeline.run(x_true, noise_std=0.0)
assert nmse < 0.01, f"Pipeline did not converge: NMSE={nmse}"
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
Inheritance vs Composition Architecture
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
Inheritance requires 4 x 3 x 2 = 24 subclasses for every combination. Composition requires only 4 + 3 + 2 = 9 component classes, which are freely combined.
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
# Code from: ch03/python/strategy_pattern.py
# Load from backend supplements endpointDependency Injection
# Code from: ch03/python/dependency_injection.py
# Load from backend supplements endpointSimulationConfig Pattern
# Code from: ch03/python/simulation_config.py
# Load from backend supplements endpointKey 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.