Métodos especiales en Python
En esta lección
- Qué son y quién los llama
- Representar el objeto: __str__ y __repr__
- Igualdad: __eq__ y __hash__
- Ordenar: __lt__ y total_ordering
- Operadores: __add__, __radd__ y compañía
- Verdadero o falso: __bool__ y __len__
- Contenedores: __getitem__, __iter__ y __contains__
- Gestores de contexto: __enter__ y __exit__
- Dataclasses: métodos especiales gratis
- Ejemplo completo: el inventario de una tienda
- Errores frecuentes
- 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ónNotImplementedError) es un valor especial que significa “no sé compararme con esto”. Python entonces prueba la comparación al revés y, si tampoco funciona,==devuelveFalseen 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 unset(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 cambiascuerda.codigodespués de meterla encatalogo, 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 += otrousa__add__y asigna el resultado at, 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 deas.__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 valenNone.
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
@contextmanagerdel módulocontextlibconvierte un generador en gestor de contexto. Lo que va antes delyieldhace de__enter__y lo que va después (dentro de unfinally), 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
Falseo lanzar un error en__eq__con tipos desconocidos: devuelveNotImplemented, 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 usanprintdentro, obtienesTypeError: __str__ returned non-string. - Modificar
selfen__add__:a + bno debe cambiara. Crea y devuelve un objeto nuevo. - Olvidar
__radd__:sum()de tus objetos falla conunsupported operand type(s) for +: 'int'. - Devolver
Truesiempre en__exit__: se tragan todos los errores, también los que no esperabas. - Esperar que
order=Truecompare por el campo “importante”: compara por el orden de declaración. Pon primero el campo por el que quieres ordenar o usafield(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
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.