Saltar al contenido
metodos-especiales.py · devschool

Métodos especiales en Python

Lección 26 de 31 · 14 min de lectura · Actualizado el

En esta lección
  1. Qué son y quién los llama
  2. Representar el objeto: __str__ y __repr__
  3. Igualdad: __eq__ y __hash__
  4. Ordenar: __lt__ y total_ordering
  5. Operadores: __add__, __radd__ y compañía
  6. Verdadero o falso: __bool__ y __len__
  7. Contenedores: __getitem__, __iter__ y __contains__
  8. Gestores de contexto: __enter__ y __exit__
  9. Dataclasses: métodos especiales gratis
  10. Ejemplo completo: el inventario de una tienda
  11. Errores frecuentes
  12. Resumen

Cuando escribes len(lista), a + b o for x in algo, Python no hace magia: llama por debajo a unos métodos con nombres como __len__, __add__ o __iter__. Son los métodos especiales (también llamados dunder methods, de double underscore, o “métodos mágicos”). Si los defines en tus clases, tus objetos se comportan igual que los tipos de Python: se imprimen bien, se comparan, se suman, se recorren o se usan con with. En herencia viste un primer vistazo; en esta lección los verás a fondo, con sus trampas y con la forma rápida de conseguir muchos de ellos: las dataclasses con order=True.

Qué son y quién los llama

Un método especial es un método normal con un nombre reservado. Tú no lo llamas directamente: lo llama Python cuando usas un operador o una función integrada.

Por ejemplo, len(x) ejecuta x.__len__(), a + b ejecuta a.__add__(b) y x[i] ejecuta x.__getitem__(i). Puedes comprobarlo con un tipo que ya conoces:

nombres = ["Ana", "Luis", "Marta"]
print(len(nombres), nombres.__len__())   # 3 3
print(nombres[1], nombres.__getitem__(1))   # Luis Luis
print((5).__add__(3))   # 8

Esto no significa que debas escribir nombres.__len__() en tu código: usa siempre len(nombres). La idea es entender que, si tu clase tiene __len__, len() funcionará con ella.

Representar el objeto: __str__ y __repr__

Ya los viste en clases y objetos: __str__ da el texto para el usuario (lo usan print() y los f-strings) y __repr__ el texto para el programador (lo usan repr(), la consola y las listas al imprimirse). La convención es que __repr__ se parezca al código que crea el objeto, como Producto('C-70', 'Cuerda 70 m', 149.0), para que al depurar sepas exactamente qué tienes delante. Si solo vas a escribir uno, que sea __repr__: cuando falta __str__, Python lo usa también para print().

Igualdad: __eq__ y __hash__

Por defecto, == entre dos objetos de tu clase solo es True si son el mismo objeto. Normalmente quieres comparar por contenido: dos productos con el mismo código son el mismo producto.

class Producto:
    def __init__(self, codigo, nombre, precio):
        self.codigo = codigo
        self.nombre = nombre
        self.precio = precio

    def __repr__(self):
        return f"Producto({self.codigo!r}, {self.nombre!r}, {self.precio})"

    def __eq__(self, otro):
        if not isinstance(otro, Producto):
            return NotImplemented
        return self.codigo == otro.codigo

    def __hash__(self):
        return hash(self.codigo)

cuerda = Producto("C-70", "Cuerda 70 m", 149.0)
cuerda_oferta = Producto("C-70", "Cuerda 70 m", 119.0)
print(cuerda == cuerda_oferta)   # True: mismo código
print(cuerda is cuerda_oferta)   # False: son dos objetos
print(cuerda == "C-70")          # False

Dos detalles importantes:

  • NotImplemented (no confundir con la excepción NotImplementedError) es un valor especial que significa “no sé compararme con esto”. Python entonces prueba la comparación al revés y, si tampoco funciona, == devuelve False en lugar de lanzar un error.
  • __hash__: los conjuntos y las claves de diccionario usan un número llamado hash para encontrar los elementos rápido. Al definir __eq__, Python desactiva el hash por defecto y tus objetos dejan de poder ir en un set (TypeError: unhashable type: 'Producto'). Por eso lo hemos definido nosotros.

La regla de oro: si dos objetos son iguales, su hash debe ser igual. Por eso el hash se calcula con los mismos atributos que usa __eq__ (aquí, el código).

catalogo = {cuerda, cuerda_oferta, Producto("A-01", "Arnés", 65.0)}
print(len(catalogo))   # 2: las dos cuerdas cuentan como una

stock = {cuerda: 4}
print(stock[cuerda_oferta])   # 4

Cuidado: los atributos que usas en __hash__ no deberían cambiar mientras el objeto está dentro de un conjunto o es clave de un diccionario. Si cambias cuerda.codigo después de meterla en catalogo, el conjunto ya no la encontrará. Por eso el hash se basa en datos que no cambian, como un código o un DNI.

Ordenar: __lt__ y total_ordering

Para que sorted(), min() y max() funcionen con tus objetos basta con __lt__ (less than, “menor que”). Pero si además quieres usar <=, > y >= sin escribir los cuatro métodos, el decorador functools.total_ordering completa los que faltan a partir de __eq__ y __lt__.

from functools import total_ordering

@total_ordering
class Nota:
    def __init__(self, alumno, valor):
        self.alumno = alumno
        self.valor = valor

    def __repr__(self):
        return f"Nota({self.alumno!r}, {self.valor})"

    def __eq__(self, otra):
        if not isinstance(otra, Nota):
            return NotImplemented
        return self.valor == otra.valor

    def __lt__(self, otra):
        if not isinstance(otra, Nota):
            return NotImplemented
        return self.valor < otra.valor

notas = [Nota("Ana", 7.5), Nota("Luis", 9), Nota("Marta", 6)]
print(sorted(notas))   # [Nota('Marta', 6), Nota('Ana', 7.5), Nota('Luis', 9)]
print(max(notas))      # Nota('Luis', 9)
print(Nota("Eva", 7) >= Nota("Pablo", 7))   # True

¿Cuándo definir el orden en la clase y cuándo usar key? Si hay un orden natural evidente (notas por valor, fechas por día), defínelo en la clase. Si cada parte del programa ordena por algo distinto, deja la clase sin orden y usa sorted(notas, key=lambda n: n.alumno), que viste en funciones avanzadas.

Operadores: __add__, __radd__ y compañía

Los operadores aritméticos tienen cada uno su método: __add__ (+), __sub__ (-), __mul__ (*), __truediv__ (/)… Vamos con una clase para tiempos de carrera en minutos y segundos:

class Tiempo:
    def __init__(self, minutos=0, segundos=0):
        self.total = minutos * 60 + segundos   # todo en segundos

    def __repr__(self):
        return f"Tiempo({self.total // 60}, {self.total % 60})"

    def __str__(self):
        return f"{self.total // 60}:{self.total % 60:02d}"

    def __add__(self, otro):
        if isinstance(otro, Tiempo):
            return Tiempo(segundos=self.total + otro.total)
        return NotImplemented

    def __mul__(self, veces):
        if isinstance(veces, int):
            return Tiempo(segundos=self.total * veces)
        return NotImplemented

vuelta = Tiempo(4, 35)
print(vuelta + Tiempo(4, 50))   # 9:25
print(vuelta * 3)               # 13:45

a + b devuelve un objeto nuevo; no modifica a. Es lo que se espera de un operador, igual que 2 + 3 no cambia el 2.

Por qué sum() falla y cómo lo arregla __radd__

Intenta sumar una lista de tiempos con sum():

vueltas = [Tiempo(4, 35), Tiempo(4, 50), Tiempo(4, 41)]
try:
    print(sum(vueltas))
except TypeError as e:
    print("Error:", e)
# Error: unsupported operand type(s) for +: 'int' and 'Tiempo'

sum() empieza en 0 y hace 0 + Tiempo(4, 35). El entero no sabe sumarse con un Tiempo, devuelve NotImplemented, y entonces Python prueba la versión reflejada: Tiempo(4, 35).__radd__(0). Como no existe, error. Basta con añadirla:

def radd(self, otro):
    if otro == 0:          # el valor inicial de sum()
        return self
    return self.__add__(otro)

