IDS Guía de estudio

Semana 9 · Módulo 8 (continuación)

POO avanzada: herencia, composición y modularidad

Con una clase por cosa del negocio empieza a haber familias (tipos de empleado, formas de pago) y objetos hechos de otros objetos (facturas con líneas). Esta semana aprendes a reutilizar sin copiar y a organizar el sistema en piezas.

Pregunta orientadora¿Cómo agrego un tipo nuevo al sistema (de pago, de cliente, de producto) sin reescribir lo que ya funciona?

Lecturas
PCC 9 (2.ª parte) · TRO 6
Proyecto
Fase 5 (esta semana)
Evaluación
Control 4: semana 11

Cómo usar esta guía

Antes de claseLa segunda parte de PCC 9 y la parte conceptual de TRO 6. Luego el checkpoint.
Durante el laboratorioTaller de arquitectura modular: un simulador polimórfico (pagos o inventario). Modelo en polimorfismo.
Después de claseEjercicios y práctica intensiva. Todo se ejecuta aquí.
ProyectoEsta semana es la Fase 5: arquitectura con clases y 5 pruebas.
  • Pregunta
  • →
  • Exploración
  • →
  • Implementación
  • →
  • Resultado
  • →
  • Interpretación
  • →
  • Verificación

Qué puedes usar esta semana

Sí, desde ahora

  • Todo lo de las semanas 1 a 8
  • Herencia: class Hija(Madre): y super().__init__(...)
  • Redefinir (sobrescribir) métodos de la clase madre
  • Polimorfismo: tratar objetos distintos con la misma llamada
  • Composición: objetos como atributos y listas de objetos
  • isinstance, importar clases desde módulos (from modelos import Factura)

Todavía no / fuera del curso

  • Clases abstractas formales (abc): mención opcional
  • Herencia múltiple: fuera del curso
  • Sintaxis de C# (virtual, override) → semana 11

Lecturas de preparación

Reading checkpoint

¿Qué relación justifica usar herencia?

Herencia para “es un”; composición para “tiene un”. Una factura no es un cliente.

¿Para qué sirve super().__init__(nombre, salario) dentro de la clase hija?

La hija reutiliza la inicialización de la madre y luego agrega lo suyo.

Si Vendedor hereda de Empleado y redefine calcular_pago(), ¿cuál se usa con v.calcular_pago() siendo v un Vendedor?

Python busca primero en la clase del objeto y luego sube hacia la madre.

¿Qué es polimorfismo?

for e in empleados: e.calcular_pago() funciona sin preguntar el tipo de cada empleado.

¿Qué relación hay entre Inventario y Producto si el inventario guarda una lista de productos?

Composición: un objeto contiene a otros y delega en ellos parte del trabajo.

¿Qué devuelve isinstance(v, Empleado) si v es un Vendedor (hija de Empleado)?

Un vendedor es un empleado, así que isinstance lo reconoce como tal.

Tienes if tipo == "tarjeta": ... elif tipo == "efectivo": ... repetido en 5 lugares. ¿Qué lo mejora?

Agregar una forma de pago nueva sería crear una clase, sin tocar los 5 lugares.

Herencia: “es un”

En palabras simples

Heredar es como las recetas de una familia: la receta base de pupusas vale para todas, y cada variante (revueltas, de queso) cambia solo lo que la hace distinta. No se reescribe la receta completa.

Jerarquía de herencia: Asalariado, PorHora y Vendedor son un Empleado; heredan sus atributos y cada una redefine calcular_pago.
Las subclases reciben los atributos de Empleado y redefinen calcular_pago().

Caso: una planilla con tres tipos de empleado

Pregunta

Hay empleados de salario fijo, por hora (con horas extra) y vendedores (base + comisión). ¿Cómo calculo la planilla sin un if por tipo en cada lugar?

Exploración

