sobrecarga de operadores en Python métodos mágicos total_ordering

Sobrecarga de operadores en Python — métodos mágicos para clases propias

La sobrecarga de operadores en Python permite que tus clases propias funcionen con los operadores habituales (+, -, *, ==, <, >) igual que los tipos predefinidos. En FP2 es el tema que más confunde porque mezcla métodos mágicos, herencia y polimorfismo en un mismo concepto.

En este artículo lo vemos todo con una clase Vector2D que construimos paso a paso.

¿Qué es la sobrecarga de operadores?

En Python los operadores ya están sobrecargados por defecto, + suma números pero también concatena cadenas:

print(3 + 5)          # → 8    (suma de enteros)
print('Hola' + ' mundo')  # → Hola mundo (concatenación)
print([1, 2] + [3, 4])    # → [1, 2, 3, 4] (concatenación de listas)

El mismo símbolo, tiene uncomportamiento diferente según el tipo, eso es sobrecarga. Con los métodos mágicos puedes hacer lo mismo para tus propias clases.

La clase Vector2D — punto de partida

Usaremos un vector bidimensional como ejemplo central, tiene coordenadas x e y y es perfecto para ilustrar todos los operadores:

class Vector2D:
    def __init__(self, x, y):
        self.__x = x
        self.__y = y

    @property
    def x(self): return self.__x

    @property
    def y(self): return self.__y

    def __str__(self):
        return f'({self.__x}, {self.__y})'

    def __repr__(self):
        return f'Vector2D({self.__x}, {self.__y})'

v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)
print(v1)       # → (3, 4)
print(repr(v1)) # → Vector2D(3, 4)

Sin sobrecarga de operadores:

print(v1 + v2)    # TypeError — no sabe cómo sumar vectores
print(v1 == v2)   # False — compara referencias, no valores
print(v1 < v2)    # TypeError — no sabe cómo comparar

Vamos a arreglarlo todo.

Operadores de comparación

Los métodos mágicos de comparación reciben self (el objeto de la izquierda) y other (el de la derecha):

__eq__(self, other)   # ==
__ne__(self, other)   # != (automático si defines __eq__)
__lt__(self, other)   # 
__gt__(self, other)   # >
__le__(self, other)   # <=
__ge__(self, other)   # >=

Añadimos __eq__ a Vector2D:

class Vector2D:
    def __init__(self, x, y):
        self.__x = x
        self.__y = y

    @property
    def x(self): return self.__x

    @property
    def y(self): return self.__y

    def __eq__(self, other):
        if not isinstance(other, Vector2D):
            return False    # tipos diferentes → nunca iguales
        return self.__x == other.x and self.__y == other.y

    def __str__(self):
        return f'({self.__x}, {self.__y})'

    def __repr__(self):
        return f'Vector2D({self.__x}, {self.__y})'

v1 = Vector2D(3, 4)
v2 = Vector2D(3, 4)
v3 = Vector2D(1, 2)

print(v1 == v2)    # → True
print(v1 == v3)    # → False
print(v1 != v3)    # → True (automático al definir __eq__)

Si defines __eq__, Python define __ne__ automáticamente como su negación, no necesitas escribirlo.

Fíjate en el isinstance, siempre comprueba el tipo antes de acceder a los atributos del otro objeto. Si other no es Vector2D no tiene .x ni .y y lanzaría AttributeError.

@functools.total_ordering — el atajo que más confunde

Para comparar con <, >, <=, >= necesitarías implementar los seis métodos de comparación. @total_ordering te permite implementar solo __eq__ y uno de los cuatro métodos de comparación enriquecida, el decorador genera el resto:

from functools import total_ordering
import math

@total_ordering
class Vector2D:
    def __init__(self, x, y):
        self.__x = x
        self.__y = y

    @property
    def x(self): return self.__x

    @property
    def y(self): return self.__y

    def modulo(self):
        return math.sqrt(self.__x**2 + self.__y**2)

    def __eq__(self, other):
        if not isinstance(other, Vector2D):
            return False
        return self.__x == other.x and self.__y == other.y

    def __lt__(self, other):
        # comparamos por módulo (longitud del vector)
        return self.modulo() < other.modulo()

    def __str__(self):
        return f'({self.__x}, {self.__y})'

    def __repr__(self):
        return f'Vector2D({self.__x}, {self.__y})'

v1 = Vector2D(3, 4)    # módulo = 5
v2 = Vector2D(1, 2)    # módulo ≈ 2.24
v3 = Vector2D(3, 4)    # módulo = 5

print(v1 > v2)     # → True  (generado por @total_ordering)
print(v1 <= v3)    # → True  (generado por @total_ordering)
print(v2 < v1)     # → True  (definido por __lt__)
print(sorted([v1, v2, v3]))    # → [(1, 2), (3, 4), (3, 4)]

@total_ordering necesita: __eq__ + uno de (__lt__, __le__, __gt__, __ge__). El decorador deduce el resto. La regla práctica: implementa siempre __eq__ y __lt__, es la combinación más natural.

Operadores aritméticos binarios

Los operadores binarios tienen dos operandos, self a la izquierda y other a la derecha:

__add__(self, other)       # +
__sub__(self, other)       # -
__mul__(self, other)       # *
__truediv__(self, other)   # /
__floordiv__(self, other)  # //
__mod__(self, other)       # %
__pow__(self, other)       # **

Añadimos operadores aritméticos a Vector2D:

from functools import total_ordering
import math

@total_ordering
class Vector2D:
    def __init__(self, x, y):
        self.__x = x
        self.__y = y

    @property
    def x(self): return self.__x

    @property
    def y(self): return self.__y

    def modulo(self):
        return math.sqrt(self.__x**2 + self.__y**2)

    def __eq__(self, other):
        if not isinstance(other, Vector2D):
            return False
        return self.__x == other.x and self.__y == other.y

    def __lt__(self, other):
        return self.modulo() < other.modulo()

    def __add__(self, other):
        if isinstance(other, Vector2D):
            return Vector2D(self.__x + other.x, self.__y + other.y)
        elif isinstance(other, (int, float)):
            return Vector2D(self.__x + other, self.__y + other)
        return NotImplemented    # tipo no soportado

    def __sub__(self, other):
        if isinstance(other, Vector2D):
            return Vector2D(self.__x - other.x, self.__y - other.y)
        elif isinstance(other, (int, float)):
            return Vector2D(self.__x - other, self.__y - other)
        return NotImplemented

    def __mul__(self, other):
        if isinstance(other, (int, float)):
            return Vector2D(self.__x * other, self.__y * other)
        elif isinstance(other, Vector2D):
            # producto escalar
            return self.__x * other.x + self.__y * other.y
        return NotImplemented

    def __str__(self):
        return f'({self.__x}, {self.__y})'

    def __repr__(self):
        return f'Vector2D({self.__x}, {self.__y})'

v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)

print(v1 + v2)      # → (4, 6)
print(v1 - v2)      # → (2, 2)
print(v1 + 10)      # → (13, 14)
print(v1 * 2)       # → (6, 8)
print(v1 * v2)      # → 11 (producto escalar: 3*1 + 4*2)

Fíjate en return NotImplemented, no es lo mismo que return None. Cuando devuelves NotImplemented Python sabe que el operador no está soportado para ese tipo y lo intenta con el operador reflejado del otro objeto.

Operadores unarios

Los operadores unarios actúan sobre un solo operando:

__neg__(self)    # -obj (negación)
__pos__(self)    # +obj
__abs__(self)    # abs(obj)
def __neg__(self):
    return Vector2D(-self.__x, -self.__y)

def __pos__(self):
    return Vector2D(self.__x, self.__y)

def __abs__(self):
    return self.modulo()

v = Vector2D(3, 4)
print(-v)       # → (-3, -4)
print(abs(v))   # → 5.0

Operadores reflejados — el concepto que más confunde

El problema con los operadores aritméticos aparece cuando el objeto propio está a la derecha:

v = Vector2D(3, 4)
print(v * 2)      # → funciona — llama a v.__mul__(2)
print(2 * v)      # → TypeError — llama a int.__mul__(v) que no sabe qué hacer

