Sobrecarga de operadores en Python — teoría con Vector2D: __eq__, __add__, __str__, operadores reflejados y @total_ordering (y los dos vectores iguales que Python decía que no lo eran)

Preparando un práctico de FP2 escribí una clase Vector2D, creé dos vectores con las mismas coordenadas y, para comprobar que todo iba bien, los comparé. Python me contestó que no eran iguales. Después intenté sumarlos:

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


v1 = Vector2D(3, 4)
v2 = Vector2D(3, 4)
print(v1 == v2)        # mismas coordenadas…
print(v1 is v2)
print(v1 + v2)

Salida:

False
False
Traceback (most recent call last):
  File "gancho.py", line 11, in <module>
    print(v1 + v2)
          ~~~^~~~
TypeError: unsupported operand type(s) for +: 'Vector2D' and 'Vector2D'

¿Cómo que (3, 4) no es igual a (3, 4)? Para Python no lo es: sin instrucciones, == compara si los dos nombres apuntan al mismo objeto (como is), y + no sabe qué significa sumar dos objetos de una clase que se acaba de inventar. Hay que enseñárselo, y eso es la sobrecarga de operadores: escribir en tu clase los métodos mágicos (__eq__, __add__, __lt__…) que Python llama cuando usas ==, + o < con tus objetos.

Es lo que se prometió en la teoría de clases y en la práctica de excepciones: __add__ y __eq__ llegaban «en el tema de sobrecarga». Y viene justo después de herencia en Python porque, en el fondo, sobrecargar un operador es sustituir un método que tu clase hereda de object.

En este artículo construimos Vector2D paso a paso y vemos todo lo que entra en FP2: __str__ frente a __repr__, __eq__ y el __hash__ que desaparece, @total_ordering, operadores aritméticos, NotImplemented, operadores reflejados (__radd__, __rmul__, __rsub__), operadores unarios, += con y sin __iadd__ y por qué en Python no existe la sobrecarga de funciones.

—

¿Qué es la sobrecarga de operadores?

En Python los operadores ya están sobrecargados de serie: + suma números, pero también une cadenas y listas. El mismo símbolo hace cosas distintas según el tipo. La razón es que un operador es una llamada a un método:

print(3 + 5)                    # suma de enteros
print('Hola' + ' mundo')        # concatenación de cadenas
print([1, 2] + [3, 4])          # concatenación de listas
print((3).__add__(5))           # lo que Python hace por dentro con 3 + 5
print('Hola'.__add__(' mundo'))
print('__add__' in dir(int), '__add__' in dir(str), '__add__' in dir(dict))

Salida:

8
Hola mundo
[1, 2, 3, 4]
8
Hola mundo
True True False

3 + 5 es, por dentro, (3).__add__(5): el método __add__ del entero 3. Las cadenas tienen su propio __add__, que concatena. Los diccionarios no tienen __add__, y por eso {} + {} da TypeError. Sobrecargar un operador en tu clase es escribir ese método: si Vector2D tiene __add__, v + w funciona.

EscribesPython llamaMétodo mágico
a + ba.__add__(b)suma
a - ba.__sub__(b)resta
a * ba.__mul__(b)multiplicación
a / ba.__truediv__(b)división
a == ba.__eq__(b)igualdad
a < ba.__lt__(b)menor que
-aa.__neg__()negación
abs(a)a.__abs__()valor absoluto
len(a)a.__len__()longitud
x in aa.__contains__(x)pertenencia
print(a)a.__str__()texto para personas

En los apuntes de FP2 lo verás comparado con Java, y ahí hay una diferencia que cae en el test: Java no permite sobrecargar operadores. Para comparar objetos en Java se implementa equals o la interfaz Comparable; en Python, __eq__ y __lt__.

—

La clase Vector2D: punto de partida

Un vector del plano tiene dos coordenadas y es perfecto para el tema: se suma, se resta, se multiplica por un número, tiene longitud (módulo) y se compara. Lo primero que conviene darle es una forma de imprimirse, porque si no, cada prueba saca <__main__.Vector2D object at 0x…>. Para eso hay dos métodos, y confundirlos es uno de los fallos del tema:

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

    def __str__(self):                  # para personas: print(), str(), f-strings
        return f'({self.x}, {self.y})'

    def __repr__(self):                 # para programadores: listas, consola, depurar
        return f'Vector2D({self.x!r}, {self.y!r})'


v = Vector2D(3, 4)
print(v)
print(str(v), repr(v))
print([v, Vector2D(1, 2)])              # una lista muestra el repr de cada elemento
print(f'v = {v}  ->  {v!r}')

Salida:

(3, 4)
(3, 4) Vector2D(3, 4)
[Vector2D(3, 4), Vector2D(1, 2)]
v = (3, 4)  ->  Vector2D(3, 4)
  • __str__ es el texto para personas: lo usan print, str() y las f-strings normales. Aquí, (3, 4).
  • __repr__ es el texto para programadores: lo usan la consola, repr(), !r en las f-strings y, sobre todo, las listas, tuplas y diccionarios cuando se imprimen. La costumbre es que parezca el código que crea el objeto: Vector2D(3, 4).

Fíjate en la tercera línea: al imprimir una lista, Python usa el repr de cada elemento, no el str. Por eso, si solo escribes uno, que sea __repr__: cuando falta __str__, print usa __repr__; al revés, no.

Escribir solo __str__ y extrañarse de que print([v, w]) siga sacando <__main__.Vector2D object at 0x…>. Las colecciones usan __repr__.

—

Operadores de comparación

__eq__ y NotImplemented

Para que == compare coordenadas, Vector2D necesita __eq__. Recibe self (el de la izquierda) y el otro objeto (el de la derecha) y devuelve True o False:

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

    def __repr__(self):
        return f'Vector2D({self.x}, {self.y})'

    def __eq__(self, otro):
        if not isinstance(otro, Vector2D):
            return NotImplemented           # «no sé compararme con eso»
        return self.x == otro.x and self.y == otro.y


v = Vector2D(3, 4)
print(v == Vector2D(3, 4), v is Vector2D(3, 4))
print(v != Vector2D(1, 2))                  # __ne__ sale solo de __eq__
print(v == (3, 4), v == 'hola')             # tipos distintos: False, sin error
print(Vector2D(3, 4) in [Vector2D(1, 2), Vector2D(3, 4)])
print(Vector2D.__hash__)                    # definir __eq__ quita el hash
try:
    print({v})
except TypeError as err:
    print('TypeError:', err)

Salida:

True False
True
False False
True
None
TypeError: unhashable type: 'Vector2D'

Lo importante está en la primera línea de __eq__: si el otro no es un Vector2D, devolvemos NotImplemented. No es False ni un error: es la forma de decirle a Python «yo no sé compararme con eso». Python entonces le pregunta al otro objeto y, si tampoco sabe, decide que no son iguales. Por eso v == (3, 4) y v == 'hola' dan False sin explotar.

¿Y si no comprobamos el tipo?

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

    def __eq__(self, otro):                 # sin comprobar el tipo
        return self.x == otro.x and self.y == otro.y


v = Vector2D(3, 4)
print(v == Vector2D(3, 4))
print(v == 5)

Salida:

True
Traceback (most recent call last):
  File "eq_mal.py", line 12, in <module>
    print(v == 5)
          ^^^^^^
  File "eq_mal.py", line 7, in __eq__
    return self.x == otro.x and self.y == otro.y
                     ^^^^^^
AttributeError: 'int' object has no attribute 'x'

Con otro vector funciona, pero v == 5 intenta leer 5.x y revienta con AttributeError. Comparar con == nunca debería lanzar un error: por eso el isinstance va siempre lo primero.

Gratis con __eq__: Python crea != como su negación (lo hace object, no hace falta __ne__), y también funcionan in en listas, list.count, list.index y list.remove, que comparan con ==. Fíjate en el True de la cuarta línea: in encontró el Vector2D(3, 4) de la lista.

El __hash__ que desaparece

La salida tiene una sorpresa al final: Vector2D.__hash__ vale None y meter el vector en un conjunto da TypeError: unhashable type. Al definir __eq__, Python quita el __hash__ heredado de object, porque el de object se basa en la identidad y ya no encajaría con tu igualdad. Si quieres usar tus objetos en un set o como claves de un diccionario, escribe un __hash__ coherente con __eq__: dos objetos iguales deben tener el mismo hash. La forma fácil es hacer el hash de una tupla con los mismos datos que compara __eq__:

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

    def __repr__(self):
        return f'Vector2D({self.x}, {self.y})'

    def __eq__(self, otro):
        if not isinstance(otro, Vector2D):
            return NotImplemented
        return (self.x, self.y) == (otro.x, otro.y)

    def __hash__(self):                      # iguales -> mismo hash
        return hash((self.x, self.y))


puntos = {Vector2D(3, 4), Vector2D(3, 4), Vector2D(1, 2)}
print(len(puntos), Vector2D(3, 4) in puntos)

Salida:

2 True

El conjunto se queda con 2 elementos: los dos Vector2D(3, 4) son iguales y tienen el mismo hash, así que cuentan como uno.

Calcular el hash con datos que pueden cambiar (un objeto mutable dentro de un conjunto al que luego le cambias x). El conjunto lo guardó en el «cajón» del hash viejo y ya no lo encuentra. Usa __hash__ solo en objetos que no vas a modificar mientras estén dentro.

—

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

Para comparar con <, <=, > y >= harían falta cuatro métodos (__lt__, __le__, __gt__, __ge__). El decorador @total_ordering del módulo functools te deja escribir __eq__ y uno de los cuatro, y genera el resto. Ordenamos los vectores por su longitud (el módulo):

from functools import total_ordering
import math


@total_ordering                              # completa <=, > y >= a partir de == y <
class Vector2D:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f'Vector2D({self.x}, {self.y})'

    def modulo(self):
        return math.hypot(self.x, self.y)    # raíz de x² + y²

    def __eq__(self, otro):
        if not isinstance(otro, Vector2D):
            return NotImplemented
        return (self.x, self.y) == (otro.x, otro.y)

    def __lt__(self, otro):                  # «menor» = más corto
        if not isinstance(otro, Vector2D):
            return NotImplemented
        return self.modulo() < otro.modulo()


a, b, c = Vector2D(3, 4), Vector2D(1, 2), Vector2D(0, 6)
print(a < c, a > b, a <= Vector2D(4, 3), c >= a)
print(sorted([a, b, c]))
print(max([a, b, c]), min([a, b, c]))
print([m for m in ('__le__', '__gt__', '__ge__') if m in Vector2D.__dict__])

Salida:

True True False True
[Vector2D(1, 2), Vector2D(3, 4), Vector2D(0, 6)]
Vector2D(0, 6) Vector2D(1, 2)
['__le__', '__gt__', '__ge__']

Solo hay __eq__ y __lt__, y sin embargo funcionan >, <= y >=, sorted, max y min. La última línea lo confirma: el decorador añadió __le__, __gt__ y __ge__ a la clase. El dibujo resume quién escribe cada método:

Métodos de comparación: cuáles escribes y cuáles se generanTabla: __eq__ y __lt__ los escribe el programador; __ne__ lo da object; __le__, __gt__ y __ge__ los genera @total_ordering con sus reglas. Aviso: con un orden por módulo, dos vectores distintos del mismo módulo no cumplen <=.Con @total_ordering escribes dos y tienes seis__eq__a == blo escribe: túx e y iguales__lt__a < blo escribe: túmodulo() menor__ne__a != blo escribe: objectnot (a == b)__le__a <= blo escribe: @total_orderinga < b or a == b__gt__a > blo escribe: @total_orderingnot (a < b) and a != b__ge__a >= blo escribe: @total_orderingnot (a < b)Vector2D(3, 4) <= Vector2D(4, 3) → Falsemismo módulo (5): ni < ni == → <= da False. Ordena por un dato coherente con __eq__.
Violeta: lo escribes tú. Coral: lo genera el decorador a partir de < y ==. Si < y == no cuentan la misma historia, el orden sale raro.

Y ahora la trampa: a <= Vector2D(4, 3) es False. Los dos miden 5, pero no son iguales (tienen coordenadas distintas), así que ni < ni == son ciertos, y <= se construye con esas dos piezas. Si tu < y tu == no miran lo mismo, el orden sale raro. Con números, fracciones o dinero no pasa, porque «igual» y «ni menor ni mayor» coinciden.

Para el examen: @total_ordering necesita __eq__ + uno de __lt__, __le__, __gt__, __ge__. Lo más natural es __eq__ y __lt__. Y sorted() y list.sort() solo necesitan __lt__, incluso sin decorador.

—

Operadores aritméticos binarios

Un operador binario tiene dos operandos: self a la izquierda y el otro a la derecha. Cada método devuelve un objeto nuevo y no toca los originales:

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

    def __repr__(self):
        return f'Vector2D({self.x}, {self.y})'

    def __add__(self, otro):                     # v + w
        if isinstance(otro, Vector2D):
            return Vector2D(self.x + otro.x, self.y + otro.y)
        return NotImplemented

    def __sub__(self, otro):                     # v - w
        if isinstance(otro, Vector2D):
            return Vector2D(self.x - otro.x, self.y - otro.y)
        return NotImplemented

    def __mul__(self, otro):                     # v * número  o  v * w (producto escalar)
        if isinstance(otro, (int, float)):
            return Vector2D(self.x * otro, self.y * otro)
        if isinstance(otro, Vector2D):
            return self.x * otro.x + self.y * otro.y
        return NotImplemented

    def __truediv__(self, numero):               # v / número
        if isinstance(numero, (int, float)):
            return Vector2D(self.x / numero, self.y / numero)
        return NotImplemented


v, w = Vector2D(3, 4), Vector2D(1, 2)
print(v + w, v - w)
print(v * 2, v * w, v / 2)
print(v + w + Vector2D(10, 10))                  # (v + w) + …: cada + devuelve un Vector2D
print(v, w)                                      # los originales no cambian
try:
    print(v + 2)
except TypeError as err:
    print('TypeError:', err)

Salida:

Vector2D(4, 6) Vector2D(2, 2)
Vector2D(6, 8) 11 Vector2D(1.5, 2.0)
Vector2D(14, 16)
Vector2D(3, 4) Vector2D(1, 2)
TypeError: unsupported operand type(s) for +: 'Vector2D' and 'int'

Algunas decisiones que conviene ver:

  • v * 2 y v * w usan el mismo __mul__, que mira el tipo del otro: con un número escala el vector; con otro vector devuelve el producto escalar, 3 · 1 + 4 · 2 = 11 (un número, no un vector).
  • v + w + Vector2D(10, 10) funciona porque v + w devuelve un Vector2D, y a ese se le suma el siguiente: (4, 6) + (10, 10) = (14, 16).
  • v y w siguen valiendo lo mismo al final: los operadores aritméticos crean objetos nuevos, igual que 3 + 5 no cambia el 3.
  • v + 2 acaba en un TypeError con un mensaje claro. Es el efecto de return NotImplemented: Python prueba con el otro operando, tampoco sabe, y lanza el error él mismo.

Lanzar tú el TypeError (o devolver None, False o un texto) cuando el tipo no encaja. Si lanzas el error, le quitas a Python la oportunidad de preguntar al otro objeto; si devuelves None, v + 'hola' «funciona» y devuelve None sin avisar. Lo verás en el «¿Qué falla aquí?» de los ejercicios.

—

Operadores unarios

Los unarios actúan sobre un solo operando: -v llama a __neg__, +v a __pos__ y abs(v) a __abs__. Y uno muy útil que no parece un operador: __bool__, que decide si el objeto es «verdadero» en un if.

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

    def __repr__(self):
        return f'Vector2D({self.x}, {self.y})'

    def __neg__(self):                       # -v
        return Vector2D(-self.x, -self.y)

    def __abs__(self):                       # abs(v): el módulo
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __bool__(self):                      # if v: … (el vector cero es «falso»)
        return self.x != 0 or self.y != 0


v = Vector2D(3, 4)
print(-v, abs(v), abs(-v))
for u in [v, Vector2D(0, 0)]:
    print(u, 'es verdadero' if u else 'es falso')

Salida:

Vector2D(-3, -4) 5.0 5.0
Vector2D(3, 4) es verdadero
Vector2D(0, 0) es falso

abs(v) es el módulo: la raíz de 3² + 4² = 25, es decir, 5,0. Y el vector cero es «falso», como el número 0 o la lista vacía. Si una clase no tiene __bool__ pero sí __len__, Python usa len(obj) != 0; si no tiene ninguno, todo objeto es verdadero.

—

Operadores reflejados: el concepto que más confunde

v * 2 funciona. ¿Y 2 * v? Ahí el que está a la izquierda es el entero 2, y int no sabe multiplicar vectores. Lo mismo le pasa a sum(), que empieza sumando 0 + primer_elemento:

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

    def __repr__(self):
        return f'Vector2D({self.x}, {self.y})'

    def __add__(self, otro):
        if isinstance(otro, Vector2D):
            return Vector2D(self.x + otro.x, self.y + otro.y)
        return NotImplemented

    def __mul__(self, otro):
        if isinstance(otro, (int, float)):
            return Vector2D(self.x * otro, self.y * otro)
        return NotImplemented


v = Vector2D(3, 4)
print(v * 2)
for orden in ['2 * v', 'sum([v, v, v])']:
    try:
        print(orden, '->', eval(orden))
    except TypeError as err:
        print(orden, '-> TypeError:', err)


class Vector2DBien(Vector2D):                # hereda todo y añade los reflejados
    def __rmul__(self, otro):                # otro * self (el vector está a la derecha)
        return self * otro                   # el producto por un número es conmutativo

    def __radd__(self, otro):                # para sum(): empieza sumando 0 + primero
        if otro == 0:
            return self
        return NotImplemented


v = Vector2DBien(3, 4)
print('2 * v ->', 2 * v)
print('sum ->', sum([v, Vector2DBien(1, 2), Vector2DBien(10, 10)]))

Salida:

Vector2D(6, 8)
2 * v -> TypeError: unsupported operand type(s) for *: 'int' and 'Vector2D'
sum([v, v, v]) -> TypeError: unsupported operand type(s) for +: 'int' and 'Vector2D'
2 * v -> Vector2D(6, 8)
sum -> Vector2D(14, 16)

Cuando el de la izquierda devuelve NotImplemented, Python no se rinde todavía: busca en el de la derecha el método reflejado, que se llama igual con una r delante (__rmul__, __radd__, __rsub__, __rtruediv__…). En 2 * v, Python prueba (2).__mul__(v), recibe NotImplemented y llama a v.__rmul__(2). El dibujo sigue los tres casos:

Cómo traduce Python un operadorTres expresiones: v + w lo resuelve __add__ de v; v + 2 recibe NotImplemented de v y del 2 y acaba en TypeError; 2 * v recibe NotImplemented del 2 y lo resuelve __rmul__ de v.Lo que hace Python con «a op b»: primero a, después bv + w1.º izquierdav.__add__(w)Vector2D(4, 6)v + 21.º izquierdav.__add__(2)NotImplemented2.º derecha(2).__radd__(v)NotImplementedTypeError2 * v1.º izquierda(2).__mul__(v)NotImplemented2.º derechav.__rmul__(2)Vector2D(6, 8)Si un método devuelve NotImplemented, Python prueba el siguiente; si los dos lo devuelven, TypeError.
El operando de la izquierda tiene la primera palabra; el de la derecha, la segunda (los métodos con r). Con los datos de la teoría.

