Composición sobre herencia: el principio en la práctica

La herencia parece la solución obvia cuando dos clases comparten comportamiento. Defines la lógica en el padre, la subclase la hereda, listo. El problema es que esa comodidad tiene un precio: la subclase queda acoplada estructuralmente a los internos del padre. No solo a su interfaz pública, sino a cómo está organizado por dentro, qué atributos usa, en qué orden llama sus métodos.

La composición propone algo diferente: en lugar de que una clase sea otra, que la tenga como atributo. Un objeto que referencia a otro objeto y delega en él la responsabilidad que necesita. El acoplamiento no desaparece, pero queda localizado y es reemplazable en tiempo de ejecución.

El principio viene del libro del GoF (Gang of Four, Design Patterns, 1994): “Favor object composition over class inheritance”. No dice que la herencia esté mal, dice que es tu segunda opción, no la primera.

La distinción práctica es esta: la herencia es correcta cuando existe una relación “es-un” verificable y estable. Un Dog es un Animal, sin matices, ahora y siempre. La composición es correcta cuando la relación es “se comporta como” o “usa”. Un Order no es un TaxCalculator, pero lo usa. Si te equivocas en esta distinción, los cambios en el padre empiezan a romper subclases de maneras que no anticipaste, porque Python no tiene un mecanismo que te proteja de ese acoplamiento implícito.

El patrón Strategy es el ejemplo canónico de composición para comportamiento intercambiable. En vez de subclasificar para cambiar un algoritmo, inyectas el algoritmo como objeto. La clase host no sabe (ni le importa) qué implementación concreta recibe, solo sabe que puede llamarle.

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol


# -- Protocolo: define la interfaz que esperamos, sin herencia --
class DiscountStrategy(Protocol):
    def apply(self, price: float) -> float:
        ...


# -- Implementaciones concretas de la estrategia --
@dataclass
class NoDiscount:
    def apply(self, price: float) -> float:
        return price


@dataclass
class PercentageDiscount:
    rate: float  # 0.0 – 1.0

    def apply(self, price: float) -> float:
        return price * (1 - self.rate)


@dataclass
class FlatDiscount:
    amount: float

    def apply(self, price: float) -> float:
        # Nunca dejamos precio negativo
        return max(0.0, price - self.amount)


# -- Clase host: compone la estrategia como atributo --
@dataclass
class Order:
    items: list[tuple[str, float]] = field(default_factory=list)
    discount: DiscountStrategy = field(default_factory=NoDiscount)

    def subtotal(self) -> float:
        return sum(price for _, price in self.items)

    def total(self) -> float:
        # Delega el cálculo al objeto compuesto; Order no sabe nada del algoritmo
        return self.discount.apply(self.subtotal())

    def set_discount(self, strategy: DiscountStrategy) -> None:
        self.discount = strategy


# -- Uso --
if __name__ == "__main__":
    order = Order(items=[("Widget", 30.0), ("Gadget", 70.0)])

    print(order.total())  # 100.0 — sin descuento

    order.set_discount(PercentageDiscount(rate=0.10))
    print(order.total())  # 90.0

    order.set_discount(FlatDiscount(amount=15.0))
    print(order.total())  # 85.0

Lo que hace que este diseño sea robusto

Order tiene una referencia a un objeto DiscountStrategy, no hereda de ninguna clase de descuento. Esa diferencia es todo. Cuando el negocio añada BuyOneGetOne o LoyaltyDiscount, creas una clase nueva con un método apply y la inyectas. Order no cambia. Sus tests no cambian. El código existente en producción no cambia.

Fíjate en DiscountStrategy: es un Protocol, no una clase base abstracta. En Python esto importa. Protocol valida la interfaz estructuralmente en tiempo de análisis estático (mypy, pyright) sin requerir que NoDiscount o PercentageDiscount hereden de nadie. Puedes componer con objetos que no controlás, que vienen de librerías externas, siempre que tengan la forma correcta.

El método set_discount muestra la otra ventaja: el comportamiento es intercambiable en tiempo de ejecución. Con herencia eso es imposible sin recrear el objeto; aquí es una asignación de atributo.

El max(0.0, ...) en FlatDiscount.apply es un buen ejemplo de lógica que pertenece a la estrategia, no al host. Order no necesita saber que los descuentos planos pueden producir negativos. Cada pieza cuida su propio invariante.

Errores que debes conocer

Error: usar herencia porque dos clases comparten implementación, no porque tengan una relación semántica real.

# ❌ Wrong
class CSVExporter(BaseReport):
    # hereda formateo de BaseReport pero no "es" un reporte
    def export(self, data): ...

# ✅ Right
class CSVExporter:
    def __init__(self, formatter: ReportFormatter) -> None:
        self.formatter = formatter  # compone el formateo como dependencia

    def export(self, data): ...

La subclase hereda todo el estado y los métodos de BaseReport, incluidos los que no necesita y los que el día de mañana cambiarán por razones ajenas a ella.


Error: olvidar que set_discount acepta cualquier duck type y no tener anotaciones de tipo, lo que deja errores para runtime.

# ❌ Wrong
def set_discount(self, strategy):
    self.discount = strategy  # ¿tiene .apply()? no lo sabemos hasta que falle

# ✅ Right
def set_discount(self, strategy: DiscountStrategy) -> None:
    self.discount = strategy  # mypy verifica la forma en análisis estático

Con la anotación correcta, mypy rechaza en CI cualquier objeto que no tenga el método apply(float) -> float, convirtiendo errores de runtime en errores de compilación estática.


Error: duplicar lógica entre estrategias porque no existe una clase base opcional para la parte común.

# ❌ Wrong
class PercentageDiscount:
    def apply(self, price: float) -> float:
        return max(0.0, price * (1 - self.rate))  # max duplicado en cada estrategia

class FlatDiscount:
    def apply(self, price: float) -> float:
        return max(0.0, price - self.amount)       # max duplicado

# ✅ Right — clase base *solo* para lógica compartida real entre estrategias
class BaseDiscount:
    def clamp(self, price: float) -> float:
        return max(0.0, price)

Aquí la herencia entre estrategias sí tiene sentido: BaseDiscount agrupa comportamiento auxiliar común, no comportamiento de dominio. La relación “es-un” aplica en el sentido de que todas las estrategias concretas son descuentos con la misma restricción de no negatividad.


La señal de que composición es lo que necesitas es cuando te preguntas “¿qué pasa si quiero cambiar solo esta parte del comportamiento?” y la respuesta con tu diseño actual implica subclasificar o modificar una clase existente.

101

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio