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

  1. Un mensaje, muchas respuestas
  2. El terminal de venta de Papyrus: recorrer sin preguntar
  3. Duck typing: "si camina como un pato..."
  4. Polimorfismo en las funciones integradas
  5. Sobrescritura vs sobrecarga (y por qué Python no tiene la segunda)
  6. El olor a código: cadenas de isinstance
  7. Errores comunes y consejos
  8. 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 a hay_stock(), precio_final(), descripcion() y tenga titulo.
  • "Fausto" queda fuera porque su propia versión de hay_stock() (la heredada de Producto, con stock 0) devuelve False; el Hamlet digital entra porque su versión sobrescrita siempre devuelve True.
  • 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) o Lamina(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 iterable

len() 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.singledispatch para 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.titulo
# BIEN: cada clase ya sabe describirse
def describir_bien(articulo):
    return articulo.descripcion()

Problemas 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 Libro antes que LibroDigital, los digitales caen en la rama equivocada (porque un LibroDigital es un Libro). 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 define precio_final(self) a secas, el terminal fallará con TypeError justo 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 un BonoRegalo te lo agradecerá.
  • Exceso de defensa: comprobar isinstance de 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 Mapa sin editar cobrar()? Si la respuesta es no, hay un isinstance escondido 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.95

Ejercicio 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ón

El 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

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados