Sobrecarga de operadores en Python — 2 clases reales con todos los operadores

La sobrecarga de operadores en Python práctica real es lo que toca ahora. En el artículo anterior vimos la teoría. Ahora construimos dos clases completas con todos los operadores, número racional y dinero con conversión de moneda. Recuerda que usando pythontutor.com, puedes también visualizar tu código.

Sobrecarga de operadores en Python práctica — Programa 1: Clase Número Racional

El número racional es el ejemplo clásico de FP2 para sobrecarga de operadores, tiene numerador y denominador, no puede tener denominador cero y opera con las reglas del álgebra de fracciones:

from functools import total_ordering
from math import gcd

@total_ordering
class Racional:
    def __init__(self, numerador, denominador=1):
        if not isinstance(numerador, int):
            raise TypeError('El numerador debe ser un entero')
        if not isinstance(denominador, int):
            raise TypeError('El denominador debe ser un entero')
        if denominador == 0:
            raise ZeroDivisionError('El denominador no puede ser cero')
        # Normalizar signo — el signo siempre en el numerador
        if denominador < 0:
            numerador = -numerador
            denominador = -denominador
        # Simplificar usando el máximo común divisor
        comun = gcd(abs(numerador), denominador)
        self.__num = numerador // comun
        self.__den = denominador // comun

    @property
    def numerador(self):
        return self.__num

    @property
    def denominador(self):
        return self.__den

    # Métodos de clase alternativos para crear racionales
    @classmethod
    def desde_float(cls, valor, precision=6):
        """Crea un racional aproximado desde un float"""
        denominador = 10 ** precision
        numerador = round(valor * denominador)
        return cls(numerador, denominador)

    # Comparación
    def __eq__(self, other):
        if isinstance(other, Racional):
            return self.__num == other.numerador and self.__den == other.denominador
        elif isinstance(other, int):
            return self.__num == other and self.__den == 1
        return False

    def __lt__(self, other):
        if isinstance(other, Racional):
            return self.__num * other.denominador < other.numerador * self.__den
        elif isinstance(other, int):
            return self.__num < other * self.__den
        return NotImplemented

    # Aritmética
    def __add__(self, other):
        if isinstance(other, Racional):
            nuevo_num = self.__num * other.denominador + other.numerador * self.__den
            nuevo_den = self.__den * other.denominador
            return Racional(nuevo_num, nuevo_den)
        elif isinstance(other, int):
            return self + Racional(other)
        return NotImplemented

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

    def __sub__(self, other):
        if isinstance(other, Racional):
            nuevo_num = self.__num * other.denominador - other.numerador * self.__den
            nuevo_den = self.__den * other.denominador
            return Racional(nuevo_num, nuevo_den)
        elif isinstance(other, int):
            return self - Racional(other)
        return NotImplemented

    def __rsub__(self, other):
        if isinstance(other, int):
            return Racional(other) - self
        return NotImplemented

    def __mul__(self, other):
        if isinstance(other, Racional):
            return Racional(self.__num * other.numerador,
                          self.__den * other.denominador)
        elif isinstance(other, int):
            return Racional(self.__num * other, self.__den)
        return NotImplemented

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

    def __truediv__(self, other):
        if isinstance(other, Racional):
            if other.numerador == 0:
                raise ZeroDivisionError('No se puede dividir por cero')
            return Racional(self.__num * other.denominador,
                          self.__den * other.numerador)
        elif isinstance(other, int):
            if other == 0:
                raise ZeroDivisionError('No se puede dividir por cero')
            return Racional(self.__num, self.__den * other)
        return NotImplemented

    def __rtruediv__(self, other):
        if isinstance(other, int):
            return Racional(other) / self
        return NotImplemented

    # Unarios
    def __neg__(self):
        return Racional(-self.__num, self.__den)

    def __pos__(self):
        return Racional(self.__num, self.__den)

    def __abs__(self):
        return Racional(abs(self.__num), self.__den)

    # Conversión
    def __float__(self):
        return self.__num / self.__den

    def __int__(self):
        return self.__num // self.__den

    def __round__(self, n=0):
        return round(float(self), n)

    # Representación
    def __str__(self):
        if self.__den == 1:
            return str(self.__num)
        return f'{self.__num}/{self.__den}'

    def __repr__(self):
        return f'Racional({self.__num}, {self.__den})'

# Uso completo
r1 = Racional(3, 4)
r2 = Racional(1, 2)
r3 = Racional(6, 8)    # se simplifica a 3/4

print(f'r1 = {r1}')           # → 3/4
print(f'r2 = {r2}')           # → 1/2
print(f'r3 = {r3}')           # → 3/4 (simplificado)

print(f'\n--- Aritmética ---')
print(f'r1 + r2 = {r1 + r2}')    # → 5/4
print(f'r1 - r2 = {r1 - r2}')    # → 1/4
print(f'r1 * r2 = {r1 * r2}')    # → 3/8
print(f'r1 / r2 = {r1 / r2}')    # → 3/2
print(f'r1 + 1 = {r1 + 1}')      # → 7/4
print(f'2 * r1 = {2 * r1}')      # → 3/2 (reflejado)
print(f'1 - r1 = {1 - r1}')      # → 1/4 (__rsub__)

print(f'\n--- Comparación ---')
print(f'r1 == r3: {r1 == r3}')    # → True (3/4 == 3/4)
print(f'r1 > r2: {r1 > r2}')      # → True
print(f'r2 < r1: {r2 < r1}')      # → True

print(f'\n--- Unarios ---')
print(f'-r1 = {-r1}')             # → -3/4
print(f'abs(-r1) = {abs(-r1)}')   # → 3/4

print(f'\n--- Conversión ---')
print(f'float(r1) = {float(r1)}') # → 0.75
print(f'int(r1) = {int(r1)}')     # → 0
print(f'round(r1, 2) = {round(r1, 2)}')  # → 0.75

print(f'\n--- Desde float ---')
r4 = Racional.desde_float(0.75)
print(f'0.75 = {r4}')    # → 3/4

print(f'\n--- Ordenación ---')
racionales = [Racional(3,4), Racional(1,2), Racional(1,3), Racional(2,3)]
print(sorted(racionales))    # → [1/3, 1/2, 2/3, 3/4]
r1 = 3/4
r2 = 1/2
r3 = 3/4

--- Aritmética ---
r1 + r2 = 5/4
r1 - r2 = 1/4
r1 * r2 = 3/8
r1 / r2 = 3/2
r1 + 1 = 7/4
2 * r1 = 3/2
1 - r1 = 1/4

--- Comparación ---
r1 == r3: True
r1 > r2: True
r2 < r1: True

--- Unarios ---
-r1 = -3/4
abs(-r1) = 3/4

--- Conversión ---
float(r1) = 0.75
int(r1) = 0
round(r1, 2) = 0.75

--- Desde float ---
0.75 = 3/4

--- Ordenación ---
[1/3, 1/2, 2/3, 3/4]

Tres detalles importantes de esta implementación:

Primero — en __init__ normalizamos el signo y simplificamos con gcd. Así Racional(6, 8) y Racional(3, 4) son iguales desde el momento de la creación.

Segundo — __rsub__ no es simplemente self.__sub__(other) porque la resta no es conmutativa. 1 - r1 es Racional(1) - r1, no r1 - Racional(1).

Tercero — __float__, __int__ y __round__ permiten usar float(), int() y round() directamente sobre racionales — igual que con los tipos predefinidos.

Sobrecarga de operadores en Python práctica — Programa 2: Clase Dinero con conversión de moneda

from functools import total_ordering

@total_ordering
class Dinero:
    # Tasas de cambio respecto al euro (atributo de clase)
    _tasas = {
        'EUR': 1.0,
        'USD': 1.09,
        'GBP': 0.86,
        'JPY': 162.50,
        'CHF': 0.97
    }

    def __init__(self, cantidad, moneda='EUR'):
        if not isinstance(cantidad, (int, float)):
            raise TypeError('La cantidad debe ser numérica')
        if cantidad < 0:
            raise ValueError('La cantidad no puede ser negativa')
        if moneda not in Dinero._tasas:
            raise ValueError(f'Moneda no soportada: {moneda}. '
                           f'Disponibles: {list(Dinero._tasas.keys())}')
        self.__cantidad = round(float(cantidad), 2)
        self.__moneda = moneda

    @property
    def cantidad(self):
        return self.__cantidad

    @property
    def moneda(self):
        return self.__moneda

    def convertir(self, moneda_destino):
        """Convierte a otra moneda usando las tasas de cambio"""
        if moneda_destino not in Dinero._tasas:
            raise ValueError(f'Moneda no soportada: {moneda_destino}')
        # Convertir a EUR primero, luego a destino
        en_euros = self.__cantidad / Dinero._tasas[self.__moneda]
        en_destino = en_euros * Dinero._tasas[moneda_destino]
        return Dinero(round(en_destino, 2), moneda_destino)

    def _en_euros(self):
        """Valor en euros para comparaciones"""
        return self.__cantidad / Dinero._tasas[self.__moneda]

    @classmethod
    def actualizar_tasa(cls, moneda, tasa):
        """Actualiza la tasa de cambio de una moneda"""
        if tasa <= 0:
            raise ValueError('La tasa debe ser positiva')
        cls._tasas[moneda] = tasa

    # Comparación — compara valores en euros
    def __eq__(self, other):
        if isinstance(other, Dinero):
            return abs(self._en_euros() - other._en_euros()) < 0.001
        elif isinstance(other, (int, float)):
            return self._en_euros() == other
        return False

    def __lt__(self, other):
        if isinstance(other, Dinero):
            return self._en_euros() < other._en_euros()
        elif isinstance(other, (int, float)):
            return self._en_euros() < other
        return NotImplemented

    # Aritmética — opera en la moneda del operando izquierdo
    def __add__(self, other):
        if isinstance(other, Dinero):
            otro_convertido = other.convertir(self.__moneda)
            return Dinero(self.__cantidad + otro_convertido.cantidad,
                         self.__moneda)
        elif isinstance(other, (int, float)):
            return Dinero(self.__cantidad + other, self.__moneda)
        return NotImplemented

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

    def __sub__(self, other):
        if isinstance(other, Dinero):
            otro_convertido = other.convertir(self.__moneda)
            resultado = self.__cantidad - otro_convertido.cantidad
            if resultado < 0:
                raise ValueError('El resultado no puede ser negativo')
            return Dinero(resultado, self.__moneda)
        elif isinstance(other, (int, float)):
            resultado = self.__cantidad - other
            if resultado < 0:
                raise ValueError('El resultado no puede ser negativo')
            return Dinero(resultado, self.__moneda)
        return NotImplemented

    def __mul__(self, other):
        if isinstance(other, (int, float)):
            return Dinero(self.__cantidad * other, self.__moneda)
        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 Dinero(self.__cantidad / other, self.__moneda)
        elif isinstance(other, Dinero):
            # División de dinero → ratio sin moneda
            otro_convertido = other.convertir(self.__moneda)
            return self.__cantidad / otro_convertido.cantidad
        return NotImplemented

    def __neg__(self):
        raise ValueError('El dinero no puede ser negativo')

    def __abs__(self):
        return Dinero(abs(self.__cantidad), self.__moneda)

    def __str__(self):
        simbolos = {'EUR': '€', 'USD': '$', 'GBP': '£',
                   'JPY': '¥', 'CHF': 'Fr'}
        simbolo = simbolos.get(self.__moneda, self.__moneda)
        return f'{self.__cantidad:.2f}{simbolo}'

    def __repr__(self):
        return f'Dinero({self.__cantidad}, "{self.__moneda}")'

# Uso completo
print('=== CLASE DINERO ===\n')

precio_eur = Dinero(100, 'EUR')
precio_usd = Dinero(50, 'USD')
precio_gbp = Dinero(80, 'GBP')

print(f'Precio EUR: {precio_eur}')
print(f'Precio USD: {precio_usd}')
print(f'Precio GBP: {precio_gbp}')

print(f'\n--- Conversión ---')
print(f'{precio_usd} → EUR: {precio_usd.convertir("EUR")}')
print(f'{precio_gbp} → USD: {precio_gbp.convertir("USD")}')
print(f'{precio_eur} → JPY: {precio_eur.convertir("JPY")}')

print(f'\n--- Aritmética ---')
total = precio_eur + precio_usd    # USD se convierte a EUR
print(f'{precio_eur} + {precio_usd} = {total}')

descuento = precio_eur * 0.10
print(f'{precio_eur} * 0.10 = {descuento}')

precio_final = precio_eur - descuento
print(f'{precio_eur} - {descuento} = {precio_final}')

cuotas = precio_eur / 3
print(f'{precio_eur} / 3 = {cuotas}')

ratio = precio_eur / precio_usd    # cuántos USD caben en 100€
print(f'{precio_eur} / {precio_usd} = {ratio:.2f}x')

print(f'\n--- Comparación ---')
print(f'{precio_eur} == {precio_usd}: {precio_eur == precio_usd}')
print(f'{precio_eur} > {precio_usd}: {precio_eur > precio_usd}')

print(f'\n--- Ordenación ---')
precios = [Dinero(80, 'GBP'), Dinero(50, 'USD'),
           Dinero(100, 'EUR'), Dinero(10000, 'JPY')]
for p in sorted(precios):
    print(f'  {p} = {p.convertir("EUR")}')
=== CLASE DINERO ===

Precio EUR: 100.00€
Precio USD: 50.00$
Precio GBP: 80.00£

--- Conversión ---
50.00$ → EUR: 45.87€
80.00£ → USD: 101.51$
100.00€ → JPY: 16250.00¥

--- Aritmética ---
100.00€ + 50.00$ = 145.87€
100.00€ * 0.10 = 10.00€
100.00€ - 10.00€ = 90.00€
100.00€ / 3 = 33.33€
100.00€ / 50.00$ = 1.83x

--- Comparación ---
100.00€ == 50.00$: False
100.00€ > 50.00$: True

--- Ordenación ---
  50.00$ = 45.87€
  10000.00¥ = 61.54€
  80.00£ = 93.02€
  100.00€ = 100.00€

Tres detalles clave de Dinero:

Primero — las comparaciones convierten siempre a euros antes de comparar. Así 100€ > 50$ funciona correctamente sin importar la moneda.

Segundo — la suma convierte el operando derecho a la moneda del izquierdo. El resultado siempre tiene la moneda del operando izquierdo.

Tercero — precio / precio devuelve un número sin moneda — el ratio entre dos cantidades. precio / número devuelve Dinero — dividir en cuotas.

Resumen y siguiente paso

En este artículo construiste dos clases con sobrecarga completa de operadores. Racional demuestra el patrón clásico de FP2 con simplificación automática y operadores reflejados no conmutativos. Dinero muestra cómo los operadores pueden incorporar lógica de negocio real, conversión de moneda transparente al usar + o >.

En el siguiente artículo encontrarás ejercicios propuestos con solución.

Siguiente paso: ejerciciosSobrecarga de operadores en Python — ejercicios para dominar los métodos mágicos

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

2 comentarios

Deja una respuesta

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