Módulo 1 · Lección 58 de 59
Dataclasses
El decorador dataclass escribe por ti el init, el repr y la comparación de las clases que solo guardan datos.
Muchas clases no tienen comportamiento interesante: solo agrupan datos. Un producto de catálogo, una fila de un CSV, una coordenada. Y para que se comporten decentemente hay que escribir siempre lo mismo.
Mira la cantidad de código repetitivo que hace falta para una clase con cuatro campos:
class Producto:
def __init__(self, referencia, nombre, precio, unidades=0):
self.referencia = referencia
self.nombre = nombre
self.precio = precio
self.unidades = unidades
def __repr__(self):
return (f"Producto(referencia={self.referencia!r}, nombre={self.nombre!r}, "
f"precio={self.precio!r}, unidades={self.unidades!r})")
def __eq__(self, otro):
if not isinstance(otro, Producto):
return NotImplemented
return (self.referencia, self.nombre, self.precio, self.unidades) == \
(otro.referencia, otro.nombre, otro.precio, otro.unidades)Veinte líneas y ni una sola decisión propia: los nombres de los campos aparecen cuatro veces cada uno. Añadir un campo quinto obliga a tocar tres sitios, y olvidarse de uno da fallos silenciosos.
El decorador @dataclass#
dataclasses es un módulo de la biblioteca estándar. Con su decorador, la clase de arriba se queda así:
from dataclasses import dataclass
@dataclass
class Producto:
referencia: str
nombre: str
precio: float
unidades: int = 0
def valor_en_almacen(self) -> float:
return self.precio * self.unidades
teclado = Producto("TEC-001", "Teclado mecánico", 79.90, 4)
otro = Producto("TEC-001", "Teclado mecánico", 79.90, 4)
raton = Producto("RAT-007", "Ratón inalámbrico", 24.50)
print(teclado)
print(teclado == otro)
print(teclado == raton)
print(raton.unidades)
print(f"{teclado.valor_en_almacen():.2f} €")Producto(referencia='TEC-001', nombre='Teclado mecánico', precio=79.9, unidades=4)
True
False
0
319.60 €Los campos se declaran con anotaciones de tipo, una por línea, y no es opcional: sin los dos puntos y el tipo, el decorador no reconoce el campo y no lo mete en el __init__. La anotación es también la documentación de la clase, que se lee de un vistazo.
Que sea una dataclass no le quita nada: sigue siendo una clase normal y puede tener métodos, propiedades y todo lo demás, como ese valor_en_almacen.
Qué genera automáticamente#
| Método | Qué hace |
|---|---|
__init__ | Un parámetro por campo, en el orden declarado, con sus valores por defecto |
__repr__ | El texto Clase(campo=valor, ...) con todos los campos |
__eq__ | Compara campo a campo, y exige que sean de la misma clase |
Lo que no genera salvo que se lo pidas: __lt__ y compañía para ordenar, y __hash__.
Valores por defecto y la trampa de los mutables#
Los valores por defecto se ponen como en cualquier función, con un igual, y los campos que los tienen van después de los que no. Pero con una lista aparece esto:
@dataclass
class Pedido:
cliente: str
lineas: list = []ValueError: mutable default <class 'list'> for field lineas is not allowed: use default_factoryEse error es una buena noticia. Es exactamente la trampa del argumento por defecto mutable que viste en la lección de funciones: el valor por defecto se evalúa una sola vez, así que todos los pedidos compartirían la misma lista y las líneas de uno aparecerían en los demás. En una función normal Python te deja hacerlo y el fallo se descubre tarde; en una dataclass te lo impide de entrada.
La solución es field(default_factory=...), que recibe una función y la llama de nuevo en cada objeto:
from dataclasses import dataclass, field
@dataclass
class Pedido:
cliente: str
lineas: list[str] = field(default_factory=list)
etiquetas: set[str] = field(default_factory=set)
primero = Pedido("Lucía Ferrer")
segundo = Pedido("Iván Soto")
primero.lineas.append("Teclado mecánico")
print(primero)
print(segundo)Pedido(cliente='Lucía Ferrer', lineas=['Teclado mecánico'], etiquetas=set())
Pedido(cliente='Iván Soto', lineas=[], etiquetas=set())Se le pasa list, no list(): la función, no su resultado. Vale cualquier función sin argumentos, así que field(default_factory=dict), field(default_factory=set) o incluso una tuya como field(default_factory=generar_referencia).
order=True: poder ordenar#
Con esta opción, la dataclass genera también __lt__, __gt__, __le__ y __ge__, que comparan los campos en el orden en que están declarados, igual que si fueran tuplas:
@dataclass(order=True)
class Producto:
precio: float
nombre: str
catalogo = [
Producto(79.90, "Teclado mecánico"),
Producto(24.50, "Ratón inalámbrico"),
Producto(219.00, "Monitor 27 pulgadas"),
]
for producto in sorted(catalogo):
print(producto)
print(min(catalogo))
print(catalogo[0] > catalogo[1])Producto(precio=24.5, nombre='Ratón inalámbrico')
Producto(precio=79.9, nombre='Teclado mecánico')
Producto(precio=219.0, nombre='Monitor 27 pulgadas')
Producto(precio=24.5, nombre='Ratón inalámbrico')
TrueEl orden de declaración importa mucho aquí: poniendo precio primero se ordena por precio y, a igualdad de precio, por nombre. Si el criterio que quieres no coincide con el orden natural de los campos, no uses order=True y define tú el __lt__, o sigue usando sorted(..., key=...).
frozen=True: objetos inmutables#
Una dataclass congelada no deja cambiar sus campos después de crearla:
@dataclass(frozen=True)
class Coordenada:
latitud: float
longitud: float
zaragoza = Coordenada(41.6488, -0.8891)
print(zaragoza)
print({zaragoza, Coordenada(41.6488, -0.8891)})
zaragoza.latitud = 0.0Coordenada(latitud=41.6488, longitud=-0.8891)
{Coordenada(latitud=41.6488, longitud=-0.8891)}
Traceback (most recent call last):
File "/Users/erwin/curso-python/coordenada.py", line 13, in <module>
zaragoza.latitud = 0.0
^^^^^^^^^^^^^^^^
File "<string>", line 16, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'latitud'Fíjate en la segunda línea de salida: el conjunto tiene un solo elemento aunque se metieran dos coordenadas. Congelar una dataclass le genera además un __hash__, así que se puede usar en conjuntos y como clave de diccionario, cosa que las normales no pueden por tener __eq__.
Congelar tiene sentido cuando el objeto representa un valor y no una cosa que evoluciona: una coordenada, un importe con su divisa, una fecha de un rango. Un carrito de la compra, en cambio, está hecho para cambiar.
Validar con __post_init__#
Si necesitas comprobar o ajustar datos al crear el objeto, la dataclass llama a __post_init__ justo después del __init__ que ha generado:
@dataclass
class Producto:
nombre: str
precio: float
def __post_init__(self):
if self.precio < 0:
raise ValueError(f"El precio no puede ser negativo: {self.precio}")
self.nombre = self.nombre.strip().title()
print(Producto(" teclado mecánico ", 79.90))
try:
Producto("Ratón", -5)
except ValueError as error:
print(f"Rechazado: {error}")Producto(nombre='Teclado Mecánico', precio=79.9)
Rechazado: El precio no puede ser negativo: -5Así conservas la brevedad de la dataclass sin renunciar a las validaciones.
asdict: el puente con JSON#
asdict() convierte el objeto en un diccionario, recorriendo también las dataclasses anidadas, las listas y los diccionarios que lleve dentro. Y un diccionario es justo lo que json.dumps sabe guardar:
import json
from dataclasses import dataclass, field, asdict
@dataclass
class Tarea:
titulo: str
hecha: bool = False
etiquetas: list[str] = field(default_factory=list)
tareas = [
Tarea("Revisar la factura de octubre", etiquetas=["administración"]),
Tarea("Preparar la charla", hecha=True),
]
print(asdict(tareas[0]))
texto = json.dumps([asdict(t) for t in tareas], indent=2, ensure_ascii=False)
print(texto)
datos = json.loads(texto)
recuperadas = [Tarea(**datos_tarea) for datos_tarea in datos]
print(recuperadas)
print(recuperadas == tareas){'titulo': 'Revisar la factura de octubre', 'hecha': False, 'etiquetas': ['administración']}
[
{
"titulo": "Revisar la factura de octubre",
"hecha": false,
"etiquetas": [
"administración"
]
},
{
"titulo": "Preparar la charla",
"hecha": true,
"etiquetas": []
}
]
[Tarea(titulo='Revisar la factura de octubre', hecha=False, etiquetas=['administración']), Tarea(titulo='Preparar la charla', hecha=True, etiquetas=[])]
TrueEse ciclo completo es el que usarás una y otra vez:
- Para guardar:
asdict()sobre cada objeto yjson.dumpsde la lista resultante. - Para cargar:
json.loadsy despuésTarea(**diccionario), que desempaqueta las claves como argumentos con nombre. - La última línea,
recuperadas == tareas, daTruegracias al__eq__generado. Es la comprobación más barata de que el guardado y la carga no han perdido nada por el camino.
Cuándo usar una dataclass#
| Usa una dataclass cuando | Usa una clase normal cuando |
|---|---|
| El objeto es sobre todo un conjunto de campos | El objeto tiene comportamiento y el estado es secundario |
Quieres repr y == sin escribirlos | Lo que importa es la identidad, no los valores |
| Los datos vienen o van a JSON, CSV o una base de datos | Todos los atributos son internos y nadie los ve |
Hay muchos campos y el __init__ sería puro copiar | El __init__ hace trabajo de verdad, no solo asignar |
En la duda, empieza con una dataclass. Pasar de dataclass a clase normal es quitar un decorador y escribir el __init__, y el código que la usa no se entera.