Composición y diseño de clases
Construir objetos a partir de otros: responsabilidades pequeñas, inyección de dependencias y menos acoplamiento
La composición construye objetos a partir de otros en vez de heredar de ellos. Aquí diseñas clases con una responsabilidad clara, pasas las dependencias por el constructor y montas un pipeline de datos que puedes cambiar y probar sin tocar el resto del sistema.
- Distinguir una relación «tiene un» de una relación «es un» y elegir composición
- Delegar trabajo a un objeto guardado como atributo
- Inyectar dependencias por el constructor y sustituirlas en una prueba
- Separar un pipeline de datos en clases pequeñas con una sola responsabilidad
- Reconocer el acoplamiento excesivo y el estado compartido accidental
Antes necesitas: Herencia, polimorfismo y abstracción
En esta lección
- ¿Qué es la composición?
- ¿Para qué sirve?
- ¿Cómo funciona por dentro?
- Sintaxis
- Ejemplo básico: el auto y su motor
- Ejemplo intermedio: inyectar un colaborador
- Caso práctico: pipeline de datos con clases pequeñas
- Visualización: dos pasos encadenados
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
En la lección anterior usaste la herencia para reutilizar comportamiento. Funciona, pero no es la única forma y muchas veces no es la correcta. Un Auto no es un Motor: lo tiene. Un Informe no es un LectorCSV: lo usa. Forzar esas relaciones en una jerarquía produce clases que arrastran métodos que no les corresponden.
La composición resuelve esos casos: un objeto guarda otros objetos como atributos y les delega la parte del trabajo que les corresponde. Es la técnica más usada en código real y la que convierte un programa en un conjunto de piezas sustituibles en lugar de un bloque monolítico.
¿Qué es la composición?
Componer es construir un objeto a partir de otros. Auto recibe un Motor en su constructor, lo guarda en self.motor y, cuando necesita arrancar, llama a self.motor.arrancar(). La clase no hereda el comportamiento: lo delega.
Piensa en piezas de Lego: el castillo no hereda del ladrillo, se construye con ladrillos. Cambiar una torre por otra más alta no exige redefinir el castillo entero; basta con cambiar esa pieza. En código, sustituir el lector de CSV por uno de Excel debería ser igual de local.
Componer no significa crear veinte clases de tres líneas. Significa que cada clase tenga una responsabilidad identificable y que las demás colaboren con ella en lugar de repetir su lógica. Si una clase lee archivos, limpia datos y escribe informes, tiene tres motivos para cambiar y ninguna prueba sencilla.
¿Para qué sirve?
- Cambiar una pieza sin tocar el resto: pasar de un lector de lista a un lector de CSV sin reescribir el informe.
- Probar sin infraestructura: inyectar un notificador que guarda mensajes en una lista en vez de enviar correos.
- Evitar jerarquías rígidas: no todo encaja en un árbol de clases, y forzarlo cuesta más de lo que ahorra.
- Modelar el dominio: un pedido tiene líneas; un informe tiene fuente, limpiador y resumen.
- Encadenar pasos de ML: escalar, imputar y codificar son objetos que se aplican en secuencia sobre los datos.
¿Cómo funciona por dentro?
Cuando Auto.arrancar ejecuta self.motor.arrancar(), ocurren dos pasos: se busca el atributo motor en el objeto y se invoca el método sobre ese otro objeto. Python no copia el motor dentro del auto: guarda una referencia. Si dos autos reciben el mismo Motor, los dos ven los cambios que se hagan en él.
Esa referencia compartida es útil para un servicio que todos usan y peligrosa para un estado que debería ser propio. Si tu clase guarda una lista recibida y la modifica, estás cambiando la lista de quien te la pasó. Copiar al recibir (list(valores)) o al devolver evita sorpresas.
La inyección de dependencias es la consecuencia de diseño: la clase no crea sus colaboradores dentro de un método, los recibe en el constructor. Informe(fuente, limpiador, resumen) puede recibir tres objetos reales en producción y tres dobles en una prueba. Si Informe hiciera self.fuente = LectorCSV(ruta), quedaría atado a una ruta y a un formato.
El acoplamiento mide cuánto sabe una clase de otra. Llamar a .leer() es un acoplamiento débil: solo depende de un nombre y de un contrato. Llamar a lector.archivo.name.split(".")[1] es fuerte: depende de la estructura interna y se rompe si el colaborador cambia. Cuanto menos sepa, más fácil es sustituir.
Sintaxis
class Motor:
def __init__(self, caballos):
self.caballos = caballos
def arrancar(self):
return f"Motor de {self.caballos} CV en marcha"
class Auto:
def __init__(self, marca, motor):
self.marca = marca
self.motor = motor # el motor se recibe, no se crea
def arrancar(self):
return f"{self.marca}: {self.motor.arrancar()}"
auto = Auto("Toyota", Motor(120))
print(auto.arrancar())| Forma | Qué expresa | Ejemplo |
|---|---|---|
| Atributo compuesto | «tiene un» | self.motor = motor |
| Delegación | reenviar el trabajo al colaborador | self.motor.arrancar() |
| Inyección por constructor | recibir la dependencia desde fuera | Auto("Toyota", motor) |
| Interfaz implícita | basta con que exista el método | fuente.leer() |
| Herencia | «es un» | class Auto(Vehiculo) |
class Motor:
def __init__(self, caballos):
self.caballos = caballos
def arrancar(self):
return f"Motor de {self.caballos} CV en marcha"
class Auto:
def __init__(self, marca, motor):
self.marca = marca
self.motor = motor
def arrancar(self):
return f"{self.marca}: {self.motor.arrancar()}"
print(Auto("Toyota", Motor(120)).arrancar())#include <iostream>
#include <string>
#include <utility>
class Motor {
public:
explicit Motor(int caballos) : caballos_(caballos) {}
std::string arrancar() const {
return "Motor de " + std::to_string(caballos_) + " CV en marcha";
}
private:
int caballos_;
};
class Auto {
public:
Auto(std::string marca, Motor motor)
: marca_(std::move(marca)), motor_(std::move(motor)) {}
std::string arrancar() const {
return marca_ + ": " + motor_.arrancar();
}
private:
std::string marca_;
Motor motor_;
};
int main() {
Auto auto1{"Toyota", Motor{120}};
std::cout << auto1.arrancar() << '\n';
return 0;
}En Python `self.motor` guarda una referencia: el motor se comparte si el mismo objeto se pasa a dos autos. En C++ el miembro es un valor y se copia o se mueve al construir; para compartir haría falta una referencia o un puntero.
Ejemplo básico: el auto y su motor
El caso mínimo: el auto no sabe calcular caballos ni encender el motor, solo coordina. Motor puede cambiar su funcionamiento interno sin que Auto se entere.
class Motor:
def __init__(self, caballos):
self.caballos = caballos
def arrancar(self):
return "en marcha"
class Auto:
def __init__(self, marca, motor):
self.marca = marca
self.motor = motor
def arrancar(self):
return f"{self.marca}: {self.motor.arrancar()}"
auto = Auto("Toyota", Motor(120))
print(auto.arrancar())
print(auto.motor.caballos)La salida es Toyota: en marcha y 120. auto.motor es un objeto Motor completo, no una copia de sus atributos. Por eso auto.motor.caballos funciona. Si mañana Motor añade consumo, el auto lo expone sin tocar una línea, y si se reemplaza por un MotorElectrico con el mismo método arrancar, el auto sigue funcionando.
Ejemplo intermedio: inyectar un colaborador
Un servicio de pagos debe avisar de cada cobro. Si el aviso se imprime dentro del método, no puedes probar el cobro sin ensuciar la salida ni cambiar de canal sin editar el servicio. Inyectar el notificador separa ambas responsabilidades.
class NotificadorConsola:
def __init__(self, prefijo):
self.prefijo = prefijo
def enviar(self, mensaje):
print(f"{self.prefijo} {mensaje}")
class ServicioPagos:
def __init__(self, notificador):
self.notificador = notificador
self.pagos = []
def cobrar(self, monto):
if monto <= 0:
raise ValueError("el monto debe ser positivo")
self.pagos.append(monto)
self.notificador.enviar(f"cobro de {monto}")
return monto
class NotificadorFalso:
def __init__(self):
self.mensajes = []
def enviar(self, mensaje):
self.mensajes.append(mensaje)
falso = NotificadorFalso()
servicio = ServicioPagos(falso)
servicio.cobrar(50)
print(len(falso.mensajes), falso.mensajes)La salida es 1 ['cobro de 50']. NotificadorFalso no hereda de NotificadorConsola: solo tiene un método enviar con la misma firma. El servicio no distingue uno del otro, y esa es la ventaja. En una prueba real comprobarías falso.mensajes sin abrir un buzón ni depender de la red, y en producción inyectarías el notificador de correo.
Caso práctico: pipeline de datos con clases pequeñas
Un informe de ventas necesita tres pasos: leer filas, limpiar las que traen nulos y resumir el total. En lugar de una función de cuarenta líneas, cada paso es un objeto y Informe los compone.
class LectorLista:
def __init__(self, filas):
self.filas = filas
def leer(self):
return list(self.filas)
class LimpiadorNulos:
def __init__(self, valor):
self.valor = valor
def limpiar(self, filas):
limpias = []
for fila in filas:
if fila["ventas"] is None:
fila = dict(fila, ventas=self.valor)
limpias.append(fila)
return limpias
class Resumen:
def __init__(self, campo):
self.campo = campo
def calcular(self, filas):
total = sum(fila[self.campo] for fila in filas)
return {self.campo: total, "filas": len(filas)}
class Informe:
def __init__(self, fuente, limpiador, resumen):
self.fuente = fuente
self.limpiador = limpiador
self.resumen = resumen
def generar(self):
filas = self.fuente.leer()
filas = self.limpiador.limpiar(filas)
return self.resumen.calcular(filas)
datos = [
{"sede": "Lima", "ventas": 100},
{"sede": "Lima", "ventas": None},
{"sede": "Arequipa", "ventas": 80},
]
informe = Informe(LectorLista(datos), LimpiadorNulos(0), Resumen("ventas"))
print(informe.generar())La salida es {'ventas': 180, 'filas': 3}. Informe no sabe de dónde vienen las filas ni cómo se limpian: solo pide leer(), limpiar() y calcular(). Para pasar a un CSV real, escribes LectorCSV con un método leer() y cambias una sola línea. Para probar el resumen con datos fijos, le pasas LectorLista.
LimpiadorNulos construye dict(fila, ventas=self.valor), que crea un diccionario nuevo en vez de modificar el original. Si mutara la fila recibida, el datos del llamador quedaría alterado y una segunda ejecución del informe daría otro resultado. Ese es el tipo de efecto invisible que la composición con datos copiados evita.
Visualización: dos pasos encadenados
Un Pipeline guarda una lista de pasos y aplica cada uno al resultado del anterior. Sigue cómo datos cambia de [1, 2] a [2, 4] y después a [20, 40].
class Escalador: def __init__(self, factor): self.factor = factor def aplicar(self, valores): return [v * self.factor for v in valores]class Pipeline: def __init__(self, pasos): self.pasos = pasos def ejecutar(self, datos): for paso in self.pasos: datos = paso.aplicar(datos) return datosp = Pipeline([Escalador(2), Escalador(10)])print(p.ejecutar([1, 2]))Pipeline recibe una lista con dos objetos Escalador ya construidos. El pipeline no los crea: los recibe, así que podría recibir cualquier objeto con un método aplicar.—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
Errores comunes
| Mensaje | Causa habitual | Cómo se arregla |
|---|---|---|
AttributeError: 'Auto' object has no attribute 'motor' | El __init__ no guardó el colaborador en self | Asigna self.motor = motor antes de usarlo |
TypeError: Auto.__init__() missing 1 required positional argument: 'motor' | Creaste el auto sin pasar la dependencia | Pasa un objeto Motor o dale un valor por defecto explícito |
AttributeError: 'LectorCSV' object has no attribute 'leer' | El colaborador usa otro nombre de método (leer_filas) | Unifica la interfaz o adapta con una clase pequeña |
TypeError: 'NoneType' object is not callable | Un colaborador quedó en None y llamaste a su método | Valida la dependencia al construir o falla antes con un mensaje claro |
class Pipeline:
def __init__(self, pasos=[]):
self.pasos = pasos
p1 = Pipeline()
p1.pasos.append("escalar")
p2 = Pipeline()
print(p2.pasos)class Pipeline:
def __init__(self, pasos=None):
self.pasos = list(pasos or [])
p1 = Pipeline()
p1.pasos.append("escalar")
p2 = Pipeline()
print(p2.pasos)El valor por defecto se evalúa una sola vez, cuando se define la función, y todas las instancias apuntan a la misma lista. El código incorrecto imprime `['escalar']`. Con `None` como centinela y una copia dentro del `__init__`, cada objeto empieza vacío.
Buenas prácticas
- Elige composición cuando la relación sea «tiene un» o «usa un»; reserva la herencia para «es un».
- Recibe las dependencias por el constructor en lugar de crearlas dentro del método.
- Depende del comportamiento (
.leer()), no de la estructura interna del colaborador. - Evita cadenas largas como
informe.fuente.archivo.carpeta: cada punto extra es acoplamiento. - Una clase, una responsabilidad. Si no puedes describirla en una frase sin «y», divídela.
- No compartas listas o diccionarios mutables sin decidir si quieres compartirlos.
- No uses un valor por defecto mutable (
[],{}): usaNoney crea dentro. - En pruebas, sustituye el colaborador por un doble que registre llamadas.
Ejercicios
Carrito con calculadora de impuestos
CalculadoraIGV(tasa) con un método calcular(subtotal) que devuelva el subtotal con impuesto redondeado a dos decimales. Crea Carrito(calculadora) que acumule precios en una lista y ofrezca subtotal() y total(), delegando el cálculo en la calculadora. Prueba el carrito con una tasa de 0.18 y dos precios, y luego con una calculadora falsa que no aplica impuesto.Pista
El carrito no debe importar CalculadoraIGV; solo llama a self.calculadora.calcular(...). Una calculadora falsa puede ser una clase con el mismo método que devuelva el valor tal cual.
Monitor con fuente, regla y notificador
leer() que devuelva una lista de lecturas, una regla con evaluar(valor) que diga si supera un límite y un notificador con enviar(mensaje). Monitor(fuente, regla, notificador) debe revisar cada lectura y enviar una alerta con su posición cuando la regla se cumpla, devolviendo la lista de alertas. Pruébalo con [20, 35, 31, 10] y un límite de 30.Pista
Recorre con enumerate(self.fuente.leer(), start=1) para tener la posición. El notificador puede guardar los mensajes en una lista para que puedas comprobar el resultado.
class Informe: def __init__(self, fuente, limpiador): self.fuente = self.limpiador = limpiador def generar(self): filas = self.fuente.() return self.limpiador.limpiar()
La fuente recibida se guarda tal cual; el método que aporta es `leer()`; el limpiador recibe lo que devolvió la fuente.
Soluciones explicadas
En el carrito, la decisión importante no es la fórmula del IGV, sino dónde vive. Si el impuesto estuviera dentro de Carrito.total, cambiar la tasa o añadir exoneraciones obligaría a editar el carrito y a repetir la lógica en cada prueba. Con la calculadora inyectada, el carrito se ocupa de acumular y la política fiscal se cambia en un objeto aparte.
En el monitor, los tres colaboradores tienen interfaces mínimas: leer, evaluar, enviar. Ninguna clase hereda de otra y ninguna importa a las demás. Esa independencia permite probar la regla sola (ReglaUmbral(30).evaluar(31) es True) sin construir un monitor completo, algo imposible si la regla estuviera enterrada en un método de veinte líneas.
El límite de la composición es la legibilidad: si un objeto necesita seis colaboradores para hacer algo, el constructor se vuelve una lista de parámetros y conviene agruparlos en una configuración. Y si dos clases comparten mucha lógica interna, puede que sí exista una relación «es un» que estás ignorando. La regla práctica sigue siendo la misma: composición por defecto, herencia cuando la sustitución sea real.
Resumen
- La composición construye un objeto con otros objetos; la herencia declara que uno es otro.
- Delegar es guardar el colaborador y reenviarle el trabajo:
self.motor.arrancar(). - La inyección por constructor permite sustituir dependencias reales por dobles de prueba.
- El acoplamiento crece cuando una clase conoce detalles internos de otra; depende del método, no de la estructura.
- En Python las referencias se comparten: decide si el colaborador debe ser propio o común.
- Un pipeline de datos se descompone en fuente, transformaciones y salida, cada una con su método.
- Usa
Nonecomo centinela en lugar de listas o diccionarios por defecto. - Clases pequeñas y con una responsabilidad clara son más fáciles de probar y de reemplazar.
Evaluación
class Auto guarda un Motor en self.motor y llama a self.motor.arrancar(). ¿Qué relación modela?
¿Por qué def __init__(self, pasos=[]) es un error frecuente en una clase con varios objetos?
Tu Informe llama a fuente.leer(), limpiador.limpiar(...) y resumen.calcular(...), y no importa ninguna de esas clases. ¿Qué ventaja aporta?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.