Métodos especiales en Python: el protocolo dunder completo
- Qué son los métodos especiales y por qué existen
- __repr__ y __str__: la identidad del objeto
- __eq__, __lt__ y @total_ordering
- Operadores aritméticos: __add__, __mul__ y reflejos
- Protocolo contenedor: __len__, __getitem__, __iter__
- __call__: objetos que se comportan como funciones
- Context managers: __enter__ y __exit__
- Control de atributos: __getattr__, __setattr__
- Programa completo: Vector2D con protocolo completo
- Errores clásicos con métodos especiales
- Preguntas frecuentes
Cuando escribes len(mi_lista), Python no invoca un método mágico escondido en el intérprete. Invoca mi_lista.__len__(). Cuando sumas dos objetos con +, Python llama a __add__. Cuando entras en un bloque with, Python llama a __enter__. Los métodos especiales son el protocolo que permite que tus propias clases se integren con el lenguaje de forma tan natural como los tipos incorporados.
Esto es lo que los desarrolladores de Python denominan el "protocolo de datos": un contrato entre tu objeto y el intérprete. Si tu objeto habla el protocolo, Python lo trata como ciudadano de primera clase. Si no lo habla, te quedas con objetos que solo funcionan con sus propios métodos, sin integración con el ecosistema.
🪪 __repr__ y __str__: la identidad del objeto
Lo primero que cualquier objeto debería tener es una representación decente. Cuando haces debug, cuando imprimes resultados en un REPL, cuando registras un error en un log, Python necesita convertir tu objeto en texto. Si no le dices cómo hacerlo, obtienes cosas como <MiClase object at 0x7f3b2a4c1d50>, que no ayuda a nadie.
class Temperatura:
def __init__(self, celsius: float):
self._celsius = celsius
def __repr__(self) -> str:
# Para desarrolladores: recreable, preciso
return f"Temperatura(celsius={self._celsius!r})"
def __str__(self) -> str:
# Para usuarios: legible, amable
return f"{self._celsius}°C"
def __format__(self, spec: str) -> str:
# Para f-strings y format(): f"{t:.1f}" → "20.5°C"
return f"{self._celsius:{spec}}°C"
t = Temperatura(20.5)
print(repr(t)) # Temperatura(celsius=20.5) ← __repr__
print(str(t)) # 20.5°C ← __str__
print(f"{t:.1f}") # 20.5°C ← __format__
print(f"{t!r}") # Temperatura(celsius=20.5) ← fuerza __repr__
__repr__. Añade __str__ solo si quieres una presentación diferente para el usuario final. Python usa __repr__ como fallback cuando no hay __str__, pero nunca al revés.
__len__, __add__, __iter__) y Python lo reconoce nativamente, igual que si fuera una lista o un número incorporado. Fuente: Depositphotos.⚖️ __eq__, __lt__ y @total_ordering
Por defecto, Python compara objetos por identidad (a is b). Si quieres comparación por valor — que es lo que casi siempre quieres — tienes que definirla explícitamente. Y si defines __eq__, tienes que pensar también en __hash__.
from functools import total_ordering
@total_ordering
class Temperatura:
def __init__(self, celsius: float):
self._celsius = celsius
def __eq__(self, otro) -> bool:
if not isinstance(otro, Temperatura):
return NotImplemented # ← NotImplemented, no raise!
return self._celsius == otro._celsius
def __lt__(self, otro) -> bool:
if not isinstance(otro, Temperatura):
return NotImplemented
return self._celsius < otro._celsius
def __hash__(self) -> int:
return hash(self._celsius) # necesario porque definimos __eq__
t1 = Temperatura(20)
t2 = Temperatura(30)
t3 = Temperatura(20)
print(t1 == t3) # True
print(t1 < t2) # True
print(t1 > t2) # False ← @total_ordering lo infiere
print(t1 <= t3) # True ← @total_ordering lo infiere
print(sorted([t2, t1])) # [Temperatura(20), Temperatura(30)]
print({t1, t3}) # {Temperatura(20)} — __hash__ funciona
__eq__ + __lt__, el decorador @total_ordering infiere automáticamente __le__, __gt__ y __ge__. Ahorra repetición y garantiza consistencia. El único coste: una pequeña penalización de rendimiento en comparaciones muy frecuentes.➕ Operadores aritméticos: __add__, __mul__ y reflejos
Los operadores aritméticos en Python invocan métodos especiales en el operando izquierdo. Si ese método no sabe cómo operar con el tipo del operando derecho y retorna NotImplemented, Python prueba el método "reflejo" en el operando derecho.
class Vector2D:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __repr__(self) -> str:
return f"Vector2D({self.x}, {self.y})"
def __add__(self, otro):
if isinstance(otro, Vector2D):
return Vector2D(self.x + otro.x, self.y + otro.y)
return NotImplemented # no raise, retornar NotImplemented
def __radd__(self, otro): # para: escalar + vector
return self.__add__(otro) if isinstance(otro, Vector2D) else NotImplemented
def __mul__(self, escalar: float):
if isinstance(escalar, (int, float)):
return Vector2D(self.x * escalar, self.y * escalar)
return NotImplemented
def __rmul__(self, escalar: float): # para: 3 * vector
return self.__mul__(escalar)
def __neg__(self):
return Vector2D(-self.x, -self.y)
def __abs__(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5
v1 = Vector2D(1, 2)
v2 = Vector2D(3, 4)
print(v1 + v2) # Vector2D(4, 6)
print(v1 * 3) # Vector2D(3, 6)
print(3 * v1) # Vector2D(3, 6) ← __rmul__
print(-v1) # Vector2D(-1, -2)
print(abs(v2)) # 5.0
📦 Protocolo contenedor: __len__, __getitem__, __iter__
Para que Python trate tu objeto como una secuencia o contenedor — iterable con for, indexable con [], medible con len() — tienes que hablar el protocolo contenedor. No hace falta heredar de ninguna clase: basta con implementar los métodos correctos.
class ListaOrdenada:
"""Contenedor que mantiene sus elementos siempre ordenados."""
def __init__(self, iterable=None):
self._data = sorted(iterable or [])
def añadir(self, valor) -> None:
import bisect
bisect.insort(self._data, valor)
def __len__(self) -> int:
return len(self._data)
def __getitem__(self, indice):
return self._data[indice] # soporta slices automáticamente
def __contains__(self, valor) -> bool:
import bisect
i = bisect.bisect_left(self._data, valor)
return i < len(self._data) and self._data[i] == valor
def __iter__(self):
return iter(self._data)
def __repr__(self) -> str:
return f"ListaOrdenada({self._data!r})"
ls = ListaOrdenada([5, 2, 8, 1])
print(ls) # ListaOrdenada([1, 2, 5, 8])
ls.añadir(3)
print(len(ls)) # 5
print(ls[0]) # 1
print(ls[1:3]) # [2, 3] ← slice funciona gratis
print(3 in ls) # True ← __contains__ O(log n)
for x in ls:
print(x, end=" ") # 1 2 3 5 8
__getitem__ con índices enteros desde 0, Python puede iterar tu objeto automáticamente aunque no definas __iter__: probará índices 0, 1, 2... hasta que obtenga IndexError. Pero definir __iter__ explícitamente es más eficiente y más claro.📞 __call__: objetos que se comportan como funciones
Cuando defines __call__, tu objeto se vuelve invocable: obj(args) llama a obj.__call__(args). Esto es perfecto para callbacks configurables, memoización, o cualquier función que necesite estado persistente entre invocaciones.
from functools import wraps
from typing import Any
class Memoize:
"""Decorator que memoriza los resultados de llamadas previas."""
def __init__(self, func):
self._func = func
self._cache = {}
self._hits = 0
self._misses = 0
wraps(func)(self) # copia __name__, __doc__, etc.
def __call__(self, *args, **kwargs):
clave = (args, tuple(sorted(kwargs.items())))
if clave not in self._cache:
self._misses += 1
self._cache[clave] = self._func(*args, **kwargs)
else:
self._hits += 1
return self._cache[clave]
def __repr__(self) -> str:
return f"Memoize({self._func.__name__!r}, hits={self._hits}, misses={self._misses})"
def limpiar_cache(self) -> None:
self._cache.clear()
self._hits = self._misses = 0
@Memoize
def fibonacci(n: int) -> int:
if n < 2: return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(35)) # 9227465 — rápido gracias a la memoización
print(fibonacci) # Memoize('fibonacci', hits=68, misses=36)
__repr__ hace lo mismo: imprime la «huella» oficial del objeto en cada salida de debug, en cada log, en cada traza de error. Sin él, todos los objetos parecen anónimos. Fuente: Depositphotos.🔒 Context managers: __enter__ y __exit__
Los context managers son la solución elegante de Python para recursos que necesitan ser liberados pase lo que pase: conexiones a bases de datos, ficheros, locks, transacciones. La sentencia with garantiza que __exit__ siempre se ejecuta, incluso si ocurre una excepción.
import time
class Cronometro:
"""Mide el tiempo de un bloque de código con with."""
def __enter__(self):
self._inicio = time.perf_counter()
return self # lo que retorna __enter__ va a la variable "as"
def __exit__(self, tipo_exc, valor_exc, traceback):
self.elapsed = time.perf_counter() - self._inicio
if tipo_exc is not None:
print(f"⚠️ Excepción tras {self.elapsed:.4f}s: {valor_exc}")
return False # False → propaga la excepción; True → la suprime
def __repr__(self) -> str:
return f"Cronometro(elapsed={getattr(self, 'elapsed', None)!r})"
with Cronometro() as c:
total = sum(i**2 for i in range(1_000_000))
print(f"Resultado: {total:,}")
print(f"Tiempo: {c.elapsed:.4f}s")
# Alternativa con contextlib.contextmanager (más conciso)
from contextlib import contextmanager
@contextmanager
def cronometro():
inicio = time.perf_counter()
try:
yield # aquí se ejecuta el cuerpo del with
finally:
elapsed = time.perf_counter() - inicio
print(f"Tiempo: {elapsed:.4f}s")
with cronometro():
total = sum(i**2 for i in range(1_000_000))
c.elapsed), o cuando forma parte de una clase más grande. Usa @contextmanager cuando es un helper puntual: el código es más compacto y fácil de leer.🔀 Control de atributos: __getattr__ y __setattr__
Python te permite interceptar el acceso a atributos. __getattr__ se llama solo cuando Python no encuentra el atributo por los medios normales. __setattr__ se llama en cada asignación. Y __getattribute__ — el más potente y peligroso — se llama en cada acceso, sin excepción.
class ConfiguracionFlexible:
"""Acceso a configuración mediante atributos dinámicos."""
def __init__(self, **kwargs):
# ¡Usar object.__setattr__ para evitar recursión!
object.__setattr__(self, '_datos', dict(kwargs))
object.__setattr__(self, '_cambios', [])
def __getattr__(self, nombre: str):
# Solo se llama si Python no encuentra el atributo normalmente
datos = object.__getattribute__(self, '_datos')
if nombre in datos:
return datos[nombre]
raise AttributeError(f"Configuración sin clave '{nombre}'")
def __setattr__(self, nombre: str, valor):
# Se llama en CADA asignación — evitar recursión con object.__setattr__
datos = object.__getattribute__(self, '_datos')
cambios = object.__getattribute__(self, '_cambios')
cambios.append((nombre, datos.get(nombre), valor))
datos[nombre] = valor
def __repr__(self) -> str:
datos = object.__getattribute__(self, '_datos')
return f"ConfiguracionFlexible({datos!r})"
cfg = ConfiguracionFlexible(host="localhost", puerto=5432)
print(cfg.host) # "localhost" ← __getattr__
cfg.debug = True # __setattr__ registra el cambio
print(cfg.debug) # True
__setattr__, hacer self.algo = valor vuelve a llamar a __setattr__ → recursión infinita. Siempre usa object.__setattr__(self, nombre, valor) para asignaciones internas. Lo mismo aplica a __getattribute__: usa object.__getattribute__(self, nombre) para acceder a atributos internos.🛠️ Programa completo: Vector2D con protocolo completo
from functools import total_ordering
import math
@total_ordering
class Vector2D:
"""Vector 2D con protocolo dunder completo."""
def __init__(self, x: float, y: float):
self._x = float(x)
self._y = float(y)
# ── Propiedades ──────────────────────────────────────────
@property
def x(self) -> float: return self._x
@property
def y(self) -> float: return self._y
# ── Representación ───────────────────────────────────────
def __repr__(self) -> str:
return f"Vector2D({self._x}, {self._y})"
def __str__(self) -> str:
return f"({self._x:.2f}, {self._y:.2f})"
def __format__(self, spec: str) -> str:
return f"({self._x:{spec}}, {self._y:{spec}})"
# ── Comparación ──────────────────────────────────────────
def __eq__(self, otro) -> bool:
if not isinstance(otro, Vector2D): return NotImplemented
return math.isclose(self._x, otro._x) and math.isclose(self._y, otro._y)
def __lt__(self, otro) -> bool:
if not isinstance(otro, Vector2D): return NotImplemented
return abs(self) < abs(otro)
def __hash__(self) -> int:
return hash((round(self._x, 9), round(self._y, 9)))
def __bool__(self) -> bool:
return self._x != 0 or self._y != 0
# ── Aritmética ───────────────────────────────────────────
def __add__(self, otro):
if isinstance(otro, Vector2D):
return Vector2D(self._x + otro._x, self._y + otro._y)
return NotImplemented
def __radd__(self, otro): return self.__add__(otro)
def __sub__(self, otro):
if isinstance(otro, Vector2D):
return Vector2D(self._x - otro._x, self._y - otro._y)
return NotImplemented
def __mul__(self, escalar):
if isinstance(escalar, (int, float)):
return Vector2D(self._x * escalar, self._y * escalar)
if isinstance(escalar, Vector2D): # producto escalar
return self._x * escalar._x + self._y * escalar._y
return NotImplemented
def __rmul__(self, escalar): return self.__mul__(escalar)
def __truediv__(self, escalar):
if not isinstance(escalar, (int, float)):
return NotImplemented
if escalar == 0:
raise ZeroDivisionError("No se puede dividir un vector por cero.")
return Vector2D(self._x / escalar, self._y / escalar)
def __neg__(self): return Vector2D(-self._x, -self._y)
def __pos__(self): return Vector2D(+self._x, +self._y)
def __abs__(self) -> float:
return math.hypot(self._x, self._y)
def __round__(self, n: int = 0):
return Vector2D(round(self._x, n), round(self._y, n))
# ── Contenedor / Secuencia ───────────────────────────────
def __len__(self) -> int:
return 2
def __getitem__(self, indice: int) -> float:
return (self._x, self._y)[indice]
def __iter__(self):
yield self._x
yield self._y
def __contains__(self, valor: float) -> bool:
return math.isclose(valor, self._x) or math.isclose(valor, self._y)
# ── Conversión ───────────────────────────────────────────
def __int__(self) -> int: return int(abs(self))
def __float__(self) -> float: return abs(self)
def __complex__(self) -> complex: return complex(self._x, self._y)
# ── Contexto ─────────────────────────────────────────────
def __enter__(self): return self
def __exit__(self, *_): return False
# ── Uso ──────────────────────────────────────────────────────
v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)
print(repr(v1)) # Vector2D(3.0, 4.0)
print(str(v1)) # (3.00, 4.00)
print(f"{v1:.3f}") # (3.000, 4.000)
print(v1 + v2) # Vector2D(4.0, 6.0)
print(v1 * 2) # Vector2D(6.0, 8.0)
print(3 * v1) # Vector2D(9.0, 12.0)
print(v1 * v2) # 11.0 ← producto escalar
print(abs(v1)) # 5.0
print(-v1) # Vector2D(-3.0, -4.0)
print(len(v1)) # 2
print(list(v1)) # [3.0, 4.0]
print(v1[0], v1[1]) # 3.0 4.0
print(3.0 in v1) # True
print(v1 == Vector2D(3,4)) # True
print(v1 < Vector2D(10,0)) # True (|v1|=5 < 10)
print(sorted([v1, v2])) # [Vector2D(1.0, 2.0), Vector2D(3.0, 4.0)]
print({v1, v2}) # funciona ← __hash__
with v1 as v:
x, y = v
print(f"x={x}, y={y}") # x=3.0, y=4.0
🐛 Errores clásicos con métodos especiales
1. raise NotImplementedError en operadores (en lugar de return NotImplemented)
# ❌ Rompe el protocolo reflejo
def __add__(self, otro):
if not isinstance(otro, MiClase):
raise NotImplementedError # Python se detiene aquí, __radd__ nunca se prueba
# ✅
def __add__(self, otro):
if not isinstance(otro, MiClase):
return NotImplemented # Python prueba otro.__radd__(self)
2. __add__ que modifica self en lugar de retornar objeto nuevo
# ❌ Viola la semántica de inmutabilidad esperada en operadores
def __add__(self, otro):
self.x += otro.x # mutar self es un horror silencioso
return self
# ✅ Siempre retornar un objeto NUEVO
def __add__(self, otro):
return Vector2D(self.x + otro.x, self.y + otro.y)
3. __eq__ sin __hash__ (objeto se vuelve inutilizable en dicts/sets)
# ❌ Al definir __eq__, Python pone __hash__ = None automáticamente
class Punto:
def __eq__(self, otro): return self.x == otro.x and self.y == otro.y
# sin __hash__ → TypeError al usar en dict o set
# ✅
class Punto:
def __eq__(self, otro): return self.x == otro.x and self.y == otro.y
def __hash__(self): return hash((self.x, self.y))
4. Recursión infinita en __setattr__ o __getattribute__
# ❌ Recursión infinita
def __setattr__(self, nombre, valor):
self._log.append(nombre) # ← llama a __setattr__ de nuevo → recursión
# ✅ Usar object.__setattr__ para acceso interno
def __setattr__(self, nombre, valor):
log = object.__getattribute__(self, '_log')
log.append(nombre)
object.__setattr__(self, nombre, valor)
✅ Resumen y próximos pasos
Los métodos especiales son el protocolo que hace que tus clases sean ciudadanos de primera clase en Python. __repr__ siempre; __str__ cuando quieres presentación diferente. __eq__ implica __hash__. Usa @total_ordering para no repetir comparaciones. Retorna NotImplemented (no raises) en operadores. __call__ convierte objetos en funciones con estado. __enter__/__exit__ garantiza limpieza de recursos. __getattr__ es el último recurso; __getattribute__ intercepta absolutamente todo.
La siguiente lección: clases abstractas — el mecanismo formal de Python para definir interfaces y obligar a las subclases a implementar métodos concretos.
❓ Preguntas frecuentes
❓ Preguntas frecuentes sobre Métodos especiales en Python: el protocolo dunder completo
Las dudas más comunes respondidas de forma clara y directa.
💬 Foro de discusión
¿Tienes dudas sobre Métodos especiales en Python: el protocolo dunder completo? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!