Tiempo.__radd__ = radd     # lo añadimos a la clase ya creada

print(sum(vueltas))   # 14:06

Aquí hemos añadido el método a la clase desde fuera solo para no repetir el código; en tu programa lo escribirías dentro de la clase, como def __radd__(self, otro):. Lo mismo pasa con 3 * vuelta: necesita __rmul__ (que podría ser simplemente __rmul__ = __mul__ dentro de la clase).

Nota: también existen las versiones “en el sitio”, como __iadd__ para +=. Si no las defines, t += otro usa __add__ y asigna el resultado a t, que casi siempre es lo que quieres.

Verdadero o falso: __bool__ y __len__

En un if, Python decide si tu objeto es verdadero así: si tiene __bool__, lo usa; si no, mira __len__ (cero es falso); y si no tiene ninguno de los dos, el objeto siempre es verdadero. Por eso una clase contenedor con __len__ se comporta como una lista: vacía cuenta como falsa en un if.

Contenedores: __getitem__, __iter__ y __contains__

__getitem__: acceso con corchetes

__getitem__ recibe lo que va entre corchetes. Puede ser un índice, una clave o incluso un slice ([1:3]), que llega como un objeto slice.

class Clasificacion:
    def __init__(self, carrera, corredores):
        self.carrera = carrera
        self._corredores = list(corredores)   # en orden de llegada

    def __len__(self):
        return len(self._corredores)

    def __getitem__(self, posicion):
        if isinstance(posicion, slice):
            return Clasificacion(self.carrera, self._corredores[posicion])
        return self._corredores[posicion]

    def __repr__(self):
        return f"Clasificacion({self.carrera!r}, {self._corredores})"

media = Clasificacion("Media de Laredo", ["Iker", "Nerea", "Sofía", "Raúl", "Celia"])
print(media[0])      # Iker
print(media[-1])     # Celia
print(media[:3])     # Clasificacion('Media de Laredo', ['Iker', 'Nerea', 'Sofía'])

Al delegar en la lista interna, los índices negativos y el IndexError cuando te sales del rango funcionan solos. Con un slice devolvemos otra Clasificacion en vez de una lista: así el resultado sigue siendo del mismo tipo, igual que un slice de una lista devuelve una lista.

Un detalle curioso: si una clase tiene __getitem__ con índices desde 0, Python ya sabe recorrerla con for y usar in aunque no tenga __iter__: va pidiendo [0], [1], [2]… hasta recibir un IndexError.

__iter__ con yield

Ese mecanismo antiguo funciona, pero lo recomendable es definir __iter__. La forma más sencilla es escribirlo como un generador con yield, que viste en iteradores y generadores. Cada for llama de nuevo a __iter__, así que el objeto se puede recorrer todas las veces que quieras.

class Horario:
    def __init__(self):
        self._clases = []   # tuplas (hora, actividad)

    def agregar(self, hora, actividad):
        self._clases.append((hora, actividad))

    def __iter__(self):
        for hora, actividad in sorted(self._clases):
            yield f"{hora} {actividad}"

    def __contains__(self, actividad):
        return any(a.lower() == actividad.lower() for _, a in self._clases)

gimnasio = Horario()
gimnasio.agregar("19:00", "Spinning")
gimnasio.agregar("09:30", "Pilates")
gimnasio.agregar("18:00", "Yoga")

for clase in gimnasio:
    print(clase)
# 09:30 Pilates
# 18:00 Yoga
# 19:00 Spinning
print(list(gimnasio)[0])     # 09:30 Pilates
print("yoga" in gimnasio)    # True

__contains__ es opcional: sin él, in recorrería el horario con __iter__. Lo definimos para que la búsqueda no distinga mayúsculas.

Gestores de contexto: __enter__ y __exit__

Con ficheros usas with open(...) as f: para que el fichero se cierre siempre, incluso si hay un error. Cualquier objeto con __enter__ y __exit__ se puede usar con with: es un gestor de contexto.

  • __enter__(self) se ejecuta al entrar en el bloque. Lo que devuelve es lo que recibe la variable de as.
  • __exit__(self, tipo, valor, traza) se ejecuta siempre al salir: tanto si el bloque termina bien como si lanza una excepción. Si no hubo error, los tres parámetros valen None.
class Cronometro:
    def __init__(self, nombre):
        self.nombre = nombre

    def __enter__(self):
        print(f"[{self.nombre}] empieza")
        self.pasos = 0
        return self

    def __exit__(self, tipo, valor, traza):
        if tipo is None:
            print(f"[{self.nombre}] terminado en {self.pasos} pasos")
        else:
            print(f"[{self.nombre}] interrumpido por {tipo.__name__}: {valor}")
        return False   # no "tragamos" la excepción

with Cronometro("copia") as c:
    for _ in range(3):
        c.pasos += 1
# [copia] empieza
# [copia] terminado en 3 pasos

try:
    with Cronometro("importar") as c:
        c.pasos += 1
        raise ValueError("fila 2 mal formada")
except ValueError:
    print("El error sigue su camino")
# [importar] empieza
# [importar] interrumpido por ValueError: fila 2 mal formada
# El error sigue su camino

Si __exit__ devuelve True, la excepción se suprime y el programa sigue como si nada. Hazlo solo con errores concretos que sepas manejar; devolver True siempre escondería fallos de verdad.

Un uso típico de los gestores de contexto son las transacciones: se confirman si todo va bien y se deshacen si algo falla. Lo verás con bases de datos en SQLite.

Nota: para gestores sencillos existe un atajo sin clase: el decorador @contextmanager del módulo contextlib convierte un generador en gestor de contexto. Lo que va antes del yield hace de __enter__ y lo que va después (dentro de un finally), de __exit__.

Dataclasses: métodos especiales gratis

Escribir __init__, __repr__, __eq__ y los cuatro métodos de comparación a mano es repetitivo. Una dataclass los genera por ti: __init__, __repr__ y __eq__ siempre, y con order=True también __lt__, __le__, __gt__ y __ge__.

from dataclasses import dataclass, field

@dataclass(order=True)
class Via:
    grado: str
    metros: int
    nombre: str

vias = [Via("6a", 25, "La Gaviota"), Via("5c", 30, "Diedro Azul"), Via("6a", 18, "Pequeña")]
for v in sorted(vias):
    print(v)
# Via(grado='5c', metros=30, nombre='Diedro Azul')
# Via(grado='6a', metros=18, nombre='Pequeña')
# Via(grado='6a', metros=25, nombre='La Gaviota')

El orden generado compara los campos en el orden en que están declarados, igual que se comparan las tuplas: primero grado; si empatan, metros; si empatan, nombre. Para controlar qué campos cuentan, usa field(compare=False):

@dataclass(order=True, frozen=True)
class Socio:
    numero: int
    nombre: str = field(compare=False)
    email: str = field(compare=False, repr=False)

a = Socio(12, "Nerea", "nerea@correo.es")
b = Socio(12, "Nerea Gómez", "ng@correo.es")
print(a)                   # Socio(numero=12, nombre='Nerea')
print(a == b)              # True: solo cuenta el número
print(Socio(3, "Raúl", "") < a)   # True
print(len({a, b}))         # 1

Con frozen=True el objeto no se puede modificar después de crearlo (intentarlo lanza FrozenInstanceError) y, a cambio, la dataclass genera un __hash__ coherente con __eq__. Por eso los socios pueden ir en un conjunto.

Consejo: en cuanto tu clase sea sobre todo “datos con nombre”, empieza con @dataclass. Escribe a mano solo los métodos especiales que la dataclass no genera (__add__, __len__, __iter__…) o los que necesites con un comportamiento distinto.

Ejemplo completo: el inventario de una tienda

Juntamos lo visto: una dataclass ordenable e inmutable para los artículos y una clase contenedor con __len__, __getitem__, __iter__, __contains__, __add__ y __str__.

from dataclasses import dataclass

@dataclass(order=True, frozen=True)
class Articulo:
    precio: float
    nombre: str
    codigo: str