Vector2DBien (que hereda de Vector2D, como vimos en el tema anterior) añade dos reflejados:

  • __rmul__ devuelve self * otro: la multiplicación por un número es conmutativa, así que basta con reutilizar __mul__.
  • __radd__ solo acepta el 0, que es lo que necesita sum(): 0 + v1 = v1, y desde ahí sum sigue con v1 + v2, que ya es una suma normal de vectores.

Los que no conmutan: __rsub__ y __rtruediv__

Con la resta y la división cuidado: 10 - x no es x - 10. En __rsub__, self es el que está a la derecha de la resta:

class Euros:
    def __init__(self, cantidad):
        self.cantidad = cantidad

    def __repr__(self):
        return f'{self.cantidad} €'

    def __sub__(self, otro):                 # euros - número
        if isinstance(otro, (int, float)):
            return Euros(self.cantidad - otro)
        return NotImplemented


class EurosMal(Euros):
    def __rsub__(self, otro):                # otro - self… calculado al revés
        return self - otro


class EurosBien(Euros):
    def __rsub__(self, otro):                # otro - self, en ese orden
        return Euros(otro - self.cantidad)


print('Euros(3) - 10  =', Euros(3) - 10)
print('10 - EurosMal(3)  =', 10 - EurosMal(3))
print('10 - EurosBien(3) =', 10 - EurosBien(3))

Salida:

Euros(3) - 10  = -7 €
10 - EurosMal(3)  = -7 €
10 - EurosBien(3) = 7 €

EurosMal copia la idea del __rmul__ (return self - otro) y calcula 3 − 10 = −7 €, cuando 10 - EurosMal(3) debería dar 7 €. EurosBien hace la cuenta en el orden correcto: otro - self.cantidad.

Escribir __rsub__ o __rtruediv__ como return self - otro o return self / otro, igual que los conmutativos. El resultado sale con el signo cambiado (o invertido, en la división). En la práctica lo verás con fracciones: 1 - 3/4 debe dar 1/4, no -1/4.

ExpresiónPrimer intentoSi devuelve NotImplemented
v + wv.__add__(w)w.__radd__(v)
2 * v(2).__mul__(v)v.__rmul__(2)
10 - e(10).__sub__(e)e.__rsub__(10) → 10 - e, en ese orden
sum([v, w])0 + v → (0).__add__(v)v.__radd__(0)

—

+= y compañía: __iadd__

¿Qué hace v += w? Depende de si la clase tiene __iadd__:

  • Sin __iadd__, Python lo traduce a v = v + w: llama a __add__, crea un objeto nuevo y cambia a dónde apunta v.
  • Con __iadd__, llama a v.__iadd__(w), que modifica el propio objeto y tiene que devolverlo (return self).

La diferencia solo se nota cuando hay otro nombre apuntando al mismo objeto:

class Vector2D:                       # inmutable: solo __add__
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f'Vector2D({self.x}, {self.y})'

    def __add__(self, otro):
        return Vector2D(self.x + otro.x, self.y + otro.y)


class Posicion(Vector2D):              # mutable: añade __iadd__
    def __iadd__(self, otro):
        self.x += otro.x               # cambia ESTE objeto…
        self.y += otro.y
        return self                    # …y lo devuelve: obligatorio


for Clase in [Vector2D, Posicion]:
    v = Clase(1, 2)
    alias = v                          # otro nombre para el mismo objeto
    v += Vector2D(10, 10)
    print(f'{Clase.__name__:<9} v = {v}  alias = {alias}  mismo objeto: {v is alias}')

Salida:

Vector2D  v = Vector2D(11, 12)  alias = Vector2D(1, 2)  mismo objeto: False
Posicion  v = Vector2D(11, 12)  alias = Vector2D(11, 12)  mismo objeto: True
v += w con y sin __iadd__Izquierda: sin __iadd__, += usa __add__, crea un Vector2D(11, 12) y solo v apunta a él; alias sigue en Vector2D(1, 2). Derecha: con __iadd__ se modifica el mismo objeto y v y alias lo comparten.Vector2D: solo __add__v += Vector2D(10, 10)valiasVector2D(11, 12)Vector2D(1, 2)v apunta a un objeto NUEVO; alias sigue en el viejov is alias → FalsePosicion: con __iadd__v += Vector2D(10, 10)valiasPosicion: x=11, y=12el MISMO objeto cambia; los dos nombres lo venv is alias → True
Sin __iadd__, v += w es v = v + w: un objeto nuevo. Con __iadd__ (y su return self), se cambia el que ya había.

Con Vector2D, alias sigue valiendo (1, 2): v pasó a apuntar a un vector nuevo. Con Posicion, los dos nombres ven el (11, 12), porque es el mismo objeto modificado. Es lo mismo que pasa con los tipos de Python: con una tupla, t += (1,) crea una tupla nueva; con una lista, l += [1] amplía la misma lista y lo ven todos sus alias.

(¿Por qué la Posicion se imprime como Vector2D(11, 12)? Porque __repr__ escribe 'Vector2D' a mano en la madre. En el tema de herencia viste el arreglo: type(self).__name__.)

Escribir __iadd__ y olvidar el return self. Python asigna a v lo que devuelve el método, así que v pasa a valer None y el error sale en la línea siguiente ('NoneType' object has no attribute …).

—

La clase Vector2D completa

Juntando las piezas, esto es lo que suele llevar una clase «numérica» como Vector2D, y cuándo merece la pena cada cosa:

MétodoPara qué¿Imprescindible?
__repr__ (y __str__)Imprimir y depurarSí, siempre
__eq__==, !=, inSi tiene sentido comparar por valor
__hash__Conjuntos y claves de diccionarioSolo si los objetos no cambian
__lt__ + @total_ordering<, <=, >, >=, sorted, maxSi hay un orden natural
__add__, __sub__, __mul__, __truediv__AritméticaSi las operaciones tienen sentido
__radd__, __rmul__, __rsub__, __rtruediv__Número a la izquierda, sum()Si se mezcla con números
__neg__, __abs__, __bool__-v, abs(v), if v:Según el caso
__iadd__+= modificando el objetoSolo en clases mutables

La regla de oro de la sobrecarga: que el operador haga lo que cualquiera esperaría. Un + que borra cosas o un < que compara algo extraño compila igual, pero confunde a quien lea tu código. Si la operación no es evidente, mejor un método con nombre (v.rotar(90)).

—

Sobrecarga de funciones: polimorfismo con parámetros

En Java (y en C++) se puede escribir varias veces un método con el mismo nombre y distintos parámetros: es la sobrecarga de métodos. En Python no existe: un segundo def con el mismo nombre sustituye al primero.

def area(lado):
    return lado * lado


def area(base, altura):                # NO sobrecarga: sustituye a la de arriba
    return base * altura


print(area(3, 4))
try:
    print(area(3))
except TypeError as err:
    print('TypeError:', err)


def area_bien(base, altura=None):      # un parámetro opcional hace las dos cosas
    if altura is None:
        return base * base             # cuadrado
    return base * altura               # rectángulo


print(area_bien(3), area_bien(3, 4))

Salida:

12
TypeError: area() missing 1 required positional argument: 'altura'
9 12

area(3, 4) funciona porque la segunda definición pisó a la primera, y area(3) da TypeError: la de un parámetro ya no existe. En Python se consigue lo mismo con una sola función que admite varias formas de llamarla: parámetros con valor por defecto (altura=None), *args o comprobar el tipo con isinstance dentro. Y en las clases, el polimorfismo que viste en herencia: el mismo método en clases distintas.