Los tres son empleados: tienen nombre y un pago. Lo que cambia es cómo se calcula el pago. Una clase madre con lo común y una hija por cada forma de pago.

Implementación
class Empleado:
    def __init__(self, nombre, salario_base):
        self.nombre = nombre
        self.salario_base = salario_base

    def calcular_pago(self):
        return self.salario_base

    def __str__(self):
        return f"{self.nombre:<6} {type(self).__name__:<11} ${self.calcular_pago():>9,.2f}"


class PorHora(Empleado):
    def __init__(self, nombre, tarifa, horas):
        super().__init__(nombre, 0)
        self.tarifa = tarifa
        self.horas = horas

    def calcular_pago(self):                       # redefine el de Empleado
        extra = max(0, self.horas - 160) * self.tarifa * 0.5
        return self.horas * self.tarifa + extra


class Vendedor(Empleado):
    def __init__(self, nombre, salario_base, ventas, comision=0.05):
        super().__init__(nombre, salario_base)
        self.ventas = ventas
        self.comision = comision

    def calcular_pago(self):
        return super().calcular_pago() + self.ventas * self.comision


planilla = [Empleado("Ana", 900), PorHora("Luis", 4.5, 170), Vendedor("Eva", 500, 12000)]
for e in planilla:                                  # polimorfismo
    print(e)
print(f"Total planilla: ${sum(e.calcular_pago() for e in planilla):,.2f}")
Ana    Empleado    $   900.00
Luis   PorHora     $   787.50
Eva    Vendedor    $ 1,100.00
Total planilla: $2,787.50
Interpretación

super().__init__ reutiliza la inicialización; super().calcular_pago() en Vendedor reutiliza el cálculo base y le suma la comisión. El bucle final no sabe ni le importa qué tipo es cada empleado: eso es polimorfismo.

Verificación

Luis: 170 × 4.5 = 765 + 10 horas extra × 4.5 × 0.5 = 22.50 → 787.50 ✔. Eva: 500 + 12,000 × 5 % = 1,100 ✔. Total 2,787.50 ✔.

Cuándo NO heredar

Si solo quieres reutilizar un método, no hace falta herencia: una función o composición basta. Hereda solo si la frase “una X es un Y” es verdadera y siempre lo será.

Polimorfismo: la misma pregunta, distintas respuestas

En palabras simples

Polimorfismo es decirle a todos los cajeros “cobra” y que cada uno sepa hacerlo con su máquina: efectivo, tarjeta o transferencia. Quien da la orden no necesita saber los detalles.

Caso: un simulador de formas de pago

Pregunta

La tienda acepta efectivo, tarjeta (3.5 % de comisión) y transferencia ($0.75 si el monto es menor a $100). ¿Cómo cobrar sin un if por forma de pago?

Exploración

El proceso de cobro es el mismo; lo que cambia es la comisión. La clase madre define cobrar usando self.comision(...), y cada hija redefine solo comision.

Implementación
class MetodoPago:
    nombre = "genérico"

    def comision(self, monto):
        return 0

    def cobrar(self, monto):
        total = round(monto + self.comision(monto), 2)
        return f"{self.nombre:<13} cobra ${total:>7.2f} (comisión ${self.comision(monto):.2f})"


class Efectivo(MetodoPago):
    nombre = "efectivo"


class Tarjeta(MetodoPago):
    nombre = "tarjeta"

    def comision(self, monto):
        return round(monto * 0.035, 2)            # 3.5 % del banco


class Transferencia(MetodoPago):
    nombre = "transferencia"

    def comision(self, monto):
        return 0.75 if monto < 100 else 0          # cargo fijo en montos pequeños


for metodo in [Efectivo(), Tarjeta(), Transferencia()]:
    print(metodo.cobrar(80))
efectivo      cobra $  80.00 (comisión $0.00)
tarjeta       cobra $  82.80 (comisión $2.80)
transferencia cobra $  80.75 (comisión $0.75)
Interpretación

cobrar está escrito una sola vez, en la madre. Al llamarlo, self.comision usa la versión de la hija correspondiente. Para agregar “billetera electrónica” basta una clase nueva de 4 líneas.

Verificación

Tarjeta: 80 × 3.5 % = 2.80 → 82.80 ✔. Transferencia de 80 (< 100): 80.75 ✔. Prueba en el editor con 150: la transferencia ya no cobra comisión.

Composición: “tiene un”

En palabras simples

Composición es armar con piezas: un carro tiene motor, llantas y asientos; no es un motor. Cada pieza hace su trabajo y el carro las coordina.

Composición: una Factura tiene un Cliente y varias LineaFactura; su método total suma los subtotales de sus líneas.
La factura no hereda de nadie: contiene un cliente y una lista de líneas, y delega en ellas el cálculo.

Caso: factura hecha de objetos

Pregunta

¿Cómo modelo una factura donde cada línea sabe su subtotal y la factura sabe su total?

Exploración

Tres clases con responsabilidades separadas: Producto (precio), Linea (producto + cantidad) y Factura (lista de líneas). La factura no calcula subtotales: se los pide a sus líneas.

Implementación
class Producto:
    def __init__(self, codigo, nombre, precio):
        self.codigo = codigo
        self.nombre = nombre
        self.precio = precio


class Linea:
    def __init__(self, producto, cantidad):
        self.producto = producto           # una línea TIENE un producto
        self.cantidad = cantidad

    def subtotal(self):
        return self.producto.precio * self.cantidad


class Factura:
    def __init__(self, numero, cliente):
        self.numero = numero
        self.cliente = cliente
        self.lineas = []                   # una factura TIENE varias líneas

    def agregar(self, producto, cantidad):
        self.lineas.append(Linea(producto, cantidad))

    def total(self):
        return round(sum(l.subtotal() for l in self.lineas) * 1.13, 2)


aceite = Producto("A01", "Aceite 1 L", 12.50)
cafe = Producto("D03", "Café 400 g", 6.75)
f = Factura("F-0002", "Comedor Lupita")
f.agregar(aceite, 2)
f.agregar(cafe, 3)
for l in f.lineas:
    print(f"{l.cantidad} x {l.producto.nombre:<11} ${l.subtotal():.2f}")
print("Total:", f.total())

aceite.precio = 13.00                    # sube el precio del producto
print("Total después del cambio de precio:", f.total())
2 x Aceite 1 L  $25.00
3 x Café 400 g  $20.25
Total: 51.13
Total después del cambio de precio: 52.26
Interpretación

La última línea muestra un efecto de compartir objetos: la línea guarda una referencia al producto, no una copia. Si el precio cambia, las facturas abiertas lo ven. En un sistema real, la línea debería guardar el precio al momento de la venta. Esa es una decisión de diseño que debes poder defender.

Verificación

(2 × 12.50 + 3 × 6.75) × 1.13 = 45.25 × 1.13 = 51.13 ✔.

Organizar en módulos

La arquitectura que pide el proyecto separa responsabilidades en archivos:

mi_sistema/
├── modelos.py        # clases de dominio: Producto, Linea, Factura...
├── servicios.py      # reglas de negocio que usan varios objetos
├── persistencia.py   # cargar / guardar JSON o CSV
├── interfaz.py       # menús, input(), print()
├── main.py           # arranca el programa
└── test_modelos.py   # pruebas con pytest

En main.py: from modelos import Producto, Factura. Regla de oro: los modelos no imprimen ni leen del teclado; eso es trabajo de interfaz.py. Así se pueden probar solos.

Tablas de traza

Traza · ¿Qué método se ejecuta?

class A:
    def hola(self):
        return "A"

    def saludo(self):
        return "soy " + self.hola()


class B(A):
    def hola(self):
        return "B"


class C(B):
    pass
