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.

89