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.
Tabla de Contenidos
¿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 + b → type(a).__add__(a, b) → if NotImplemented → type(b).__radd__(b, a) → if NotImplemented → TypeError.
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

Un comentario