Python prueba primero int.__mul__(v), falla porque int no sabe multiplicar por Vector2D. Entonces Python busca el operador reflejado en el objeto de la derecha, v.__rmul__(2). Los operadores reflejados tienen el mismo nombre pero con r al principio:

__radd__(self, other)    # other + self
__rsub__(self, other)    # other - self
__rmul__(self, other)    # other * self
__rtruediv__(self, other)  # other / self

Añadimos __rmul__ a Vector2D:

def __rmul__(self, other):
    """other * self — para cuando el vector está a la derecha"""
    return self.__mul__(other)    # la multiplicación es conmutativa

v = Vector2D(3, 4)
print(v * 2)      # → (6, 8) — llama a __mul__
print(2 * v)      # → (6, 8) — llama a __rmul__

El flujo completo de Python al evaluar 2 * v:

1. Python prueba: int.__mul__(2, v) → devuelve NotImplemented
2. Python prueba el reflejado: v.__rmul__(2) → devuelve Vector2D(6, 8)
3. Si __rmul__ tampoco existe → TypeError

La clase Vector2D completa

Aquí tienes la clase completa con todos los operadores:

from functools import total_ordering
import math

@total_ordering
class Vector2D:
    def __init__(self, x, y):
        self.__x = float(x)
        self.__y = float(y)

    @property
    def x(self): return self.__x

    @property
    def y(self): return self.__y

    def modulo(self):
        return math.sqrt(self.__x**2 + self.__y**2)

    def normalizado(self):
        m = self.modulo()
        if m == 0:
            raise ValueError('No se puede normalizar el vector cero')
        return Vector2D(self.__x / m, self.__y / m)

    # Comparación
    def __eq__(self, other):
        if not isinstance(other, Vector2D):
            return False
        return self.__x == other.x and self.__y == other.y

    def __lt__(self, other):
        return self.modulo() < other.modulo()

    # Aritmética
    def __add__(self, other):
        if isinstance(other, Vector2D):
            return Vector2D(self.__x + other.x, self.__y + other.y)
        elif isinstance(other, (int, float)):
            return Vector2D(self.__x + other, self.__y + other)
        return NotImplemented

    def __radd__(self, other):
        return self.__add__(other)

    def __sub__(self, other):
        if isinstance(other, Vector2D):
            return Vector2D(self.__x - other.x, self.__y - other.y)
        elif isinstance(other, (int, float)):
            return Vector2D(self.__x - other, self.__y - other)
        return NotImplemented

    def __rsub__(self, other):
        if isinstance(other, (int, float)):
            return Vector2D(other - self.__x, other - self.__y)
        return NotImplemented

    def __mul__(self, other):
        if isinstance(other, (int, float)):
            return Vector2D(self.__x * other, self.__y * other)
        elif isinstance(other, Vector2D):
            return self.__x * other.x + self.__y * other.y
        return NotImplemented

    def __rmul__(self, other):
        return self.__mul__(other)

    def __truediv__(self, other):
        if isinstance(other, (int, float)):
            if other == 0:
                raise ZeroDivisionError('No se puede dividir por cero')
            return Vector2D(self.__x / other, self.__y / other)
        return NotImplemented

    # Unarios
    def __neg__(self):
        return Vector2D(-self.__x, -self.__y)

    def __pos__(self):
        return Vector2D(self.__x, self.__y)

    def __abs__(self):
        return self.modulo()

    def __round__(self, n=0):
        return Vector2D(round(self.__x, n), round(self.__y, n))

    # Representación
    def __str__(self):
        return f'({self.__x}, {self.__y})'

    def __repr__(self):
        return f'Vector2D({self.__x}, {self.__y})'

# Prueba completa
v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)

print(f'v1 = {v1}')
print(f'v2 = {v2}')
print(f'v1 + v2 = {v1 + v2}')
print(f'v1 - v2 = {v1 - v2}')
print(f'v1 * 2 = {v1 * 2}')
print(f'3 * v1 = {3 * v1}')        # reflejado
print(f'v1 * v2 = {v1 * v2}')      # producto escalar
print(f'v1 / 2 = {v1 / 2}')
print(f'-v1 = {-v1}')
print(f'abs(v1) = {abs(v1)}')
print(f'v1 == v2: {v1 == v2}')
print(f'v1 > v2: {v1 > v2}')
print(f'sorted: {sorted([v1, v2, Vector2D(0,0)])}')
v1 = (3.0, 4.0)
v2 = (1.0, 2.0)
v1 + v2 = (4.0, 6.0)
v1 - v2 = (2.0, 2.0)
v1 * 2 = (6.0, 8.0)
3 * v1 = (9.0, 12.0)
v1 * v2 = 11.0
v1 / 2 = (1.5, 2.0)
-v1 = (-3.0, -4.0)
abs(v1) = 5.0
v1 == v2: False
v1 > v2: True
sorted: [(0.0, 0.0), (1.0, 2.0), (3.0, 4.0)]

Sobrecarga de funciones — polimorfismo con parámetros

Python no permite definir dos funciones con el mismo nombre. En su lugar usa parámetros por omisión y comprobación de tipos para simular el comportamiento:

# Polimorfismo con parámetros por omisión
class Paralelogramo:
    def __init__(self, lado1, angulo=90, lado2=None):
        self.lado1 = lado1

        if lado2 is None or lado1 == lado2:
            if angulo != 90:
                self.tipo = 'rombo'
                self.angulo = angulo
            else:
                self.tipo = 'cuadrado'
        else:
            self.lado2 = lado2
            if angulo != 90:
                self.tipo = 'romboide'
                self.angulo = angulo
            else:
                self.tipo = 'rectángulo'

    def __str__(self):
        return f'{self.tipo} (lado: {self.lado1})'

cuadrado   = Paralelogramo(12)
rombo      = Paralelogramo(12, 60)
rectangulo = Paralelogramo(12, side2=8)
romboide   = Paralelogramo(12, 60, 8)

print(cuadrado)    # → cuadrado (lado: 12)
print(rombo)       # → rombo (lado: 12)
# Polimorfismo basado en el tipo de los parámetros
def crear_numero(a, b):
    if isinstance(a, int) and isinstance(b, int):
        from fractions import Fraction
        return Fraction(a, b)
    elif isinstance(a, float) or isinstance(b, float):
        return complex(a, b)
    return None

print(crear_numero(1, 2))      # → 1/2 (Fraction)
print(crear_numero(1.0, 2.0))  # → (1+2j) (complex)
print(crear_numero('a', 'b'))  # → None

La diferencia clave con otros lenguajes: en Python no hay sobrecarga real de funciones, hay una sola función que se comporta de forma diferente según los parámetros que recibe.

Visualízalo con Python Tutor

Copia este código en pythontutor.com:

from functools import total_ordering
import math

@total_ordering
class Vector2D:
    def __init__(self, x, y):
        self.__x = float(x)
        self.__y = float(y)

    def modulo(self):
        return math.sqrt(self.__x**2 + self.__y**2)

    def __eq__(self, other):
        if not isinstance(other, Vector2D):
            return False
        return self.__x == other.x and self.__y == other.y

    def __lt__(self, other):
        return self.modulo() < other.modulo()

    def __add__(self, other):
        if isinstance(other, Vector2D):
            return Vector2D(self.__x + other.x, self.__y + other.y)
        elif isinstance(other, (int, float)):
            return Vector2D(self.__x + other, self.__y + other)
        return NotImplemented

    def __rmul__(self, other):
        if isinstance(other, (int, float)):
            return Vector2D(self.__x * other, self.__y * other)
        return NotImplemented

    @property
    def x(self): return self.__x
    @property
    def y(self): return self.__y

    def __str__(self):
        return f'({self.__x}, {self.__y})'

v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)

print(v1 + v2)     # __add__
print(3 * v1)      # __rmul__ — v1 está a la derecha
print(v1 > v2)     # generado por @total_ordering

Ejecuta paso a paso y observa tres cosas:

Primero — cuando evalúa v1 + v2 Python llama directamente a v1.__add__(v2) — los dos son Vector2D así que entra por el primer isinstance.

Segundo — cuando evalúa 3 * v1 Python primero intenta int.__mul__(3, v1) — falla con NotImplemented. Entonces busca el reflejado y llama a v1.__rmul__(3) — ahí sí funciona.

Tercero — cuando evalúa v1 > v2 Python no encuentra __gt__ en la clase pero sí encuentra @total_ordering — que lo ha generado automáticamente a partir de __lt__.

Resumen rápido

# COMPARACIÓN
from functools import total_ordering

@total_ordering
class MiClase:
    def __eq__(self, other):
        if not isinstance(other, MiClase): return False
        return ...    # comparación real

    def __lt__(self, other):
        return ...    # @total_ordering genera el resto

# ARITMÉTICA BINARIA
def __add__(self, other):
    if isinstance(other, MiClase):
        return MiClase(...)
    elif isinstance(other, (int, float)):
        return MiClase(...)
    return NotImplemented    # no None — permite operadores reflejados

# OPERADORES REFLEJADOS — cuando self está a la derecha
def __radd__(self, other): return self.__add__(other)
def __rmul__(self, other): return self.__mul__(other)
def __rsub__(self, other):
    # ojo — la resta NO es conmutativa
    if isinstance(other, (int, float)):
        return MiClase(other - self.valor)
    return NotImplemented

# UNARIOS
def __neg__(self): return MiClase(-self.valor)
def __abs__(self): return abs(self.valor)
def __round__(self, n=0): return MiClase(round(self.valor, n))

# FLUJO DE Python con operadores
# a + b:
# 1. type(a).__add__(a, b)
# 2. Si NotImplemented → type(b).__radd__(b, a)
# 3. Si NotImplemented → TypeError

# POLIMORFISMO EN PYTHON
# No existe sobrecarga real → usa parámetros por omisión + isinstance
def metodo(self, param1, param2=None):
    if isinstance(param1, int):
        ...    # comportamiento para int
    elif isinstance(param1, str):
        ...    # comportamiento para str

# ERRORES TÍPICOS
# 1. return None en vez de return NotImplemented → rompe los reflejados
# 2. No comprobar isinstance antes de acceder a atributos → AttributeError
# 3. Olvidar que la resta NO es conmutativa en __rsub__
# 4. Usar @total_ordering sin __eq__ → no funciona

En el próximo artículo practicamos la sobrecarga de operadores con clases reales.


Python operator overloading — magic methods for your own classes

What is operator overloading?

print(3 + 5)              # → 8
print('Hello' + ' world') # → Hello world
print([1, 2] + [3, 4])   # → [1, 2, 3, 4]

Same symbol, different behaviour per type. You can do the same for your own classes using magic methods.

Comparison operators

from functools import total_ordering
import math

@total_ordering
class Vector2D:
    def __init__(self, x, y):
        self.__x = float(x)
        self.__y = float(y)

    @property
    def x(self): return self.__x
    @property
    def y(self): return self.__y

    def magnitude(self):
        return math.sqrt(self.__x**2 + self.__y**2)

    def __eq__(self, other):
        if not isinstance(other, Vector2D):
            return False
        return self.__x == other.x and self.__y == other.y

    def __lt__(self, other):
        return self.magnitude() < other.magnitude()

@total_ordering generates >, <=, >= from __eq__ + __lt__. Always check isinstance before accessing attributes, otherwise AttributeError.

Arithmetic operators

def __add__(self, other):
    if isinstance(other, Vector2D):
        return Vector2D(self.__x + other.x, self.__y + other.y)
    elif isinstance(other, (int, float)):
        return Vector2D(self.__x + other, self.__y + other)
    return NotImplemented    # not None — enables reflected operators

def __mul__(self, other):
    if isinstance(other, (int, float)):
        return Vector2D(self.__x * other, self.__y * other)
    elif isinstance(other, Vector2D):    # dot product
        return self.__x * other.x + self.__y * other.y
    return NotImplemented

Reflected operators — the most confusing concept

v = Vector2D(3, 4)
print(v * 2)    # → works — calls v.__mul__(2)
print(2 * v)    # → TypeError without __rmul__