class Inventario:
    def __init__(self, articulos=()):
        self._por_codigo = {a.codigo: a for a in articulos}

    def __len__(self):
        return len(self._por_codigo)

    def __getitem__(self, codigo):
        return self._por_codigo[codigo]   # KeyError si no existe

    def __contains__(self, codigo):
        return codigo in self._por_codigo

    def __iter__(self):
        yield from sorted(self._por_codigo.values())   # del más barato al más caro

    def __add__(self, otro):
        if not isinstance(otro, Inventario):
            return NotImplemented
        return Inventario([*self, *otro])

    def __str__(self):
        filas = [f"{a.codigo:<6}{a.nombre:<16}{a.precio:>7.2f} €" for a in self]
        return "\n".join(filas)

tienda = Inventario([
    Articulo(65.0, "Arnés", "A-01"),
    Articulo(12.5, "Magnesio", "M-02"),
])
almacen = Inventario([Articulo(89.9, "Pies de gato", "P-07")])

todo = tienda + almacen
print(len(todo))           # 3
print(todo["P-07"].nombre) # Pies de gato
print("X-99" in todo)      # False
print(min(todo).nombre)    # Magnesio
print(todo)
# M-02  Magnesio          12.50 €
# A-01  Arnés             65.00 €
# P-07  Pies de gato      89.90 €

Fíjate en cómo cada pieza aprovecha a las demás: __add__ y __str__ usan __iter__ (con *self y for a in self), y min() funciona porque recorre el inventario con __iter__ y compara artículos con el __lt__ que generó la dataclass.

Errores frecuentes

  • Devolver False o lanzar un error en __eq__ con tipos desconocidos: devuelve NotImplemented, para que Python pueda probar la comparación al revés.
  • Definir __eq__ y olvidar __hash__: tus objetos dejan de poder ir en conjuntos o ser claves de diccionario (unhashable type). Define __hash__ con los mismos atributos o usa @dataclass(frozen=True).
  • Que __str__ o __repr__ no devuelvan un texto: si devuelven un número o usan print dentro, obtienes TypeError: __str__ returned non-string.
  • Modificar self en __add__: a + b no debe cambiar a. Crea y devuelve un objeto nuevo.
  • Olvidar __radd__: sum() de tus objetos falla con unsupported operand type(s) for +: 'int'.
  • Devolver True siempre en __exit__: se tragan todos los errores, también los que no esperabas.
  • Esperar que order=True compare por el campo “importante”: compara por el orden de declaración. Pon primero el campo por el que quieres ordenar o usa field(compare=False).

Resumen

Quieres que funcione…Define
print(x) / repr(x)__str__ / __repr__
a == b__eq__ (y __hash__ si va en conjuntos)
<, sorted, min, max__lt__ (+ @total_ordering para el resto)
a + b, a * 3__add__, __mul__
sum(lista), 3 * a__radd__, __rmul__
if x:__bool__ o __len__
len(x)__len__
x[i], x[1:3], x["clave"]__getitem__
for e in x__iter__ (fácil con yield)
e in x__contains__
with x as y:__enter__ y __exit__
Todo lo anterior de datos sin escribirlo@dataclass(order=True, frozen=True)

En la siguiente lección aprenderás a describir los tipos de tus funciones y clases para que el editor detecte errores antes de ejecutar: anotaciones de tipo.

Pon a prueba lo que has aprendido

[Python] Una clase define __eq__ pero no __hash__. ¿Qué pasa al crear {Producto()} (un conjunto con un objeto)?

[Python] La clase Tiempo define __add__ pero no __radd__. ¿Por qué falla sum([Tiempo(1), Tiempo(2)])?

[Python] Con @dataclass(order=True), ¿cuál es el resultado de sorted([Via("6a", 25), Via("5c", 30), Via("6a", 18)]) si los campos son grado y metros, en ese orden?
@dataclass(order=True)
class Via:
    grado: str
    metros: int

[Python] ¿Qué imprime este código?
class G:
    def __enter__(self):
        return self
    def __exit__(self, tipo, valor, traza):
        print("salgo", tipo.__name__)
        return True

with G():
    raise ValueError("x")
print("sigue")

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.