ExpresiónResultado
A().hola()
B().hola()
C().hola()
B().saludo()
isinstance(C(), A)
isinstance(A(), B)
¿Por qué B().saludo() da “soy B” si saludo está en A?

saludo llama a self.hola(), y self es un objeto B: Python usa la versión de B. Esta es la clave del polimorfismo.

Ejercicios

E1 · Cuentas de ahorro y corriente

herencia + redefinición · ★★★

Clase Cuenta y dos hijas. Ahorro gana interés mensual al cierre. Corriente permite sobregiro hasta $200 y cobra $3 de cuota al cierre. Ambas redefinen lo necesario. Procesa el cierre de mes de una lista mixta.

Solución
class Cuenta:
    def __init__(self, titular, saldo=0):
        self.titular = titular
        self.saldo = saldo

    def retirar(self, monto):
        if monto > self.saldo:
            raise ValueError("saldo insuficiente")
        self.saldo -= monto

    def cierre_de_mes(self):
        pass


class Ahorro(Cuenta):
    def __init__(self, titular, saldo=0, tasa_mensual=0.005):
        super().__init__(titular, saldo)
        self.tasa_mensual = tasa_mensual

    def cierre_de_mes(self):
        self.saldo = round(self.saldo * (1 + self.tasa_mensual), 2)


class Corriente(Cuenta):
    def __init__(self, titular, saldo=0, sobregiro=200, cuota=3.00):
        super().__init__(titular, saldo)
        self.sobregiro = sobregiro
        self.cuota = cuota

    def retirar(self, monto):
        if monto > self.saldo + self.sobregiro:
            raise ValueError("excede el sobregiro permitido")
        self.saldo -= monto

    def cierre_de_mes(self):
        self.saldo -= self.cuota


cuentas = [Ahorro("Ana", 1000), Corriente("Luis", 50)]
cuentas[1].retirar(180)          # queda en -130, dentro del sobregiro
for c in cuentas:
    c.cierre_de_mes()
    print(f"{c.titular:<5} {type(c).__name__:<9} ${c.saldo:,.2f}")
Ana   Ahorro    $1,005.00
Luis  Corriente $-133.00

Verificación: Ana: 1,000 × 1.005 = 1,005 ✔. Luis: 50 − 180 = −130, menos la cuota de 3 → −133 ✔.

E2 · Inventario multibodega

composición · ★★★

Una Empresa tiene varias Bodega, y cada bodega tiene productos con su stock. Implementa el stock total por producto y una transferencia entre bodegas que no permita enviar más de lo que hay.

Solución
class Producto:
    def __init__(self, codigo, stock):
        self.codigo = codigo
        self.stock = stock


class Bodega:
    def __init__(self, nombre):
        self.nombre = nombre
        self.productos = {}                    # codigo -> Producto

    def recibir(self, codigo, cantidad):
        if codigo not in self.productos:
            self.productos[codigo] = Producto(codigo, 0)
        self.productos[codigo].stock += cantidad

    def stock_de(self, codigo):
        p = self.productos.get(codigo)
        return p.stock if p else 0


class Empresa:
    def __init__(self):
        self.bodegas = []

    def stock_total(self, codigo):
        return sum(b.stock_de(codigo) for b in self.bodegas)

    def transferir(self, codigo, cantidad, origen, destino):
        if origen.stock_de(codigo) < cantidad:
            raise ValueError(f"{origen.nombre} no tiene {cantidad} de {codigo}")
        origen.productos[codigo].stock -= cantidad
        destino.recibir(codigo, cantidad)


central, norte = Bodega("Central"), Bodega("Norte")
empresa = Empresa()
empresa.bodegas += [central, norte]
central.recibir("A01", 100)
norte.recibir("A01", 20)
empresa.transferir("A01", 30, central, norte)
print(central.stock_de("A01"), norte.stock_de("A01"), empresa.stock_total("A01"))
assert empresa.stock_total("A01") == 120          # transferir no crea ni destruye stock
70 50 120

