AulaML Python · C++
Python intermedio · 34 min · Programación orientada a objetos

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.

Lección 4 de 4 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es la composición?
  2. ¿Para qué sirve?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis
  5. Ejemplo básico: el auto y su motor
  6. Ejemplo intermedio: inyectar un colaborador
  7. Caso práctico: pipeline de datos con clases pequeñas
  8. Visualización: dos pasos encadenados
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. 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

PYTHON Componer y delegar
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())
FormaQué expresaEjemplo
Atributo compuesto«tiene un»self.motor = motor
Delegaciónreenviar el trabajo al colaboradorself.motor.arrancar()
Inyección por constructorrecibir la dependencia desde fueraAuto("Toyota", motor)
Interfaz implícitabasta con que exista el métodofuente.leer()
Herencia«es un»class Auto(Vehiculo)
Un objeto dentro de otro
Python
PYTHON auto.py
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())
C++
CPP auto.cpp
#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.

PYTHON auto.py
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.

PYTHON pagos.py
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.

PYTHON informe.py
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].

Ejecución visual ·Delegación en cadena
1 / 8
1 class Escalador:
2 def __init__(self, factor):
3 self.factor = factor
4
5 def aplicar(self, valores):
6 return [v * self.factor for v in valores]
7
8 class Pipeline:
9 def __init__(self, pasos):
10 self.pasos = pasos
11
12 def ejecutar(self, datos):
13 for paso in self.pasos:
14 datos = paso.aplicar(datos)
15 return datos
16
17 p = Pipeline([Escalador(2), Escalador(10)])
18 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.
Variables
Escalador = <class 'Escalador'>
Pipeline = <class 'Pipeline'>
p = Pipeline(pasos=[Escalador(factor=2), Escalador(factor=10)])
Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

Errores comunes

MensajeCausa habitualCómo se arregla
AttributeError: 'Auto' object has no attribute 'motor'El __init__ no guardó el colaborador en selfAsigna self.motor = motor antes de usarlo
TypeError: Auto.__init__() missing 1 required positional argument: 'motor'Creaste el auto sin pasar la dependenciaPasa 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 callableUn colaborador quedó en None y llamaste a su métodoValida la dependencia al construir o falla antes con un mensaje claro
El argumento por defecto mutable se comparte entre objetos
Incorrecto
PYTHON Todos los pipelines usan la misma lista
class Pipeline:
    def __init__(self, pasos=[]):
        self.pasos = pasos

p1 = Pipeline()
p1.pasos.append("escalar")
p2 = Pipeline()
print(p2.pasos)
Correcto
PYTHON Cada objeto recibe su propia lista
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 ([], {}): usa None y crea dentro.
  • En pruebas, sustituye el colaborador por un doble que registre llamadas.

Ejercicios

Ejercicio 1

Carrito con calculadora de impuestos

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

Ejercicio 2

Monitor con fuente, regla y notificador

Compón un monitor de temperatura a partir de tres objetos: una fuente con 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.

Completar código Completa la delegación del informe
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 None como 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

Evaluación 1

class Auto guarda un Motor en self.motor y llama a self.motor.arrancar(). ¿Qué relación modela?

Evaluación 2

¿Por qué def __init__(self, pasos=[]) es un error frecuente en una clase con varios objetos?

Evaluación 3

Tu Informe llama a fuente.leer(), limpiador.limpiar(...) y resumen.calcular(...), y no importa ninguna de esas clases. ¿Qué ventaja aporta?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.