Cuando escribes a + b, Python no ejecuta magia interna misteriosa: llama a a.__add__(b). Toda la aritmética, las comparaciones y las asignaciones compuestas del lenguaje son azúcar sintáctico sobre métodos especiales (dunders) del modelo de datos. Esto significa que puedes darle a cualquier clase exactamente la semántica que necesita, con la misma sintaxis que usan los tipos built-in.
Hay tres familias de dunders operacionales que conviene dominar juntas porque interactúan entre sí de formas no obvias.
Igualdad y el precio que pagas con __hash__
__eq__ define el comportamiento de ==. Por defecto hereda de object y compara identidad (equivalente a is). Cuando lo sobreescribes, le dices a Python qué significa que dos instancias sean equivalentes en valor.
El contrato tiene un precio: si defines __eq__, Python elimina automáticamente el __hash__ heredado, asignándole None a tu clase. La razón es sólida: dos objetos iguales según == deben producir el mismo hash. Si te dejara el __hash__ de object (basado en identidad), podrías tener objetos que son iguales pero con hashes distintos, rompiendo invariantes de dict y set. Python prefiere que la clase falle ruidosamente a que se comporte de forma inconsistente.
Si tu objeto es inmutable y quieres que sea hashable, defines __hash__ explícitamente. Si es mutable y no quieres que viva en sets ni como clave de dict, no haces nada: el comportamiento por defecto es correcto.
Orden y functools.total_ordering
Las comparaciones de orden usan __lt__, __le__, __gt__, __ge__. Implementar los seis (contando __eq__) a mano es tedioso y propenso a inconsistencias. functools.total_ordering resuelve esto: defines __eq__ y uno de los cuatro métodos de orden, y el decorador deriva el resto. El coste es una pequeña penalización de rendimiento (los métodos derivados usan lógica genérica con llamadas indirectas), aceptable en casi todos los contextos.
Operadores binarios y sus versiones reflejadas
Cuando Python evalúa a + b, el protocolo es:
1. Intenta a.__add__(b).
2. Si eso retorna NotImplemented, intenta b.__radd__(a).
Las versiones reflejadas (__radd__, __rsub__, __rmul__, etc.) existen para que un tipo pueda participar en operaciones aunque esté en el lado derecho de un operador cuyo operando izquierdo no lo conoce. El caso clásico: 2 + mi_vector llama a int.__add__(mi_vector), que retorna NotImplemented, y entonces Python cae a mi_vector.__radd__(2).
Las versiones in-place (__iadd__, __isub__, etc.) se activan con +=, -=, etc. La convención es que los objetos mutables las implementen retornando self tras modificarse, mientras que los inmutables simplemente no las definen (Python cae al __add__ normal y reasigna la variable).
from __future__ import annotations
from functools import total_ordering
from typing import Any
@total_ordering
class Vector:
"""Vector 2D mutable con aritmética completa."""
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
# ── Representación ────────────────────────────────────────────
def __repr__(self) -> str:
return f"Vector({self.x}, {self.y})"
# ── Igualdad y orden ──────────────────────────────────────────
def __eq__(self, other: object) -> bool:
if not isinstance(other, Vector):
return NotImplemented # permite que Python pruebe el otro lado
return self.x == other.x and self.y == other.y
def __lt__(self, other: object) -> bool:
if not isinstance(other, Vector):
return NotImplemented
return self.magnitude() < other.magnitude()
# Sin __hash__ explícito: esta clase es intencionalmente no hashable
# porque es mutable. Python ya puso __hash__ = None por nosotros.
# ── Aritmética binaria ─────────────────────────────────────────
def __add__(self, other: object) -> Vector:
if isinstance(other, Vector):
return Vector(self.x + other.x, self.y + other.y)
if isinstance(other, (int, float)):
return Vector(self.x + other, self.y + other)
return NotImplemented
def __radd__(self, other: object) -> Vector:
# Llega aquí cuando `other + self` y other no supo operar.
# La suma vectorial con escalar es conmutativa, delegamos.
return self.__add__(other)
# ── In-place ──────────────────────────────────────────────────
def __iadd__(self, other: object) -> Vector:
if isinstance(other, Vector):
self.x += other.x
self.y += other.y
return self # mutable: modifica en lugar y devuelve self
if isinstance(other, (int, float)):
self.x += other
self.y += other
return self
return NotImplemented
# ── Utilidad ──────────────────────────────────────────────────
def magnitude(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5
# ── Demo ──────────────────────────────────────────────────────────
v1 = Vector(3, 4) # magnitud 5
v2 = Vector(1, 1) # magnitud ~1.41
v3 = Vector(3, 4)
print(v1 == v3) # True → __eq__
print(v1 == v2) # False
print(v2 < v1) # True → __lt__
print(v1 > v2) # True → derivado por total_ordering
print(v1 <= v3) # True → derivado por total_ordering
print(v1 + v2) # Vector(4, 5) → __add__
print(2.0 + v2) # Vector(3, 3) → __radd__ (float no conoce Vector)
original_id = id(v1)
v1 += Vector(1, 0)
print(v1) # Vector(4, 4) → __iadd__, mismo objeto
print(id(v1) == original_id) # True: no se creó un objeto nuevo
Qué está pasando en cada decisión
Retornar NotImplemented (no lanzar TypeError) es la clave del protocolo. Cuando __eq__ o __add__ encuentran un tipo que no conocen, devuelven NotImplemented para decirle a Python “prueba el otro lado”. Si lanzaras una excepción directamente, cortarías ese mecanismo de fallback.
total_ordering deriva __le__, __gt__, __ge__ a partir de __eq__ y __lt__. Fíjate que en el código solo definimos esos dos, pero v1 > v2 y v1 <= v3 funcionan. El decorador construye los métodos faltantes usando negaciones y combinaciones lógicas de los que sí existen.
__radd__ tiene sentido aquí porque 2.0 + v2 pasa por float.__add__(v2), que retorna NotImplemented porque float no sabe sumar vectores. Python entonces intenta v2.__radd__(2.0), que sí funciona. Sin __radd__, esa expresión lanzaría TypeError.
__iadd__ retornando self es la convención fundamental para objetos mutables. Python ejecuta v1 = v1.__iadd__(Vector(1, 0)) internamente, así que si retornas self, la variable v1 sigue apuntando al mismo objeto. Si en cambio retornaras un objeto nuevo (o no definieras __iadd__), Python caería al __add__ y crearía una nueva instancia, que es la semántica correcta para inmutables como int o tuple.
La ausencia de __hash__ no es un olvido. Vector es mutable: cambiar v1.x después de insertar v1 en un set rompería la invariante del hash. Python tomó la decisión por nosotros al ver que definimos __eq__.
Errores que debes conocer
Error: definir __eq__ en una clase que necesitas usar como clave de diccionario, sin recordar restaurar __hash__.
# ❌ Wrong
class Config:
def __init__(self, name):
self.name = name
def __eq__(self, other):
return self.name == other.name
cache = {}
cache[Config("prod")] = 42 # TypeError: unhashable type: 'Config'
# ✅ Right
class Config:
def __init__(self, name):
self.name = name
def __eq__(self, other):
return self.name == other.name
def __hash__(self):
return hash(self.name) # consistente con __eq__
__hash__ debe basarse exactamente en los mismos campos que __eq__ para garantizar que objetos iguales tengan el mismo hash.
Error: implementar __iadd__ sin retornar self en un objeto mutable.
# ❌ Wrong
def __iadd__(self, other):
self.x += other.x
self.y += other.y
# sin return → retorna None implícitamente
v = Vector(1, 1)
v += Vector(1, 0)
print(v) # None — v ahora apunta a None
# ✅ Right
def __iadd__(self, other):
self.x += other.x
self.y += other.y
return self
Python asigna el resultado de __iadd__ de vuelta a la variable; si retorna None, la variable queda destruida.
Error: lanzar TypeError en vez de retornar NotImplemented al encontrar un tipo desconocido.
# ❌ Wrong
def __add__(self, other):
if not isinstance(other, Vector):
raise TypeError(f"No sé sumar con {type(other)}")
...
# ✅ Right
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented # Python intentará other.__radd__(self)
...
Retornar NotImplemented mantiene vivo el protocolo de reflexión; lanzar la excepción lo corta antes de que Python pueda intentar el operando derecho.
N° 89