Polimorfismo en Python: duck typing, Protocol y métodos especiales
- Qué es el polimorfismo y por qué Python lo hace diferente
- Polimorfismo por herencia: la ruta clásica
- Duck typing: si grazna como un pato, es un pato
- typing.Protocol: duck typing con type hints
- Métodos especiales: polimorfismo con operadores nativos
- @total_ordering: comparaciones completas gratis
- @singledispatch: sobrecarga de funciones por tipo
- Polimorfismo en la biblioteca estándar
- Los cuatro tipos de polimorfismo en Python
- Programa completo: sistema de formas geométricas
- Errores clásicos con polimorfismo
- Preguntas frecuentes
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.
🦆 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.
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
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.
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
🔀 @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]
__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
| Tipo | Mecanismo | Verificación | Ejemplo |
|---|---|---|---|
| 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 |
🛠️ 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.
💬 Foro de discusión
¿Tienes dudas sobre Polimorfismo en Python: duck typing, Protocol y métodos especiales? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!