Invariante del sistema: una transferencia no cambia el stock total. La prueba final lo comprueba; pruebas así detectan errores de lógica que un caso normal no muestra.

Práctica intensiva

Ejercicios rápidos que se corrigen solos y problemas de arquitectura, que es lo que se evalúa en la Fase 5.

Ejercicios rápidos · ¿Qué imprime?

Si da error, escribe el nombre del error.

CódigoTu respuesta
class A:
    def f(self): return 1
class B(A):
    pass
print(B().f())
class A:
    def f(self): return 1
class B(A):
    def f(self): return 2
print(B().f())
class A:
    def f(self): return 1
class B(A):
    def f(self): return super().f() + 10
print(B().f())
class A:
    def __init__(self, x): self.x = x
class B(A):
    def __init__(self, x, y):
        super().__init__(x)
        self.y = y
b = B(1, 2)
print(b.x + b.y)
class A:
    def __init__(self, x): self.x = x
class B(A):
    def __init__(self, y):
        self.y = y
print(B(2).x)
class A: pass
class B(A): pass
print(isinstance(B(), A), isinstance(A(), B))

Problemas tipo examen

P1 · Reservas de hotel con tarifas polimórficas

herencia + composición · ★★★★

Tarifas: estándar $55/noche; corporativa con 15 % de descuento; temporada con recargo del 40 % si la ocupación es ≥ 90 % o del 10 % si no. Una Reserva tiene un huésped y una tarifa. Calcula el ingreso proyectado de una lista de reservas.

Tu programa debe cumplir estos casos de prueba:

ReservaTotal esperado
Ana, estándar, 3 noches$165.00
ACME, corporativa, 5 noches$233.75
Luis, temporada 95 %, 2 noches$154.00
Eva, temporada 60 %, 2 noches$121.00
Ingreso proyectado$673.75
Pista

La clase base define total() usando self.precio_noche(), que cada hija redefine. NotImplementedError en la base avisa si alguien olvida redefinirlo.

Solución
class Tarifa:
    """Clase base: define cómo se cobra una reserva."""
    def __init__(self, noches):
        if noches <= 0:
            raise ValueError("al menos una noche")
        self.noches = noches

    def precio_noche(self):
        raise NotImplementedError("cada tarifa define su precio")

    def total(self):
        return round(self.noches * self.precio_noche(), 2)


class Estandar(Tarifa):
    def precio_noche(self):
        return 55.0


class Corporativa(Tarifa):
    def precio_noche(self):
        return 55.0 * 0.85                       # 15 % de convenio


class Temporada(Tarifa):
    def __init__(self, noches, ocupacion):
        super().__init__(noches)
        self.ocupacion = ocupacion

    def precio_noche(self):
        return 55.0 * (1.4 if self.ocupacion >= 0.9 else 1.1)


class Reserva:
    def __init__(self, huesped, tarifa):
        self.huesped = huesped
        self.tarifa = tarifa                     # composición: la reserva TIENE una tarifa

    def __str__(self):
        return f"{self.huesped:<8} {type(self.tarifa).__name__:<11} {self.tarifa.noches} noches ${self.tarifa.total():>7.2f}"


reservas = [Reserva("Ana", Estandar(3)), Reserva("ACME SA", Corporativa(5)),
            Reserva("Luis", Temporada(2, 0.95)), Reserva("Eva", Temporada(2, 0.6))]
for r in reservas:
    print(r)
print(f"Ingreso proyectado: ${sum(r.tarifa.total() for r in reservas):,.2f}")
assert Corporativa(1).total() == 46.75
Ana      Estandar    3 noches $ 165.00
ACME SA  Corporativa 5 noches $ 233.75
Luis     Temporada   2 noches $ 154.00
Eva      Temporada   2 noches $ 121.00
Ingreso proyectado: $673.75

P2 · Objetos que se guardan y se recuperan

composición + persistencia · ★★★★