Python tries int.__mul__(2, v) first, fails. Then tries v.__rmul__(2):

def __rmul__(self, other):
    return self.__mul__(other)    # multiplication is commutative

def __rsub__(self, other):
    if isinstance(other, (int, float)):
        return Vector2D(other - self.__x, other - self.__y)
    return NotImplemented    # subtraction is NOT commutative — careful

Python’s operator resolution: a + btype(a).__add__(a, b) → if NotImplementedtype(b).__radd__(b, a) → if NotImplementedTypeError.

Unary operators

def __neg__(self): return Vector2D(-self.__x, -self.__y)
def __abs__(self): return self.magnitude()
def __round__(self, n=0): return Vector2D(round(self.__x, n), round(self.__y, n))

Function overloading — Python style

Python has no real function overloading. Uses default parameters and type checking instead:

# Default parameter polymorphism
class Parallelogram:
    def __init__(self, side1, angle=90, side2=None):
        if side2 is None or side1 == side2:
            self.kind = 'rhombus' if angle != 90 else 'square'
        else:
            self.kind = 'rhomboid' if angle != 90 else 'rectangle'

square    = Parallelogram(12)
rhombus   = Parallelogram(12, 60)
rectangle = Parallelogram(12, side2=8)

# Type-based polymorphism
def create_number(a, b):
    if isinstance(a, int) and isinstance(b, int):
        from fractions import Fraction
        return Fraction(a, b)
    elif isinstance(a, float) or isinstance(b, float):
        return complex(a, b)
    return None

Visualise with Python Tutor

Copy this code into pythontutor.com:

from functools import total_ordering
import math

@total_ordering
class Vector2D:
    def __init__(self, x, y):
        self.__x = float(x)
        self.__y = float(y)

    def magnitude(self):
        return math.sqrt(self.__x**2 + self.__y**2)

    def __eq__(self, other):
        if not isinstance(other, Vector2D):
            return False
        return self.__x == other.x and self.__y == other.y

    def __lt__(self, other):
        return self.magnitude() < other.magnitude()

    def __add__(self, other):
        if isinstance(other, Vector2D):
            return Vector2D(self.__x + other.x, self.__y + other.y)
        elif isinstance(other, (int, float)):
            return Vector2D(self.__x + other, self.__y + other)
        return NotImplemented

    def __rmul__(self, other):
        if isinstance(other, (int, float)):
            return Vector2D(self.__x * other, self.__y * other)
        return NotImplemented

    @property
    def x(self): return self.__x
    @property
    def y(self): return self.__y

    def __str__(self): return f'({self.__x}, {self.__y})'

v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)
print(v1 + v2)
print(3 * v1)
print(v1 > v2)

Step through and observe: v1 + v2 calls __add__ directly. 3 * v1 tries int.__mul__ first, fails, then calls v1.__rmul__(3). v1 > v2 uses the __gt__ generated automatically by @total_ordering.

Quick summary

# COMPARISON
@total_ordering
class MyClass:
    def __eq__(self, other):
        if not isinstance(other, MyClass): return False
        return ...
    def __lt__(self, other): return ...

# ARITHMETIC
def __add__(self, other):
    if isinstance(other, MyClass): return MyClass(...)
    elif isinstance(other, (int, float)): return MyClass(...)
    return NotImplemented    # NOT None

# REFLECTED
def __radd__(self, other): return self.__add__(other)
def __rmul__(self, other): return self.__mul__(other)
def __rsub__(self, other):    # careful — NOT commutative
    if isinstance(other, (int, float)):
        return MyClass(other - self.value)
    return NotImplemented

# UNARY
def __neg__(self): return MyClass(-self.value)
def __abs__(self): return abs(self.value)

# COMMON MISTAKES
# 1. return None instead of NotImplemented → breaks reflected operators
# 2. No isinstance check → AttributeError
# 3. Forgetting subtraction is NOT commutative in __rsub__
# 4. @total_ordering without __eq__ → doesn't work

Publicaciones Similares

Un comentario

Deja una respuesta

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