Polimorfismo en Python: duck typing, Protocol y métodos especiales

📅 Actualizado en marzo 2026 📊 Nivel: Intermedio ⏱️ 24 min de lectura

El polimorfismo es la capacidad de tratar objetos de tipos distintos a través de la misma interfaz. En la práctica significa esto: tienes una lista con un perro, un gato y un robot, y puedes llamar a .hablar() sobre todos ellos con el mismo bucle, aunque no tengan ninguna relación de herencia entre sí.

Java y C# implementan polimorfismo casi exclusivamente a través de herencia e interfaces declaradas. Python va mucho más lejos: el duck typing hace que el polimorfismo sea el comportamiento por defecto, sin declarar nada. A eso se añaden los protocolos estructurales, la sobrecarga de operadores y las funciones genéricas. El resultado es un sistema extraordinariamente flexible que, bien usado, produce código muy elegante.

🏗️ Polimorfismo por herencia: la ruta clásica

El polimorfismo por herencia funciona igual que en cualquier lenguaje OO: una clase base declara un método, las subclases lo sobrescriben, y el código que usa la clase base funciona con cualquier subclase.

class Animal:
    def __init__(self, nombre):
        self.nombre = nombre

    def hablar(self):
        raise NotImplementedError(
            f"{type(self).__name__} debe implementar hablar()"
        )

    def presentar(self):
        # Método concreto: usa hablar() que cambia por subclase
        return f"Soy {self.nombre} y digo: {self.hablar()}"


class Perro(Animal):
    def hablar(self): return "¡Guau!"

class Gato(Animal):
    def hablar(self): return "¡Miau!"

class Pato(Animal):
    def hablar(self): return "¡Cuac!"


animales = [Perro("Rex"), Gato("Luna"), Pato("Donald")]

# El mismo bucle funciona con los tres tipos:
for a in animales:
    print(a.presentar())

# "Soy Rex y digo: ¡Guau!"
# "Soy Luna y digo: ¡Miau!"
# "Soy Donald y digo: ¡Cuac!"

# isinstance es fiable:
for a in animales:
    if isinstance(a, Animal):   # True para todos
        print(type(a).__name__)

El método presentar() en Animal llama a hablar(), que cada subclase implementa diferente. El polimorfismo hace que el mismo código en la clase base produzca resultados distintos según el objeto real. Esto es el patrón Template Method en su forma más directa.

Músicos de orquesta con violines, chelos y contrabajos tocando una partitura en concierto
En una orquesta, el director usa el mismo gesto (interface: «tocar compás»), pero cada instrumento responde con su sonido característico. Eso es exactamente el polimorfismo: una llamada, comportamientos distintos. Fuente: Pexels (licencia libre).

🦆 Duck typing: si grazna como un pato, es un pato

Duck typing es el mecanismo que hace que Python sea diferente. El nombre viene de la frase atribuida a James Whitcomb Riley: «Si camina como un pato y grazna como un pato, para mí es un pato». Python no comprueba de qué tipo es un objeto; simplemente intenta llamar al método.

class Perro:
    def __init__(self, nombre): self.nombre = nombre
    def hablar(self): return f"{self.nombre}: ¡Guau!"

class Gato:
    def __init__(self, nombre): self.nombre = nombre
    def hablar(self): return f"{self.nombre}: ¡Miau!"

class Robot:
    def __init__(self, modelo): self.nombre = modelo
    def hablar(self): return f"{self.nombre}: *pitido*"

class Kazoo:
    nombre = "Kazoo de juguete"
    def hablar(self): return "¡Toooot!"


# Ninguna de estas clases hereda de Animal ni de nada en común.
# No importa. Si tiene hablar(), funciona.
cosas = [Perro("Rex"), Gato("Luna"), Robot("R2D2"), Kazoo()]

for cosa in cosas:
    print(cosa.hablar())

# "Rex: ¡Guau!"
# "Luna: ¡Miau!"
# "R2D2: *pitido*"
# "¡Toooot!"

El bucle funciona aunque Robot y Kazoo no tengan ninguna relación con Animal. Python simplemente llama a .hablar() en cada objeto. Si el método existe, se ejecuta. Si no, AttributeError. No hay comprobación de tipo previa.

