Cuando escribes len(x), Python no consulta ningún campo mágico oculto — simplemente llama a x.__len__(). Eso es todo. Los dunder methods (también llamados métodos especiales o magic methods) son métodos con nombres de la forma __nombre__ que el intérprete invoca en respuesta a operaciones sintácticas específicas. No hay magia real: hay un contrato documentado entre tu objeto y el lenguaje.
La decisión de diseño es deliberada. Guido quería que los tipos built-in y los tipos de usuario fueran ciudadanos de primera clase por igual. Una lista y tu propia clase Matrix pueden responder a len(), a los operadores aritméticos y a la indexación con exactamente el mismo mecanismo. El protocolo es abierto: cualquier objeto puede implementar cualquier operación simplemente definiendo el método correspondiente.
Lo que cambia cuando entiendes esto es profundo. Dejas de pensar en los operadores como comportamiento exclusivo de los tipos built-in y empiezas a verlos como mensajes que el intérprete envía a tus objetos.
El flujo para operadores binarios tiene un matiz importante. En x + y, Python primero intenta x.__add__(y). Si ese método no existe, o si retorna el singleton NotImplemented (no lo confundas con NotImplementedError), entonces Python prueba el operador reflejado del operando derecho: y.__radd__(x). Esto permite que tipos que no se conocen entre sí cooperen: y tiene la oportunidad de manejar la operación aunque x no supiera nada de él.
Cuándo usar esto: siempre que tu clase represente un concepto con semántica natural de operador, contenedor, o contexto. Una clase Vector debe soportar + y *. Una clase Dataset tiene sentido con len() y []. Una clase de configuración puede soportar with mediante __enter__ y __exit__. Forzar a los usuarios a llamar métodos como .add() cuando + es lo natural es un error de diseño, no precaución.
Lo que se rompe si lo ignoras: acabas escribiendo APIs verbosas que se sienten ajenas a Python, o peor, defines __eq__ sin definir __hash__, rompiendo silenciosamente el comportamiento en diccionarios y sets.
from __future__ import annotations
from math import sqrt
class Vector:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __repr__(self) -> str:
# __repr__ es para desarrolladores: debe ser inequívoco y reproducible
return f"Vector({self.x!r}, {self.y!r})"
def __len__(self) -> int:
# len() espera un entero no negativo; aquí "longitud" = dimensiones
return 2
def __getitem__(self, index: int) -> float:
return (self.x, self.y)[index]
def __add__(self, other: object) -> Vector | type[NotImplemented]:
if not isinstance(other, Vector):
# Señalamos que no sabemos manejar este tipo; Python probará __radd__
return NotImplemented
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, scalar: object) -> Vector | type[NotImplemented]:
if not isinstance(scalar, (int, float)):
return NotImplemented
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar: object) -> Vector | type[NotImplemented]:
# Cubre el caso 3.0 * v donde float.__mul__(v) retorna NotImplemented
return self.__mul__(scalar)
def __abs__(self) -> float:
return sqrt(self.x ** 2 + self.y ** 2)
def __bool__(self) -> bool:
# Un vector nulo es falsy; cualquier otro es truthy
return bool(self.x or self.y)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self) -> int:
# Si defines __eq__, debes definir __hash__ para que el objeto
# siga siendo hashable; usamos la misma información que __eq__
return hash((self.x, self.y))
# --- Ejercicio del protocolo ---
v1 = Vector(3.0, 4.0)
v2 = Vector(1.0, 2.0)
print(repr(v1)) # Vector(3.0, 4.0)
print(len(v1)) # 2
print(v1[0]) # 3.0 — via __getitem__
print(v1 + v2) # Vector(4.0, 6.0)
print(v1 * 2) # Vector(6.0, 8.0)
print(3.0 * v1) # Vector(9.0, 12.0) — via __rmul__
print(abs(v1)) # 5.0
print(bool(Vector(0, 0))) # False
print(v1 == Vector(3.0, 4.0)) # True
print({v1, v2}) # funciona porque __hash__ está definido
Qué está pasando realmente
La clase Vector no hereda de nada especial — solo de object implícitamente. Aun así, participa en len(), en los operadores + y *, en abs(), en bool(), y en indexación directa. Cada una de esas capacidades viene de un dunder distinto, y el intérprete los invoca en situaciones sintácticas precisas.
Fíjate en el retorno de NotImplemented dentro de __add__ y __eq__. No es una excepción ni un None — es un objeto sentinel que le dice al intérprete “no puedo manejar esto, intenta con el otro operando”. Si en cambio lanzaras una excepción o retornaras False directamente, cortarías el mecanismo de fallback y romperías la cooperación entre tipos.
La pareja __mul__ / __rmul__ ilustra la asimetría del protocolo. Cuando escribes 3.0 * v1, Python evalúa float.__mul__(v1), que retorna NotImplemented porque float no sabe nada de Vector. Entonces Python llama v1.__rmul__(3.0), que sí sabe qué hacer. Sin __rmul__, esa expresión lanzaría TypeError.
La relación entre __eq__ y __hash__ es quizás la trampa más costosa del modelo. En Python 3, si defines __eq__, la clase hereda __hash__ = None automáticamente, volviéndose unhashable. Eso significa que no puedes usar instancias como claves de diccionario ni elementos de set — silenciosamente, hasta que falla en runtime. La regla es simple: si dos objetos son iguales según __eq__, deben producir el mismo hash.
__repr__ merece atención aparte: es el método que Python usa en el REPL, en logging, y en mensajes de error. Un buen __repr__ es reproducible — idealmente, eval(repr(obj)) == obj. Aquí usamos !r en el f-string para que los strings aparezcan con sus comillas, cumpliendo ese criterio.
Errores que debes conocer
Error: Retornar None en lugar de NotImplemented cuando el tipo del otro operando no es compatible, lo que suprime el fallback al operador reflejado.
# ❌ Wrong
def __add__(self, other):
if not isinstance(other, Vector):
return None # Python no intentará other.__radd__; la expresión valdrá None
return Vector(self.x + other.x, self.y + other.y)
# ✅ Right
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x + other.x, self.y + other.y)
NotImplemented es el centinela que activa el mecanismo de fallback del intérprete; None es solo un valor de retorno normal que rompe el protocolo silenciosamente.
Error: Definir __eq__ sin definir __hash__, dejando la clase unhashable sin advertencia visible.
# ❌ Wrong
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return isinstance(other, Point) and self.x == other.x and self.y == other.y
# __hash__ queda como None → TypeError al insertar en un set
# ✅ Right
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return isinstance(other, Point) and self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y))
__hash__ debe basarse en los mismos campos que __eq__ para mantener el invariante de que objetos iguales tienen hashes iguales.
Error: Confundir NotImplemented (singleton, tipo NotImplementedType) con NotImplementedError (excepción), lanzando una excepción donde deberías retornar el sentinel.
# ❌ Wrong
def __add__(self, other):
if not isinstance(other, Vector):
raise NotImplementedError # corta la ejecución; no hay fallback posible
# ✅ Right
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented # retorna el sentinel; el intérprete continúa el protocolo
NotImplementedError es para métodos abstractos que una subclase debe sobreescribir; NotImplemented es la señal de protocolo para operadores binarios.
El modelo de datos de Python es en esencia una interfaz con el intérprete documentada íntegramente en la data model reference — leer esa sección del manual una vez cambia permanentemente cómo ves el lenguaje.
N° 87