Un Catalogo que contiene objetos Producto debe poder convertirse a JSON y reconstruirse desde JSON, sin perder datos. No se permiten códigos repetidos.

Tu programa debe cumplir estos casos de prueba:

ComprobaciónEsperado
Ida y vuelta a JSONmismo valor de inventario (534.0)
Agregar un código repetidoValueError
Pista

Cada Producto sabe convertirse a diccionario (a_dict) y crearse desde uno (desde_dict). @classmethod define un método que se llama sobre la clase: Producto.desde_dict(d).

Solución
class Producto:
    def __init__(self, codigo, nombre, precio, stock):
        self.codigo, self.nombre, self.precio, self.stock = codigo, nombre, precio, stock

    def a_dict(self):
        return {"codigo": self.codigo, "nombre": self.nombre, "precio": self.precio, "stock": self.stock}

    @classmethod
    def desde_dict(cls, d):
        return cls(d["codigo"], d["nombre"], d["precio"], d["stock"])


class Catalogo:
    def __init__(self):
        self._productos = {}

    def agregar(self, producto):
        if producto.codigo in self._productos:
            raise ValueError(f"código repetido: {producto.codigo}")
        self._productos[producto.codigo] = producto

    def valor(self):
        return sum(p.precio * p.stock for p in self._productos.values())

    def a_json(self):
        import json
        return json.dumps([p.a_dict() for p in self._productos.values()])

    @classmethod
    def desde_json(cls, texto):
        import json
        c = cls()
        for d in json.loads(texto):
            c.agregar(Producto.desde_dict(d))
        return c


c = Catalogo()
c.agregar(Producto("A01", "Aceite", 12.5, 40))
c.agregar(Producto("B07", "Arroz", 4.25, 8))
texto = c.a_json()
copia = Catalogo.desde_json(texto)                  # ida y vuelta
print(texto)
print("Valor original:", c.valor(), "| valor recuperado:", copia.valor())
assert copia.valor() == c.valor()
[{"codigo": "A01", "nombre": "Aceite", "precio": 12.5, "stock": 40}, {"codigo": "B07", "nombre": "Arroz", "precio": 4.25, "stock": 8}]
Valor original: 534.0 | valor recuperado: 534.0

Este patrón conecta la Fase 4 (persistencia) con la Fase 5 (clases).

Proyecto integrador · Fase 5

Semana 9 · Arquitectura con clases y pruebas unitarias.

Entregables de esta fase

Cómo lo evalúa la rúbrica (25 %)

Excelente: 3 o más clases con responsabilidades claras, composición o herencia justificada, atributos encapsulados, interfaz separada de la lógica. Aceptable: hay clases, pero mezclan cálculos con print. Deficiente: código procedural metido en clases artificiales.

Preguntas de defensa

¿Por qué usaron herencia aquí y composición allá?

Aplica la prueba de la frase: “un Vendedor es un Empleado” (herencia); “una Factura tiene líneas” (composición).

Agreguen en vivo un tipo nuevo (de pago, de empleado, de tarifa).

Si su diseño es polimórfico, basta crear una subclase y redefinir un método, sin tocar el resto. Practica hacerlo en menos de 5 minutos.

¿Por qué sus clases de modelo no tienen print ni input?

Para poder probarlas y reutilizarlas: la interfaz decide cómo mostrar; los modelos solo calculan y validan.

¿Qué prueba esta prueba unitaria?

Nombra la regla de negocio, el caso borde y demuestra que la prueba falla si se rompe la regla.

Rumbo al Control 4 y al Final

Control 4 en la semana 11 y Examen Final en la 12. De esta semana:

Fuentes

  • [PCC] Matthes, E. (2023). Python Crash Course, 3.ª ed. Cap. 9.
  • [TRO] Troelsen, A. y Japikse, P. (2022). Pro C# 10 with .NET 6. Cap. 6 (sección conceptual).