💡 El contrato informal de duck typing
El contrato es: «cualquier objeto con un método hablar() que devuelva un string funciona aquí». No hace falta firmarlo ni heredarlo. Basta con cumplirlo. Este enfoque hace que tu código acepte tipos que ni siquiera existían cuando lo escribiste, incluidos los de bibliotecas externas.

📋 typing.Protocol: duck typing con type hints

El problema del duck typing puro es que los linters y los IDEs no pueden verificarlo. Si pasas un objeto sin el método hablar(), el error ocurre en tiempo de ejecución, no antes. typing.Protocol resuelve esto: define la interfaz estructuralmente, sin exigir herencia, pero permitiendo verificación estática.

from typing import Protocol, runtime_checkable


@runtime_checkable
class Hablante(Protocol):
    """Todo objeto con hablar() que devuelve str satisface este protocolo."""
    nombre: str
    def hablar(self) -> str: ...


class Perro:
    def __init__(self, nombre: str): self.nombre = nombre
    def hablar(self) -> str: return f"{self.nombre}: ¡Guau!"

class Robot:
    def __init__(self, modelo: str): self.nombre = modelo
    def hablar(self) -> str: return f"{self.nombre}: *pitido*"


# ¡Perro y Robot satisfacen Hablante SIN heredar de él!
def hacer_hablar(cosa: Hablante) -> None:
    print(cosa.hablar())

hacer_hablar(Perro("Rex"))    # type checker: ✓
hacer_hablar(Robot("HAL"))    # type checker: ✓

# Con @runtime_checkable, isinstance también funciona:
print(isinstance(Perro("Rex"), Hablante))   # True
print(isinstance("hola", Hablante))         # False
🔵 Protocol vs ABC
ABC usa tipado nominal: debes heredar explícitamente de la clase base. Protocol usa tipado estructural: basta con que el objeto tenga los métodos correctos. Para clases que controlas, ambos son válidos. Para integrar clases de terceros sin modificarlas, Protocol es el único que funciona.
Diagrama de polimorfismo en Python: a la izquierda árbol de herencia Animal→Perro/Gato/Pato; en el centro el concepto duck typing con el bucle for; a la derecha duck typing puro con Robot y Kazoo sin herencia, Protocol y @singledispatch
Tres vistas del polimorfismo. Izquierda: herencia clásica con jerarquía formal. Centro: la idea central — la misma llamada hablar() sobre objetos distintos. Derecha: duck typing puro, Protocol y @singledispatch sin ninguna herencia. Infografía: Ciberaula.

⚙️ Métodos especiales: polimorfismo con operadores nativos

Los métodos especiales (dunder, por los doble-guiones) son la forma más profunda de polimorfismo en Python. Permiten que tus objetos respondan a los operadores nativos (+, *, [], len()…) igual que lo hacen los tipos built-in.

