La lección anterior terminó con una confesión: entre __init__, __repr__ y __eq__, la clase Libro dedica la mitad de sus líneas a recitar sus propios atributos — titulo, precio, stock — una y otra vez. Ese código repetitivo (llamado boilerplate) es tan universal que Python lo automatizó en la biblioteca estándar: el decorador @dataclass genera esos métodos por ti a partir de una simple lista de campos. En esta lección reescribirás Libro en un tercio de las líneas, conocerás field(), frozen=True y order=True, cerrarás la tabla de criterio que 04-06 dejó abierta (¿namedtuple, dict, clase o dataclass?) y dejarás el catálogo de Papyrus en su forma definitiva: un diccionario de objetos.
Contenido
- El boilerplate que todos escribimos igual
@dataclass: tres métodos gratis- Valores por defecto y
field(default_factory=...) __post_init__: validar al nacerfrozen=True: dataclasses inmutablesorder=True: comparables y ordenables- La tabla de criterio definitiva: namedtuple vs dict vs clase vs dataclass
- El catálogo final de Papyrus:
dictde objetos - Errores comunes y consejos
- Ejercicios con soluciones
El boilerplate que todos escribimos igual
Así quedó Libro tras 05-05 (solo la parte repetitiva):
class Libro:
def __init__(self, titulo, precio, stock=0):
self.titulo = titulo
self.precio = precio
self.stock = stock
def __repr__(self):
return f"Libro({self.titulo!r}, {self.precio}, {self.stock})"
def __eq__(self, otro):
if not isinstance(otro, Libro):
return NotImplemented
return (self.titulo, self.precio, self.stock) == (otro.titulo, otro.precio, otro.stock)Quince líneas donde cada atributo aparece cinco veces. Añadir un campo autor obliga a tocar tres métodos, y olvidar uno crea un bug silencioso (un __eq__ que ignora el campo nuevo). Es trabajo mecánico — justo lo que las máquinas hacen mejor.
@dataclass: tres métodos gratis
from dataclasses import dataclass
@dataclass
class Libro:
"""Un libro del catálogo de Papyrus."""
titulo: str
precio: float
stock: int = 0Eso es todo. El decorador @dataclass (importado de dataclasses, biblioteca estándar) lee los campos declarados y genera automáticamente __init__, __repr__ y __eq__:
odisea = Libro("La Odisea", 12.50, 4) # __init__ generado
print(odisea) # Libro(titulo='La Odisea', precio=12.5, stock=4) ← __repr__ generado
print(odisea == Libro("La Odisea", 12.50, 4)) # True ← __eq__ generado (campo a campo)
print(odisea == Libro("La Odisea", 12.50, 3)) # False (difiere el stock)Dos aclaraciones importantes:
- Las anotaciones
titulo: stroprecio: floatson type hints (pistas de tipo), que estudiaremos a fondo en el módulo 8. Por ahora te basta esto:@dataclasslas necesita para detectar los campos, y no valida tipos en ejecución —Libro(42, "hola")no protesta (todavía; enseguida llega__post_init__). - Una dataclass es una clase normal y corriente: puede tener métodos, properties (05-04), heredar y ser heredada. Solo te ahorra el boilerplate.
Por eso podemos añadirle el comportamiento de siempre:
@dataclass
class Libro:
titulo: str
precio: float
stock: int = 0
IVA_LIBROS = 0.04 # sin anotación de tipo → atributo de clase, no campo
DESCUENTO_SOCIO = 0.05
def precio_final(self, socio=False):
descuento = Libro.DESCUENTO_SOCIO if socio else 0
return round(self.precio * (1 - descuento) * (1 + Libro.IVA_LIBROS), 2)
def hay_stock(self):
return self.stock > 0
print(Libro("Hamlet", 9.95, 6).precio_final(socio=True)) # 9.83 — la cifra canónicaDetalle sutil pero crucial: IVA_LIBROS = 0.04 sin anotación es un atributo de clase corriente (05-01) y @dataclass lo ignora como campo. Con anotación (IVA_LIBROS: float = 0.04) se convertiría en un campo más del __init__ — casi nunca lo que quieres para una constante.
Valores por defecto y field(default_factory=...)
Los valores por defecto funcionan como en las funciones (stock: int = 0), y con la misma trampa del módulo 3: nunca un mutable como valor por defecto. @dataclass es aún más estricto que las funciones — directamente lo prohíbe:
@dataclass
class Socio:
nombre: str
codigo: str
historial: list = [] # ValueError: mutable default <class 'list'> for field historialLa solución es field(default_factory=...): en lugar de un valor compartido, le das una función que fabrica un valor nuevo para cada instancia:
from dataclasses import dataclass, field
@dataclass
class Socio:
nombre: str
codigo: str
historial: list = field(default_factory=list) # una lista NUEVA por socio
luis = Socio("Luis", "LUIS-001")
marta = Socio("Marta", "MARTA-002")
luis.historial.append("La Odisea")
print(marta.historial) # [] — cada socio tiene la suya (sin la trampa de 03-02)field() admite más ajustes útiles: field(compare=False) excluye un campo del __eq__/orden generados, y field(repr=False) lo oculta del __repr__ (útil para historiales largos).
__post_init__: validar al nacer
Como __init__ lo escribe el decorador, ¿dónde ponemos las validaciones de 05-04? En __post_init__, un método que la dataclass llama automáticamente justo después del __init__ generado:
@dataclass
class Libro:
titulo: str
precio: float
stock: int = 0
def __post_init__(self):
self.titulo = self.titulo.strip()
if not self.titulo:
raise ValueError("El título no puede estar vacío")
if self.precio < 0:
raise ValueError(f"Precio negativo no permitido: {self.precio}")
if not isinstance(self.stock, int) or self.stock < 0:
raise ValueError(f"Stock inválido: {self.stock!r}")
Libro("Fantasma", -3) # ValueError: Precio negativo no permitido: -3
Libro(" Hamlet ", 9.95) # OK, y además normaliza: titulo == "Hamlet"Las invariantes del negocio de Papyrus — precio ≥ 0, stock entero ≥ 0 — quedan protegidas en la construcción. (A diferencia de las properties de 05-04, __post_init__ no vigila las asignaciones posteriores; si necesitas eso, combina dataclass con properties o usa frozen=True, que viene ahora.)
frozen=True: dataclasses inmutables
@dataclass(frozen=True) genera objetos inmutables: cualquier asignación posterior lanza error, como en las tuplas y namedtuples de 04-02.
@dataclass(frozen=True)
class CodigoSocio:
nombre: str
numero: int
luis = CodigoSocio("LUIS", 1)
luis.numero = 99 # FrozenInstanceError: cannot assign to field 'numero'Y llega el premio que 05-05 dejó pendiente: una dataclass congelada sí es hashable (genera __hash__ coherente con su __eq__), así que sus instancias entran en sets y como claves de diccionario (04-04):
socios_activos = {CodigoSocio("LUIS", 1), CodigoSocio("MARTA", 2), CodigoSocio("LUIS", 1)}
print(len(socios_activos)) # 2 — el duplicado se fusiona, como en todo setCriterio: usa frozen=True para objetos-valor que representan datos identificativos (códigos, coordenadas, dinero); deja mutables los que modelan estado que cambia (el stock de un libro).
order=True: comparables y ordenables
@dataclass(order=True) genera __lt__, __le__, __gt__ y __ge__ comparando los campos en su orden de declaración, como tuplas:
@dataclass(order=True)
class Reserva:
prioridad: int # primer campo declarado → manda en el orden
cliente: str = field(compare=False) # excluido: no desempata por nombre
cola = [Reserva(2, "Omar"), Reserva(1, "Júlia"), Reserva(2, "Pau")]
for r in sorted(cola):
print(r.prioridad, r.cliente) # 1 Júlia · 2 Omar · 2 PauEl truco profesional está a la vista: declara primero el campo por el que quieres ordenar y excluye con field(compare=False) los que no deban influir. Es la alternativa generada a escribir __lt__ a mano (05-05); elige una vía u otra, no ambas.
La tabla de criterio definitiva: namedtuple vs dict vs clase vs dataclass
En 04-06 cerramos las estructuras de datos con una tabla de criterio. La POO añade dos columnas y la deja completa:
dict |
namedtuple (04-06) |
Clase normal (05-01…05) | @dataclass |
|
|---|---|---|---|---|
| Acceso | d["precio"] |
libro.precio |
libro.precio |
libro.precio |
| Mutable | Sí | No | Sí | Sí (o no, con frozen) |
| Métodos propios | No | Limitado | Sí, sin restricciones | Sí, sin restricciones |
__repr__/__eq__ dignos |
— | Gratis | A mano (05-05) | Gratis |
| Validación al crear | No | No | __init__/properties |
__post_init__ |
| Campos definidos y estables | No (claves libres) | Sí | Sí | Sí |
| Coste en líneas | 0 | 1 | Alto | Bajo |
| Úsalo cuando... | Datos heterogéneos de vida corta, claves dinámicas | Registro inmutable trivial, sin comportamiento | Mucha lógica, invariantes vigiladas con properties, jerarquías ricas | Datos con nombre + algo de comportamiento y validación |
Regla de decisión rápida, de menos a más estructura: ¿claves que cambian en ejecución? → dict. ¿Solo un registro inmutable para leer? → namedtuple (o dataclass congelada si quieres validación). ¿Datos con nombre, igualdad sensata, algo de comportamiento? → dataclass, el nuevo punto de partida por defecto. ¿La lógica domina sobre los datos (properties por todas partes, jerarquía de herencia con sobrescrituras, dunder a medida)? → clase completa escrita a mano. Y las dos cosas se mezclan: nada impide una dataclass con properties o una jerarquía cuya base sea una dataclass.
El catálogo final de Papyrus: dict de objetos
Cerremos el círculo abierto en 04-03. El gran refactor dejó el catálogo como {título: {"precio": ..., "stock": ...}}; el módulo 5 lo lleva a su forma definitiva — diccionario de clave normalizada a objeto Libro — combinando lo mejor de ambos mundos: la búsqueda directa por clave del dict y la inteligencia del objeto.
from dataclasses import dataclass
NOMBRE_TIENDA = "Papyrus"
def normalizar_titulo(texto):
"""La normalización canónica de 04-05, ahora clave del catálogo."""
return texto.strip().casefold()
@dataclass
class Libro:
titulo: str
precio: float
stock: int = 0
IVA_LIBROS = 0.04
DESCUENTO_SOCIO = 0.05
def __post_init__(self):
if self.precio < 0:
raise ValueError(f"Precio negativo: {self.precio}")
if self.stock < 0:
raise ValueError(f"Stock negativo: {self.stock}")
def precio_final(self, socio=False):
descuento = Libro.DESCUENTO_SOCIO if socio else 0
return round(self.precio * (1 - descuento) * (1 + Libro.IVA_LIBROS), 2)
def hay_stock(self):
return self.stock > 0
libros = [
Libro("La Odisea", 12.50, 4),
Libro("Hamlet", 9.95, 6),
Libro("El Quijote", 15.90, 8),
Libro("Fausto", 21.00, 0),
]
# El catálogo definitivo: dict[str, Libro] — clave normalizada → objeto
catalogo = {normalizar_titulo(libro.titulo): libro for libro in libros}
def buscar_libro(catalogo, buscado):
"""La búsqueda de papyrus_utils, reducida a una línea."""
return catalogo.get(normalizar_titulo(buscado))
pedido = buscar_libro(catalogo, " HAMLET ")
if pedido and pedido.hay_stock():
print(f"{pedido.titulo}: {pedido.precio_final(socio=True):.2f} € para Luis")
# Hamlet: 9.83 € para LuisCompara con el punto de partida del módulo: buscar_libro() ya no recorre claves normalizando una a una — el catálogo se construyó normalizado (comprensión de diccionarios, 04-03) y get() (04-03) resuelve la búsqueda con caso perdido incluido, devolviendo None como la versión original. Y lo que devuelve no es un sub-diccionario anónimo: es un objeto que sabe calcular su precio y declarar su stock.
flowchart LR
A["Módulo 4<br>dict de dicts<br>+ funciones sueltas"] --> B["05-01…05-05<br>clase Libro<br>escrita a mano"] --> C["05-06<br>dataclass Libro<br>catalogo: dict[str, Libro]"]
Errores Comunes y Consejos
- Olvidar la anotación de tipo en un campo:
titulo = "x"sin: strno es un campo — es un atributo de clase y desaparece del__init__. Todo campo de dataclass lleva anotación; toda constante de clase, no. - Campo sin valor por defecto después de uno que sí lo tiene:
stock: int = 0seguido deautor: strdaTypeError(misma regla que los parámetros de función en 03-02). Ordena: primero los obligatorios. - Valor por defecto mutable:
historial: list = []lanzaValueErroral definir la clase. Siemprefield(default_factory=list)(odict,set...). - Esperar validación de tipos por las anotaciones:
Libro(42, "hola")se crea sin queja. Las anotaciones documentan (y herramientas del módulo 8 las comprobarán antes de ejecutar), pero en ejecución valida__post_init__. frozen=Truecon__post_init__que asigna:self.titulo = self.titulo.strip()falla conFrozenInstanceErrorincluso dentro de__post_init__. En congeladas, valida sin reasignar (o usaobject.__setattr__, truco avanzado que rara vez necesitarás).- Mezclar
order=Truecon un__lt__manual: el decorador rechaza generar orden si ya defines__lt__(ValueError). Una fuente de verdad para el orden, no dos. - Consejo: ante una clase nueva, empieza por
@dataclass. Si con el tiempo necesita properties, herencia compleja o dunder a medida, "asciéndela" a clase completa: el código cliente ni se enterará, porque la interfaz (libro.precio,libro.precio_final()) no cambia.
Ejercicios
Ejercicio 1: Revista como dataclass
Reescribe la clase Revista de 05-02 como dataclass con campos titulo, numero, precio y stock = 0, un __post_init__ que valide que numero es un entero positivo, y el método precio_final(socio=False) con la fórmula canónica. Comprueba que Revista("Quimera", 482, 6.50, 10).precio_final(True) devuelve 6.42 y que numero=0 lanza ValueError.
Ejercicio 2: línea de venta congelada
Crea la dataclass LineaVenta con frozen=True y order=True, campos fecha (cadena "2026-07-13"), titulo y importe — en ese orden, para que las líneas se ordenen cronológicamente. Crea tres líneas de fechas distintas desordenadas, ordénalas con sorted() y comprueba que un set elimina una línea duplicada.
Ejercicio 3: el catálogo completo, de punta a punta
Usando la dataclass Libro final y el catálogo dict[str, Libro], escribe vender_titulo(catalogo, buscado, socio=False) que: busque el libro con buscar_libro(); devuelva None si no existe; si existe pero no hay stock, devuelva la cadena "AGOTADO: <título>"; y si hay stock, descuente 1 unidad y devuelva el precio final. Prueba con "hamlet" (socio), "FAUSTO" y "Rayuela".
Soluciones
Solución 1:
@dataclass
class Revista:
titulo: str
numero: int
precio: float
stock: int = 0
IVA_LIBROS = 0.04
DESCUENTO_SOCIO = 0.05
def __post_init__(self):
if not isinstance(self.numero, int) or self.numero <= 0:
raise ValueError(f"Número de revista inválido: {self.numero!r}")
def precio_final(self, socio=False):
descuento = Revista.DESCUENTO_SOCIO if socio else 0
return round(self.precio * (1 - descuento) * (1 + Revista.IVA_LIBROS), 2)
print(Revista("Quimera", 482, 6.50, 10).precio_final(True)) # 6.42
Revista("Quimera", 0, 6.50) # ValueError: Número de revista inválido: 0Cuatro campos, validación y comportamiento en un tercio del espacio que ocupaba la versión manual de 05-02.
Solución 2:
@dataclass(frozen=True, order=True)
class LineaVenta:
fecha: str # primer campo → gobierna el orden cronológico
titulo: str
importe: float
lineas = [
LineaVenta("2026-07-13", "Hamlet", 9.83),
LineaVenta("2026-07-11", "La Odisea", 12.35),
LineaVenta("2026-07-12", "El Quijote", 16.54),
LineaVenta("2026-07-11", "La Odisea", 12.35), # duplicada
]
for linea in sorted(lineas):
print(linea.fecha, linea.titulo)
# 2026-07-11 La Odisea · 2026-07-11 La Odisea · 2026-07-12 El Quijote · 2026-07-13 Hamlet
print(len(set(lineas))) # 3 — frozen ⇒ hashable ⇒ el set deduplica (04-04)Las fechas yyyy-MM-dd ordenan bien como texto — y en el módulo 6 estas líneas de venta serán exactamente lo que escribamos en un CSV.
Solución 3:
def vender_titulo(catalogo, buscado, socio=False):
libro = buscar_libro(catalogo, buscado)
if libro is None:
return None
if not libro.hay_stock():
return f"AGOTADO: {libro.titulo}"
libro.stock -= 1
return libro.precio_final(socio)
print(vender_titulo(catalogo, "hamlet", socio=True)) # 9.83
print(vender_titulo(catalogo, "FAUSTO")) # AGOTADO: Fausto
print(vender_titulo(catalogo, "Rayuela")) # None
print(catalogo["hamlet"].stock) # 5 — la venta descontó de verdadObserva la colaboración de todo el módulo en diez líneas: búsqueda normalizada (04-05/05-06), objeto que informa de su stock (05-01), precio calculado por el propio libro (05-01/05-02) y el catálogo como dict de objetos. Los tres desenlaces distintos (None, cadena, número) piden a gritos algo mejor que "adivina el tipo devuelto" — las excepciones del módulo 7 darán la respuesta elegante.
Conclusión
El módulo 5 cumple lo que prometió al cerrar el módulo 4: datos y comportamiento ya no viven separados. En 05-01, class, __init__ y self unieron el catálogo con sus funciones y cada libro aprendió a calcular su propio precio de socio; la herencia levantó la familia Producto → Libro/Revista → LibroDigital con super() repartiendo lo común (05-02); el polimorfismo hizo que el terminal de Ana cobrara cualquier artículo — incluso patos sin pedigrí como el BonoRegalo — enviando el mismo mensaje (05-03); la encapsulación blindó las invariantes del negocio con _internos y properties que rechazan precios y stocks imposibles (05-04); los métodos mágicos enseñaron a tus objetos el idioma nativo de Python — print, ==, sorted, len, in, + — y saldaron el print feo (05-05); y las dataclasses eliminaron el boilerplate, validaron al nacer con __post_init__ y completaron la tabla de criterio que 04-06 dejó abierta. El resultado final es el catálogo definitivo de Papyrus: dict[str, Libro], clave normalizada a objeto inteligente. Pero este edificio tiene un defecto que Ana descubre cada noche al apagar el ordenador: todo — el catálogo, los socios, las líneas de venta — vive en memoria y se pierde al cerrar el programa. Mañana, catalogo renace de cero, con "Fausto" otra vez sin los ejemplares que llegaron. El módulo 6 resuelve exactamente eso: archivos — texto, CSV, JSON — para que el estado de Papyrus sobreviva entre ejecuciones. Tus objetos ya saben pensar; ahora aprenderán a recordar.
Curso de Programación en Python
Módulo 1: Introducción a Python
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