«Sobrecarga de operadores» y «sobrecarga de métodos» suenan parecido, pero son cosas distintas. La primera (dar significado a + en tu clase) existe en Python; la segunda (varios métodos con el mismo nombre y distinta firma), no.

—

Errores típicos con la sobrecarga de operadores

__eq__ sin isinstance: v == 5 lanza AttributeError en lugar de dar False.

Definir __eq__ y querer meter los objetos en un set: TypeError: unhashable type. Falta __hash__.

Devolver False, None o lanzar TypeError cuando el tipo no encaja, en vez de return NotImplemented.

Olvidar los reflejados: v * 2 funciona y 2 * v no; sum() de tus objetos falla con 'int' and 'Vector2D'.

__rsub__ escrito como self - otro: el resultado sale con el signo cambiado.

__iadd__ sin return self: la variable pasa a valer None.

Escribir solo __str__: las listas siguen saliendo con <… object at 0x…>.

—

Míralo paso a paso en Python Tutor

pythontutor.com dibuja los objetos y la pila de llamadas, y ahí se ve que un operador es una llamada a un método. Elige Python 3 y pega:

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

    def __add__(self, otro):
        return Vector2D(self.x + otro.x, self.y + otro.y)

    def __mul__(self, n):
        return Vector2D(self.x * n, self.y * n)

    def __rmul__(self, n):
        return self * n


v = Vector2D(3, 4)
w = v + Vector2D(1, 2)
z = 2 * v
print(w.x, w.y, z.x, z.y)

Salida:

4 6 6 8

Qué observar paso a paso:

  1. En la línea w = v + Vector2D(1, 2) verás aparecer un marco de __add__ en la pila, con self apuntando a v y otro al vector nuevo. El + se convirtió en una llamada.
  2. Dentro de __add__ se crea un tercer objeto, el resultado. Fíjate en que v no cambia.
  3. En z = 2 * v, Python no entra en ningún método del 2 (los enteros no salen en la pila): salta directamente a __rmul__ de v, con n valiendo 2.
  4. Desde __rmul__ aparece un segundo marco, el de __mul__, por el return self * n. Dos marcos apilados para un solo *.

—

Resumen rápido

Quiero…EscriboDetalle
Imprimir bonito__str__ y __repr__Las listas usan __repr__
== por valor__eq__ + isinstancereturn NotImplemented si el tipo no encaja
Usar el objeto en un set__hash__Iguales → mismo hash
Ordenar__eq__ + __lt__ + @total_orderingEl decorador genera <=, >, >=
+, -, *, /__add__, __sub__, __mul__, __truediv__Devuelven un objeto nuevo
Número a la izquierda__radd__, __rmul__, __rsub__, __rtruediv__Resta y división no conmutan
+= que modifica__iadd__Siempre return self
Varias «versiones» de una funciónParámetros por defectoEn Python no hay sobrecarga de funciones

Tres ideas para quedarte:

  1. Un operador es una llamada a un método: a + b es a.__add__(b). Sobrecargar es escribir ese método.
  2. NotImplemented no es un error, es «pregunta al otro»: así funcionan los reflejados y los mensajes de error claros.
  3. En los reflejados, self está a la derecha: la suma y el producto conmutan; la resta y la división, no.

En la práctica de sobrecarga de operadores construimos tres clases completas: fracciones que se simplifican solas, dinero sin errores de céntimos (y con conversión de moneda) y una estantería de la biblioteca que se usa como una lista.

Siguiente paso: prácticaSobrecarga de operadores en Python práctica — fracciones que se simplifican solas, dinero sin errores de céntimos y la estantería de la biblioteca (y el bote de la cena que no cuadraba por un céntimo)

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

Sigue aprendiendo

Deja una respuesta

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