class Vector:
    """Vector 2D que soporta aritmética y comparación polimórficas."""

    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    # ── Representación ──────────────────────────
    def __repr__(self) -> str:
        return f"Vector({self.x}, {self.y})"

    def __str__(self) -> str:
        return f"({self.x}, {self.y})"

    # ── Aritmética ───────────────────────────────
    def __add__(self, otro: "Vector") -> "Vector":
        return Vector(self.x + otro.x, self.y + otro.y)

    def __mul__(self, escalar: float) -> "Vector":
        return Vector(self.x * escalar, self.y * escalar)

    def __rmul__(self, escalar: float) -> "Vector":
        return self.__mul__(escalar)   # soporta: 3 * v

    def __neg__(self) -> "Vector":
        return Vector(-self.x, -self.y)

    # ── Comparación y hash ───────────────────────
    def __eq__(self, otro: object) -> bool:
        if not isinstance(otro, Vector): return NotImplemented
        return self.x == otro.x and self.y == otro.y

    def __hash__(self) -> int:
        return hash((self.x, self.y))   # ← necesario si defines __eq__

    def __abs__(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __bool__(self) -> bool:
        return abs(self) != 0.0

    # ── Contenedor (opcional) ───────────────────
    def __iter__(self):
        yield self.x
        yield self.y

    def __len__(self) -> int:
        return 2


v1 = Vector(1, 2)
v2 = Vector(3, 4)

print(v1 + v2)         # (4, 6)
print(v1 * 3)          # (3, 6)
print(3 * v1)          # (3, 6)  ← __rmul__
print(-v1)             # (-1, -2)
print(abs(v2))         # 5.0
print(v1 == Vector(1, 2))  # True
print(list(v1))        # [1, 2]

# Funciona en sets y como clave de dict:
s = {v1, v2, Vector(1, 2)}   # ← solo 2 elementos

📊 @total_ordering: comparaciones completas gratis

Si defines __lt__ y __eq__, el decorador @total_ordering genera automáticamente __le__, __gt__ y __ge__. Esto es suficiente para que tus objetos funcionen con sorted(), min(), max() y todos los operadores de comparación.

from functools import total_ordering
from datetime import date


@total_ordering
class Version:
    """Versión semántica comparable: 1.2.3 < 1.3.0 < 2.0.0"""

    def __init__(self, major: int, minor: int, patch: int = 0):
        self.major = major
        self.minor = minor
        self.patch = patch

    def __repr__(self) -> str:
        return f"v{self.major}.{self.minor}.{self.patch}"

    def __eq__(self, otra: object) -> bool:
        if not isinstance(otra, Version): return NotImplemented
        return (self.major, self.minor, self.patch) == (otra.major, otra.minor, otra.patch)

    def __lt__(self, otra: "Version") -> bool:
        return (self.major, self.minor, self.patch) < (otra.major, otra.minor, otra.patch)

    # total_ordering genera: __le__, __gt__, __ge__


versiones = [Version(2,0), Version(1,3,1), Version(1,2), Version(1,3)]

print(sorted(versiones))
# [v1.2.0, v1.3.0, v1.3.1, v2.0.0]

print(max(versiones))   # v2.0.0
print(min(versiones))   # v1.2.0

v = Version(1, 3, 1)
print(v > Version(1, 2))    # True   ← generado por @total_ordering
print(v <= Version(2, 0))   # True   ← generado por @total_ordering
Conjunto de adaptadores de enchufe de viaje de diferentes países con conectores distintos pero misma función
Un adaptador de viaje universal acepta enchufes de cualquier país y los convierte en una única salida. Duck typing funciona igual: Python no pregunta «¿de qué tipo eres?» — pregunta «¿tienes lo que necesito?». Si encajas, funciona. Fuente: Pexels (licencia libre).
Medallón de Cicerón, orador romano
Esse quam videri
«Ser más que parecer»
Cicerón · De Amicitia, 65 a.C.
Duck typing aplica exactamente esta máxima: a Python no le importa cómo se llama la clase ni de quién hereda (el parecer). Lo que importa es si el objeto tiene los métodos que necesitas (el ser). Antes de crear jerarquías de herencia complejas solo para satisfacer un type checker, pregúntate: ¿tiene mi objeto lo que necesito? Si la respuesta es sí, ya es suficiente.

🔀 @singledispatch: sobrecarga de funciones por tipo

@singledispatch permite definir una función genérica con implementaciones especializadas por tipo del primer argumento. Es la forma pythónica de sobrecarga de funciones, y aplica el principio abierto/cerrado: puedes añadir implementaciones para nuevos tipos sin modificar la función original.

from functools import singledispatch
import json
from pathlib import Path


@singledispatch
def serializar(obj) -> str:
    """Implementación por defecto."""
    return str(obj)


@serializar.register
def _(obj: int) -> str:
    return f"INT:{obj}"


@serializar.register
def _(obj: list) -> str:
    return "[" + ", ".join(serializar(item) for item in obj) + "]"


@serializar.register
def _(obj: dict) -> str:
    pares = ", ".join(f"{k}={serializar(v)}" for k, v in obj.items())
    return "{" + pares + "}"


@serializar.register
def _(obj: Path) -> str:
    return f"PATH:{obj}"


print(serializar(42))                      # "INT:42"
print(serializar([1, "hola", True]))       # "[INT:1, hola, True]"
print(serializar({"a": 1, "b": [2, 3]}))  # "{a=INT:1, b=[INT:2, INT:3]}"
print(serializar(Path("/tmp/data.csv")))   # "PATH:/tmp/data.csv"
print(serializar(3.14))                   # "3.14"  ← implementación por defecto

# Ver qué implementación usaría:
print(serializar.dispatch(int))           # <function _ at 0x...>
print(serializar.registry.keys())         # los tipos registrados

📚 Polimorfismo en la biblioteca estándar

La biblioteca estándar de Python explota el polimorfismo en cada esquina. Conocer estos protocolos te permite que tus clases se integren con comportamientos nativos sin esfuerzo.

class Caja:
    """Una colección personalizada que se integra con Python nativo."""

    def __init__(self, *items):
        self._items = list(items)

    # Polimorfismo con len() y bool()
    def __len__(self):       return len(self._items)
    def __bool__(self):      return len(self._items) > 0

    # Polimorfismo con [] y for
    def __getitem__(self, i): return self._items[i]
    def __setitem__(self, i, v): self._items[i] = v
    def __iter__(self):      return iter(self._items)
    def __contains__(self, item): return item in self._items

    # Polimorfismo con + y *
    def __add__(self, otra):
        return Caja(*self._items, *otra._items)

    def __repr__(self):
        return f"Caja({', '.join(repr(i) for i in self._items)})"


c1 = Caja(1, 2, 3)
c2 = Caja("a", "b")

print(len(c1))          # 3         ← __len__
print(bool(Caja()))     # False      ← __bool__
print(c1[1])            # 2         ← __getitem__
print(3 in c1)          # True       ← __contains__
print(list(c2))         # ['a', 'b'] ← __iter__
print(c1 + c2)          # Caja(1, 2, 3, 'a', 'b')

# sorted() funciona porque __iter__ está definido:
print(sorted(c1))       # [1, 2, 3]

# json.dumps necesita un iterador; Caja lo es:
import json
print(json.dumps(list(c1)))  # [1, 2, 3]
⚠️ Protocolo de iteración y Secuencia
Si defines __iter__ y __len__, tu clase se comporta como una secuencia en la mayoría de contextos. Si además defines __getitem__, sorted(), reversed() y los métodos de list que esperan indexación funcionarán sin más. Python no exige que heredes de list o Sequence.

🗺️ Los cuatro tipos de polimorfismo en Python

TipoMecanismoVerificaciónEjemplo
Polimorfismo de subtipos Herencia + override isinstance() funciona Animal → Perro → hablar()
Duck typing Sin herencia, por convención Solo en runtime (AttributeError) Cualquier objeto con hablar()
Polimorfismo estructural typing.Protocol Estática + isinstance si @runtime_checkable class X(Protocol): def hablar()
Polimorfismo ad-hoc Dunder methods / @singledispatch En compilación con type hints __add__, __len__, singledispatch
Ficha de referencia de polimorfismo en Python: duck typing, Protocol, @singledispatch, @overload, dunder methods por categoría y tabla de cuándo usar cada técnica
Referencia rápida — Duck typing y Protocol con @runtime_checkable; @singledispatch y @overload para sobrecarga; métodos especiales por categoría (representación, aritmética, comparación, contenedor, callable); tabla de cuándo usar cada técnica; @total_ordering. Ficha: Ciberaula.

🛠️ Programa completo: sistema de formas geométricas

from abc import ABC, abstractmethod
from functools import total_ordering
from typing import Protocol, runtime_checkable
import math


@runtime_checkable
class Dibujable(Protocol):
    """Protocolo: cualquier objeto que se pueda dibujar en ASCII."""
    def dibujar(self) -> str: ...


@total_ordering
class Forma(ABC):
    """Clase base abstracta para todas las formas."""

    color: str = "azul"

    @abstractmethod
    def area(self) -> float: ...

    @abstractmethod
    def perimetro(self) -> float: ...

    def descripcion(self) -> str:
        return (f"{type(self).__name__}: "
                f"área={self.area():.2f}, "
                f"perímetro={self.perimetro():.2f}")

    # Comparación por área (total_ordering genera el resto)
    def __eq__(self, otra: object) -> bool:
        if not isinstance(otra, Forma): return NotImplemented
        return round(self.area(), 10) == round(otra.area(), 10)

    def __lt__(self, otra: "Forma") -> bool:
        return self.area() < otra.area()

    def __hash__(self):
        return hash(round(self.area(), 8))

    def __repr__(self):
        return self.descripcion()


class Circulo(Forma):
    def __init__(self, radio: float, color: str = "rojo"):
        self.radio = radio
        self.color = color

    def area(self) -> float:
        return math.pi * self.radio ** 2

    def perimetro(self) -> float:
        return 2 * math.pi * self.radio

    def dibujar(self) -> str:
        return f"⬤  (radio={self.radio})"


class Rectangulo(Forma):
    def __init__(self, ancho: float, alto: float, color: str = "verde"):
        self.ancho = ancho
        self.alto  = alto
        self.color = color

    def area(self) -> float:
        return self.ancho * self.alto

    def perimetro(self) -> float:
        return 2 * (self.ancho + self.alto)

    def dibujar(self) -> str:
        return f"▬  ({self.ancho}×{self.alto})"


class Triangulo(Forma):
    def __init__(self, a: float, b: float, c: float):
        self.a, self.b, self.c = a, b, c

    def area(self) -> float:
        s = self.perimetro() / 2
        return math.sqrt(s * (s-self.a) * (s-self.b) * (s-self.c))

    def perimetro(self) -> float:
        return self.a + self.b + self.c

    def dibujar(self) -> str:
        return f"▲  (lados={self.a},{self.b},{self.c})"


# ── Uso polimórfico ────────────────────────────────────
formas = [
    Circulo(5), Rectangulo(4, 6), Triangulo(3, 4, 5),
    Circulo(3), Rectangulo(7, 2),
]

# Polimorfismo: el mismo método en tipos distintos
print("=== Todas las formas ===")
for f in sorted(formas, reverse=True):
    print(f"  {f.descripcion()}")

# Duck typing + Protocol: solo dibujar las que lo implementan
print("\n=== Dibujables (duck typing) ===")
for f in formas:
    if isinstance(f, Dibujable):    # @runtime_checkable
        print(f"  {f.dibujar()}")

# min y max gracias a @total_ordering
print(f"\nMás pequeña: {min(formas)}")
print(f"Más grande:  {max(formas)}")

# set funciona porque definimos __hash__:
unicas = set(formas)
print(f"Formas únicas: {len(unicas)}")

🐛 Errores clásicos con polimorfismo

1. Confundir duck typing con «no necesito type hints»

# ❌ Sin tipo: el IDE no detecta que falta el método
def hacer_hablar(cosa):
    cosa.hablar()   # AttributeError en runtime si cosa no tiene hablar()

# ✅ Protocol: duck typing + verificación estática
from typing import Protocol
class Hablante(Protocol):
    def hablar(self) -> str: ...

def hacer_hablar(cosa: Hablante) -> None:
    cosa.hablar()   # el type checker avisa si hablar() no existe

2. Olvidar __hash__ al definir __eq__

# ❌ Rompe el uso en sets y diccionarios
class Punto:
    def __init__(self, x, y): self.x, self.y = x, y
    def __eq__(self, otro): return self.x == otro.x and self.y == otro.y
    # __hash__ no definido → Python lo pone a None → TypeError en sets

# ✅
class Punto:
    def __init__(self, x, y): self.x, self.y = x, y
    def __eq__(self, otro): return self.x == otro.x and self.y == otro.y
    def __hash__(self): return hash((self.x, self.y))

3. Devolver el tipo incorrecto en operadores

# ❌ __add__ devuelve None implícitamente
class Vector:
    def __add__(self, otro):
        Vector(self.x + otro.x, self.y + otro.y)  # falta return!

# ✅ Siempre devuelve una instancia del tipo
    def __add__(self, otro):
        return Vector(self.x + otro.x, self.y + otro.y)

4. Usar isinstance en lugar de duck typing cuando no es necesario

# ❌ Demasiado rígido: rompe extensibilidad
def area_total(formas):
    total = 0
    for f in formas:
        if isinstance(f, Circulo):
            total += math.pi * f.radio**2
        elif isinstance(f, Rectangulo):
            total += f.ancho * f.alto
    return total

# ✅ Duck typing: cualquier objeto con area() funciona
def area_total(formas):
    return sum(f.area() for f in formas)

✅ Resumen y próximos pasos

El polimorfismo en Python tiene muchas caras. La herencia con override es el punto de partida, pero el duck typing es la forma genuinamente pythónica: si el objeto tiene el método que necesitas, funciona, sin preguntar de dónde viene. typing.Protocol añade verificación estática sin romper la flexibilidad. Los métodos dunder integran tus objetos con toda la maquinaria del lenguaje — operadores, sorted(), len(), bucles for. Y @singledispatch ofrece sobrecarga de funciones extensible sin if anidados.

La siguiente lección: encapsulamiento — atributos privados y protegidos, @property, getters y setters, y el principio de ocultamiento de información en Python.

❓ Preguntas frecuentes

❓ Preguntas frecuentes sobre Polimorfismo en Python: duck typing, Protocol y métodos especiales

Las dudas más comunes respondidas de forma clara y directa.

Son dos mecanismos para conseguir el mismo efecto (llamar al mismo método en objetos distintos) pero con enfoques opuestos. El polimorfismo por herencia requiere que todos los objetos desciendan de una clase común que declare el método. Python comprueba la jerarquía de clases en tiempo de ejecución. Duck typing no requiere ninguna relación: si el objeto tiene el método, funciona. Python nunca comprueba el tipo; simplemente intenta llamar al método y lanza AttributeError si no existe. La filosofía Python favorece duck typing: los programas son más flexibles, más fáciles de extender y no requieren jerarquías artificiales solo para reutilizar una interfaz.
La diferencia es nominal vs estructural. ABC requiere que las subclases hereden explícitamente de la clase base (relación nominal). Protocol solo exige que el objeto tenga los métodos con la firma correcta, sin herencia (relación estructural). Usa Protocol cuando: quieras type hints sin forzar herencia, trabajes con clases de terceros que no puedes modificar, o sigas el estilo pythónico de duck typing pero con verificación estática. Usa ABC cuando: la jerarquía tenga semántica propia más allá del contrato de métodos, necesites isinstance() fiable sin @runtime_checkable, o el diseño requiera métodos concretos compartidos en la base.
@singledispatch implementa el principio abierto/cerrado: puedes añadir comportamiento para nuevos tipos sin modificar la función original. Con if/isinstance, cada vez que añades un tipo debes editar la función. Con @singledispatch, simplemente registras una nueva implementación. Esto es especialmente valioso en librerías: el consumidor puede extender el comportamiento sin tocar el código fuente. Además, es más legible: cada implementación es una función independiente con su propio cuerpo, no ramas de un if largo.
Sí, y hay una regla importante: si defines __eq__, Python automáticamente pone __hash__ a None, lo que hace que los objetos no se puedan usar como claves de diccionario ni en sets. Si defines __eq__ y quieres que el objeto sea hashable, debes definir también __hash__. La regla de consistencia es: objetos que se consideran iguales (a == b) deben tener el mismo hash (hash(a) == hash(b)). Lo contrario no es necesario: dos objetos distintos pueden tener el mismo hash (colisión). Si tus objetos son mutables, lo más seguro es no definir __hash__ y no usarlos como claves.
Sí, y sin declaración explícita. Como Protocol usa tipado estructural, si una clase implementa los métodos de dos Protocol diferentes, automáticamente satisface ambas. Por ejemplo, una clase con __iter__ y __len__ satisface tanto Iterable como Sized sin heredar de ninguna. Esta es una ventaja enorme sobre las interfaces nominales: puedes combinar comportamientos simplemente implementando los métodos, sin diamantes de herencia ni conflictos de clases base.
Es un decorador de functools que genera automáticamente los seis operadores de comparación (__le__, __gt__, __ge__) a partir de solo __eq__ y __lt__. Sin él, si quieres que tus objetos funcionen con sorted(), min(), max() y los operadores de comparación completos, deberías implementar los seis métodos manualmente. Con @total_ordering solo necesitas dos. Úsalo siempre que definas objetos comparables como fechas, precios, versiones o cualquier entidad con un orden natural. La pequeña penalización de rendimiento (una llamada indirecta extra) raramente importa en la práctica.
Valora este artículo

💬 Foro de discusión

¿Tienes dudas sobre Polimorfismo en Python: duck typing, Protocol y métodos especiales? Comparte tu pregunta con la comunidad.

¿Tienes cuenta? o comenta como invitado ↓

Todavía no hay mensajes. ¡Sé el primero en participar!