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

  1. El boilerplate que todos escribimos igual
  2. @dataclass: tres métodos gratis
  3. Valores por defecto y field(default_factory=...)
  4. __post_init__: validar al nacer
  5. frozen=True: dataclasses inmutables
  6. order=True: comparables y ordenables
  7. La tabla de criterio definitiva: namedtuple vs dict vs clase vs dataclass
  8. El catálogo final de Papyrus: dict de objetos
  9. Errores comunes y consejos
  10. 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 = 0

Eso 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: str o precio: float son type hints (pistas de tipo), que estudiaremos a fondo en el módulo 8. Por ahora te basta esto: @dataclass las 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ónica

Detalle 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 historial

La 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 set

Criterio: 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 Pau

El 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 No 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)
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 Luis

Compara 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 : str no 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 = 0 seguido de autor: str da TypeError (misma regla que los parámetros de función en 03-02). Ordena: primero los obligatorios.
  • Valor por defecto mutable: historial: list = [] lanza ValueError al definir la clase. Siempre field(default_factory=list) (o dict, 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=True con __post_init__ que asigna: self.titulo = self.titulo.strip() falla con FrozenInstanceError incluso dentro de __post_init__. En congeladas, valida sin reasignar (o usa object.__setattr__, truco avanzado que rara vez necesitarás).
  • Mezclar order=True con 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: 0

Cuatro 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 verdad

Observa 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 ProductoLibro/RevistaLibroDigital 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

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados