La lección anterior terminó con una escena aparentemente trivial: un bucle recorría libros, revistas y libros digitales llamando a descripcion() y hay_stock(), y cada objeto respondía a su manera sin que el bucle preguntara jamás "¿y tú qué eres?". Ese fenómeno se llama polimorfismo ("muchas formas"): un mismo mensaje produce distintas respuestas según quién lo recibe. Es la recompensa real de construir jerarquías, y en Python va aún más lejos gracias al duck typing. En esta lección aprenderás a escribir código que funciona con tipos que ni siquiera existen todavía — como el terminal de venta de Papyrus, que cobrará mapas y láminas el día que Ana decida venderlos, sin cambiar ni una línea.
Contenido
- Un mensaje, muchas respuestas
- El terminal de venta de Papyrus: recorrer sin preguntar
- Duck typing: "si camina como un pato..."
- Polimorfismo en las funciones integradas
- Sobrescritura vs sobrecarga (y por qué Python no tiene la segunda)
- El olor a código: cadenas de
isinstance - Errores comunes y consejos
- Ejercicios con soluciones
Un mensaje, muchas respuestas
Partimos de la jerarquía de 05-02: Producto como base, con Libro, Revista y LibroDigital (que hereda de Libro). Las tres derivadas sobrescriben descripcion(), y LibroDigital sobrescribe además hay_stock().
odisea = Libro("La Odisea", "Homero", 12.50, 4)
quimera = Revista("Quimera", 482, 6.50, 10)
hamlet_epub = LibroDigital("Hamlet", "Shakespeare", 4.95, "EPUB")
for articulo in (odisea, quimera, hamlet_epub):
print(articulo.descripcion())[Libro] La Odisea, de Homero — 13.00 € [Revista n.º 482] Quimera — 6.76 € [Digital EPUB] Hamlet, de Shakespeare — 5.15 €
La llamada es idéntica (articulo.descripcion()), pero Python resuelve en tiempo de ejecución qué versión ejecutar siguiendo el MRO de cada objeto (05-02): empieza en la clase real del objeto y sube. Mismo mensaje, tres formas.
El terminal de venta de Papyrus: recorrer sin preguntar
Veamos el polimorfismo haciendo trabajo útil. Luis (socio LUIS-001) se acerca al mostrador con una cesta variada, y el terminal de Ana cobra sin distinguir tipos:
def cobrar(cesta, socio=False):
"""Imprime el tique y devuelve el total de una cesta de Productos."""
total = 0
for articulo in cesta:
if not articulo.hay_stock():
print(f" (sin stock: {articulo.titulo} pasa a reservas)")
continue
importe = articulo.precio_final(socio) # cada clase calcula lo suyo
total += importe
print(f" {articulo.descripcion()}")
return round(total, 2)
cesta_luis = [
Libro("La Odisea", "Homero", 12.50, 4),
Libro("Fausto", "Goethe", 21.00, 0), # agotado desde el módulo 4
Revista("Quimera", 482, 6.50, 10),
LibroDigital("Hamlet", "Shakespeare", 4.95),
]
print(f"Total (socio): {cobrar(cesta_luis, socio=True):.2f} €")[Libro] La Odisea, de Homero — 13.00 € (sin stock: Fausto pasa a reservas) [Revista n.º 482] Quimera — 6.76 € [Digital EPUB] Hamlet, de Shakespeare — 5.15 € Total (socio): 23.66 €
Detalles que hacen a esta función polimórfica:
cobrar()no menciona ninguna clase concreta: solo exige que cada elemento responda ahay_stock(),precio_final(),descripcion()y tengatitulo.- "Fausto" queda fuera porque su propia versión de
hay_stock()(la heredada deProducto, con stock 0) devuelveFalse; el Hamlet digital entra porque su versión sobrescrita siempre devuelveTrue. - El precio de socio de "La Odisea" (12.35 €), Quimera (6.42 €) y el EPUB (4.89 €) suma 23.66 € — cada objeto aplicó la fórmula canónica sobre su propio precio base.
- Si el mes que viene Ana crea
Mapa(Producto)oLamina(Producto),cobrar()los aceptará sin modificarse. Eso se conoce como código abierto a extensión, cerrado a modificación.
flowchart LR
A["cobrar(cesta)"] -->|"articulo.descripcion()"| B{"¿Qué clase es<br>el objeto real?"}
B -->|Libro| C["'[Libro] La Odisea, de Homero...'"]
B -->|Revista| D["'[Revista n.º 482] Quimera...'"]
B -->|LibroDigital| E["'[Digital EPUB] Hamlet...'"]
Duck typing: "si camina como un pato..."
En muchos lenguajes, el polimorfismo exige herencia: solo puedes pasar a cobrar() objetos declarados como Producto. Python es más pragmático. Su filosofía es el duck typing: "si camina como un pato y grazna como un pato, es un pato". No importa de qué clase desciende un objeto; importa qué métodos responde.
Marta propone vender bonos regalo, y ni siquiera los hace heredar de Producto:
class BonoRegalo:
"""No hereda de Producto, pero 'grazna' igual."""
def __init__(self, importe):
self.titulo = f"Bono regalo de {importe:.2f} €"
self.importe = importe
def hay_stock(self):
return True # un bono siempre se puede emitir
def precio_final(self, socio=False):
return round(self.importe, 2) # sin IVA ni descuento: vale lo que vale
def descripcion(self):
return f"[Bono] {self.titulo}"
cesta = [Libro("El Quijote", "Cervantes", 15.90, 8), BonoRegalo(20)]
print(f"Total: {cobrar(cesta):.2f} €") # Total: 36.54 € (16.54 + 20.00)cobrar() lo acepta sin rechistar: tiene los cuatro "graznidos" que la función usa. Esto es polimorfismo sin herencia.
| Polimorfismo por herencia | Duck typing | |
|---|---|---|
| Requisito | Descender de una base común | Responder a los métodos usados |
| Verificación | isinstance(x, Producto) posible |
Solo al ejecutar: si falta el método, AttributeError |
| Estilo típico de | Java, C# | Python |
| En Papyrus | Libro, Revista, LibroDigital |
BonoRegalo |
¿Cuándo conviene cada uno? La herencia aporta código compartido y documentación de la relación; el duck typing aporta flexibilidad máxima. En la práctica Python combina ambos: jerarquía para lo que comparte implementación, duck typing para invitados ocasionales. (En el módulo 8, los type hints con protocolos pondrán nombre formal a estos "graznidos".)
Polimorfismo en las funciones integradas
Llevas usando polimorfismo desde el módulo 1 sin saberlo. Las funciones integradas de Python son polimórficas por diseño:
len("Papyrus") # 7 — caracteres de una cadena
len(["La Odisea", "Hamlet"]) # 2 — elementos de una lista
len({"LUIS-001", "MARTA-002"}) # 2 — elementos de un conjunto
len(catalogo) # 4 — claves de un diccionario
sum([12.50, 9.95, 15.90]) # 38.35 — suma listas...
sum((4, 6, 8, 0)) # 18 — ...y tuplas, y cualquier iterablelen() no pregunta el tipo: envía el mismo mensaje ("¿cuánto mides?") y cada tipo responde a su manera. El bucle for, sorted(), min()/max() con key= (03-03) funcionan igual: aceptan cualquier objeto que sepa comportarse como iterable. En 05-05 descubrirás el mecanismo exacto (métodos mágicos como __len__) y harás que tus propias clases respondan a len() y compañía.
Sobrescritura vs sobrecarga (y por qué Python no tiene la segunda)
Dos términos que se confunden con facilidad:
| Concepto | Qué significa | ¿Existe en Python? |
|---|---|---|
| Sobrescritura (overriding) | La clase derivada redefine un método del padre | Sí — LibroDigital.hay_stock() |
| Sobrecarga (overloading) | Varias versiones del mismo método con distintas firmas, en la misma clase | No en el sentido clásico |
En Java podrías declarar precioFinal() y precioFinal(boolean socio) como dos métodos distintos. En Python, si defines dos veces precio_final en una clase, la segunda definición reemplaza a la primera silenciosamente. El equivalente pythónico de la sobrecarga son los recursos del módulo 3:
def precio_final(self, socio=False, unidades=1):
"""Una sola firma cubre todos los casos de uso."""
descuento = Producto.DESCUENTO_SOCIO if socio else 0
bruto = self.precio * unidades * (1 - descuento) * (1 + Producto.IVA_LIBROS)
return round(bruto, 2)
odisea.precio_final() # 13.00 — sin argumentos
odisea.precio_final(socio=True) # 12.35 — "otra firma"
odisea.precio_final(socio=True, unidades=2) # 24.70 — "otra más"- Valores por defecto (
socio=False,unidades=1) cubren las variantes habituales. - Para casos extremos,
*args/**kwargs(03-02) permiten firmas totalmente flexibles. - Existe
functools.singledispatchpara despachar por tipo de argumento, pero es una herramienta de nicho: los valores por defecto resuelven el 95 % de los casos.
El olor a código: cadenas de isinstance
isinstance() (05-02) es legítimo para validar entradas o filtrar colecciones. Pero cuando se usa para decidir comportamiento, delata un diseño que ignora el polimorfismo. Compara:
# MAL: el terminal pregunta el tipo a cada artículo
def describir_mal(articulo):
if isinstance(articulo, LibroDigital):
return f"[Digital {articulo.formato}] {articulo.titulo}"
elif isinstance(articulo, Libro):
return f"[Libro] {articulo.titulo}, de {articulo.autor}"
elif isinstance(articulo, Revista):
return f"[Revista n.º {articulo.numero}] {articulo.titulo}"
else:
return articulo.tituloProblemas de la versión "mal":
- Cada clase nueva obliga a editar la función (y todas las funciones parecidas repartidas por el programa). Con polimorfismo, la clase nueva trae su comportamiento puesto.
- El orden importa y traiciona: si compruebas
Libroantes queLibroDigital, los digitales caen en la rama equivocada (porque unLibroDigitales unLibro). Es un error real y silencioso. - Duplica conocimiento: el formato de descripción vive lejos de la clase que describe.
Regla práctica: si escribes isinstance dentro de un if/elif para variar comportamiento, pregúntate si ese comportamiento no debería ser un método sobrescrito en cada clase. Casi siempre la respuesta es sí.
Errores Comunes y Consejos
- Ordenar mal las ramas de
isinstance: la clase derivada debe comprobarse antes que su base, o nunca se alcanzará su rama. Mejor aún: elimina la cadena con un método polimórfico. - Sobrescribir cambiando la firma: si
Producto.precio_final(self, socio=False)acepta un argumento, la versión de una derivada también debe aceptarlo. Si la derivada defineprecio_final(self)a secas, el terminal fallará conTypeErrorjusto con ese tipo — rompiendo la promesa de que "cualquier Producto sirve". - Definir dos métodos con el mismo nombre en una clase esperando sobrecarga: Python se queda solo con el último, sin avisar. Usa valores por defecto.
- Confiar en el duck typing sin documentar los "graznidos": si
cobrar()exige cuatro métodos, dilo en su docstring (03-01). Quien escriba unBonoRegalote lo agradecerá. - Exceso de defensa: comprobar
isinstancede todo, en todas partes, "por si acaso". Python favorece el estilo EAFP (es más fácil pedir perdón que permiso), que desarrollaremos con las excepciones en el módulo 7. - Consejo: cuando dudes de si tu diseño es polimórfico, haz la prueba del artículo nuevo: ¿puedes añadir
Mapasin editarcobrar()? Si la respuesta es no, hay unisinstanceescondido que eliminar.
Ejercicios
Ejercicio 1: un pato nuevo en la cesta
Crea la clase Lamina (una lámina ilustrada) sin heredar de Producto: con titulo, precio, y los métodos hay_stock() (siempre True), precio_final(socio=False) (fórmula canónica de Papyrus) y descripcion() ("[Lámina] ..."). Comprueba que cobrar([Lamina("El cuervo", 5.00)]) funciona por puro duck typing.
Ejercicio 2: eliminar el olor a código
Refactoriza esta función heredada del antiguo terminal para que use polimorfismo (te hará falta tocar también las clases: pista — el envío es comportamiento de cada clase):
def coste_envio(articulo):
if isinstance(articulo, LibroDigital):
return 0.0
elif isinstance(articulo, Revista):
return 1.50
elif isinstance(articulo, Libro):
return 2.95Ejercicio 3: inventario polimórfico
Escribe valor_inventario(articulos) que devuelva la suma de precio_final() × stock de los artículos físicos (los que tienen atributo stock mayor que 0), recorriendo la lista una sola vez y sin usar isinstance. Pruébala con "La Odisea" (stock 4), "El Quijote" (stock 8) y un LibroDigital (que debe aportar 0).
Soluciones
Solución 1:
class Lamina:
IVA_LIBROS = 0.04
DESCUENTO_SOCIO = 0.05
def __init__(self, titulo, precio):
self.titulo = titulo
self.precio = precio
def hay_stock(self):
return True
def precio_final(self, socio=False):
descuento = Lamina.DESCUENTO_SOCIO if socio else 0
return round(self.precio * (1 - descuento) * (1 + Lamina.IVA_LIBROS), 2)
def descripcion(self):
return f"[Lámina] {self.titulo} — {self.precio_final():.2f} €"
print(f"Total: {cobrar([Lamina('El cuervo', 5.00)]):.2f} €") # Total: 5.20 €cobrar() no comprueba herencia: Lamina grazna (responde a los cuatro métodos), luego es un pato.
Solución 2:
class Producto:
# ... todo lo anterior ...
def coste_envio(self):
return 2.95 # valor por defecto razonable para lo físico
class Revista(Producto):
# ... todo lo anterior ...
def coste_envio(self):
return 1.50 # sobrescribe: las revistas van en sobre
class LibroDigital(Libro):
# ... todo lo anterior ...
def coste_envio(self):
return 0.0 # sobrescribe: nada que enviar
def coste_envio(articulo):
return articulo.coste_envio() # o directamente eliminar la funciónEl conocimiento del envío ahora vive en cada clase. Añadir Mapa con envío en tubo (3.50 €) ya no toca ninguna función existente. Nota cómo Libro ni siquiera aparece: hereda el 2.95 de Producto.
Solución 3:
def valor_inventario(articulos):
"""Valor a precio de venta (sin socio) del stock físico."""
total = sum(a.precio_final() * a.stock for a in articulos)
return round(total, 2)
inventario = [
Libro("La Odisea", "Homero", 12.50, 4),
Libro("El Quijote", "Cervantes", 15.90, 8),
LibroDigital("Hamlet", "Shakespeare", 4.95),
]
print(f"{valor_inventario(inventario):,.2f} €") # 184.32 €sum() con una expresión generadora recorre la lista una sola vez. El LibroDigital no necesita trato especial: su stock heredado vale 0, así que multiplica por 0 y aporta nada — el propio dato es polimórfico. (13.00 × 4 + 16.54 × 8 = 184.32 €.)
Conclusión
El polimorfismo es la razón de ser de la jerarquía que construiste en 05-02: cobrar() envía los mismos mensajes — hay_stock(), precio_final(), descripcion() — y cada objeto responde según su clase real, sin un solo if de tipos. Has visto que Python extiende la idea con el duck typing (el BonoRegalo cuela sin heredar de nadie), que las funciones integradas como len() y sum() son polimórficas de serie, que la "sobrecarga" clásica se sustituye con valores por defecto, y que las cadenas de isinstance son el olor a código que delata un diseño rígido. Ahora bien: todo este edificio se apoya en una confianza delicada — nada impide que alguien escriba odisea.precio = -5 o fausto.stock = -3 y rompa las cuentas de Ana. Proteger los datos de un objeto para que solo cambien por las vías legítimas es la encapsulación, el tema de la próxima lección.
Curso de Programación en Python
Módulo 1: Introducción a Python
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
