En muchos proyectos Python empezamos usando diccionarios para representar datos. Eso es práctico al principio, pero puede volverse confuso cuando las mismas claves aparecen en muchas funciones o cuando no queda claro qué estructura se espera.
En este tema veremos cómo usar dataclasses para representar datos con más claridad, reducir errores de claves, mejorar firmas de funciones y hacer que el código comunique mejor el dominio.
Un diccionario es flexible, pero esa flexibilidad puede ocultar errores.
producto = {
"precio": 1000,
"cantidad": 2,
}
Si otra parte del código espera la clave "cant" o "qty", Python no lo detectará hasta que se ejecute esa línea.
def calcular_importe(producto):
return producto["precio"] * producto["cant"]
El error está en una clave mal nombrada. Una estructura más explícita ayuda a evitarlo.
Una dataclass permite declarar datos con nombres y tipos esperados.
from dataclasses import dataclass
@dataclass
class Producto:
precio: float
cantidad: int
Ahora podemos crear productos así:
producto = Producto(precio=1000, cantidad=2)
Y acceder con atributos:
importe = producto.precio * producto.cantidad
Una dataclass hace visible la estructura esperada. Al leer la clase, sabemos qué campos componen un producto.
__init__ y __repr__.Antes:
def calcular_importe(producto):
return producto["precio"] * producto["cantidad"]
Después:
def calcular_importe(producto: Producto):
return producto.precio * producto.cantidad
La función ahora comunica qué tipo de dato espera.
También podemos trabajar con listas de productos:
def calcular_subtotal(productos: list[Producto]):
return sum(
producto.precio * producto.cantidad
for producto in productos
)
El tipo list[Producto] indica que se espera una lista de objetos Producto.
Una dataclass puede tener valores por defecto.
from dataclasses import dataclass
@dataclass
class Cliente:
email: str
tipo: str = "nuevo"
activo: bool = True
Podemos crear un cliente indicando solo el email:
cliente = Cliente(email="ana@example.com")
El tipo será "nuevo" y activo será True.
No uses listas o diccionarios mutables como valores por defecto directos. Usa field(default_factory=...).
from dataclasses import dataclass, field
@dataclass
class Carrito:
productos: list[Producto] = field(default_factory=list)
Así cada carrito obtiene su propia lista de productos.
Podemos validar datos después de crear el objeto usando __post_init__.
@dataclass
class Producto:
precio: float
cantidad: int
def __post_init__(self):
if self.precio < 0:
raise ValueError("El precio no puede ser negativo")
if self.cantidad <= 0:
raise ValueError("La cantidad debe ser positiva")
La validación queda cerca de la estructura de datos.
Si un dato no debería cambiar después de crearse, podemos usar frozen=True.
@dataclass(frozen=True)
class Producto:
precio: float
cantidad: int
Esto ayuda a evitar modificaciones accidentales del estado.
producto = Producto(precio=1000, cantidad=2)
# producto.precio = 2000 # No permitido si frozen=True.
Podemos modelar una venta con productos, cliente y país.
@dataclass(frozen=True)
class Venta:
productos: list[Producto]
cliente: Cliente
pais: str
La firma de la función principal puede simplificarse:
def calcular_total_venta(venta: Venta):
subtotal = calcular_subtotal(venta.productos)
descuento = obtener_descuento(venta.cliente.tipo)
impuesto = obtener_impuesto(venta.pais)
return round(subtotal * (1 - descuento) * (1 + impuesto), 2)
En ventas_demo, puedes crear un archivo src/modelos.py:
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Producto:
precio: float
cantidad: int
def __post_init__(self):
if self.precio < 0:
raise ValueError("El precio no puede ser negativo")
if self.cantidad <= 0:
raise ValueError("La cantidad debe ser positiva")
@dataclass(frozen=True)
class Cliente:
tipo: str = "nuevo"
@dataclass(frozen=True)
class Venta:
productos: list[Producto] = field(default_factory=list)
cliente: Cliente = field(default_factory=Cliente)
pais: str = "AR"
Una función de subtotal puede quedar así:
from modelos import Producto
def calcular_subtotal(productos: list[Producto]):
return sum(
producto.precio * producto.cantidad
for producto in productos
)
La función ya no depende de claves de diccionario. Depende de un modelo explícito.
Podemos probar la validación del modelo:
import pytest
from modelos import Producto
def test_producto_rechaza_precio_negativo():
with pytest.raises(ValueError, match="precio"):
Producto(precio=-1, cantidad=2)
def test_producto_rechaza_cantidad_no_positiva():
with pytest.raises(ValueError, match="cantidad"):
Producto(precio=1000, cantidad=0)
También podemos probar el cálculo:
def test_calcular_subtotal_con_productos():
productos = [
Producto(precio=1000, cantidad=2),
Producto(precio=500, cantidad=3),
]
assert calcular_subtotal(productos) == 3500
Si recibes datos como diccionarios, puedes convertirlos en dataclasses en una capa de entrada.
def crear_producto_desde_dict(datos):
return Producto(
precio=float(datos["precio"]),
cantidad=int(datos["cantidad"]),
)
Así el resto del sistema trabaja con objetos claros, no con diccionarios sueltos.
No uses dataclasses por obligación. Tal vez no aportan mucho cuando:
La pregunta clave es si la dataclass mejora la claridad del código.
Convierte este código basado en diccionarios a dataclasses:
def calcular_total(pedido):
total = 0
for item in pedido["items"]:
total += item["precio"] * item["cantidad"]
return total
Una posible solución:
from dataclasses import dataclass
@dataclass(frozen=True)
class ItemPedido:
precio: float
cantidad: int
@dataclass(frozen=True)
class Pedido:
items: list[ItemPedido]
def calcular_total(pedido: Pedido):
return sum(
item.precio * item.cantidad
for item in pedido.items
)
En ventas_demo, realiza estas tareas:
Producto.Producto.__post_init__.python -m ruff check src tests
python -m black src tests
python -m pytest
Antes de continuar, verifica que puedes hacer lo siguiente:
default_factory.__post_init__.frozen=True cuando conviene evitar mutaciones.En este tema vimos que dataclasses ayudan a representar datos con claridad, especialmente cuando los diccionarios empiezan a generar errores de claves, firmas confusas o estructuras implícitas.
En el próximo tema estudiaremos tipado gradual con type hints y revisión básica con mypy.