Operadores como dunders: __eq__, __lt__, __add__ y reflejos

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

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio