Cuando defines una clase en Python para guardar datos estructurados, normalmente escribes __init__, __repr__ y __eq__ a mano. El decorador @dataclass automatiza exactamente eso: inspecciona las anotaciones de tipo de la clase y genera esos métodos en tiempo de importación, sin metaclases ni magia oscura. Es código Python ordinario que el intérprete ejecuta una sola vez cuando carga el módulo.
Lo que genera no es magia: puedes ver los métodos con import inspect; print(inspect.getsource(MiClase.__init__)) si usas @dataclass(repr=True) y quieres curiosear. El mecanismo central es dataclasses.fields(), que devuelve una tupla de objetos Field describiendo cada atributo declarado. Eso es lo que distingue a @dataclass de soluciones como __init_subclass__: hay una API pública para introspección.
El problema más clásico que resuelve es el de los defaults mutables. Si escribes def __init__(self, items=[]), todas las instancias comparten la misma lista. field(default_factory=list) retrasa la construcción del valor por defecto a cada llamada de __init__, rompiendo ese vínculo.
La opción frozen=True convierte la instancia en inmutable generando __setattr__ y __delattr__ que lanzan FrozenInstanceError. Como efecto secundario útil, también genera __hash__ basado en los campos, lo que te permite usar las instancias como claves de diccionario o elementos de un set. Sin frozen=True, @dataclass pone __hash__ = None deliberadamente para evitar el bug silencioso de hashear objetos mutables.
Cuándo no usarlas: si necesitas validación compleja en cada campo, herencia múltiple complicada, o compatibilidad con ORMs que controlan la inicialización, considera attrs o Pydantic. Para datos simples o semisimples con lógica de dominio ligera, @dataclass es exactamente la herramienta correcta.
from __future__ import annotations
from dataclasses import dataclass, field, InitVar, fields
from typing import ClassVar
@dataclass(order=True, slots=True)
class Vector:
# Los campos usados en order=True se comparan en orden de declaración.
x: float
y: float
z: float = 0.0
# ClassVar queda excluida del __init__ generado.
dimensions: ClassVar[int] = 3
def magnitude(self) -> float:
return (self.x**2 + self.y**2 + self.z**2) ** 0.5
@dataclass(frozen=True)
class Color:
r: int
g: int
b: int
def __post_init__(self) -> None:
# __post_init__ se ejecuta justo después del __init__ generado.
# Con frozen=True, object.__setattr__ es la única forma de mutar.
for name, val in [("r", self.r), ("g", self.g), ("b", self.b)]:
if not (0 <= val <= 255):
raise ValueError(f"{name}={val!r} fuera del rango [0, 255]")
@dataclass
class Palette:
name: str
# default_factory evita que todas las instancias compartan la misma lista.
colors: list[Color] = field(default_factory=list)
# repr=False oculta el campo en __repr__ (útil para campos voluminosos).
_cache: dict = field(default_factory=dict, repr=False, compare=False)
# InitVar: llega al __init__ pero no se almacena como atributo.
seed_color: InitVar[Color | None] = None
def __post_init__(self, seed_color: Color | None) -> None:
if seed_color is not None:
self.colors.append(seed_color)
# ── Demostración ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
v1 = Vector(1.0, 2.0, 3.0)
v2 = Vector(4.0, 5.0)
print(v1 < v2) # True — order=True compara (x, y, z) como tupla
print(v1.magnitude()) # 3.741...
red = Color(255, 0, 0)
blue = Color(0, 0, 255)
print({red, blue}) # hashable gracias a frozen=True
palette = Palette(name="Primarios", seed_color=red)
palette.colors.append(blue)
print(palette) # _cache no aparece en __repr__
# Introspección de los campos declarados
for f in fields(palette):
print(f.name, f.default_factory if f.default_factory is not f.default_factory.__class__ else "—")
Desglose del código
order=True y el orden de declaración van de la mano. Cuando activas esta opción, @dataclass genera __lt__, __le__, __gt__ y __ge__ comparando los campos como si fueran una tupla en el orden en que aparecen. Por eso la posición de x, y, z importa: cambiarla cambiaría la semántica de ordenación. Si ya defines __eq__ manualmente, order=True falla; el decorador detecta la inconsistencia y lanza TypeError en tiempo de carga del módulo.
slots=True (Python 3.10+) le pide al decorador que construya internamente una nueva clase con __slots__ definidos. Esto elimina el __dict__ por instancia, reduciendo uso de memoria en objetos masivos y acelerando acceso a atributos porque CPython no necesita hacer lookup en el diccionario. El costo: no puedes añadir atributos arbitrarios después, y la herencia se complica si la clase padre no también usa slots.
ClassVar es la señal que le dice a @dataclass “este atributo pertenece a la clase, no a la instancia; no lo incluyas en __init__“. Sin esa anotación, el decorador lo tomaría como campo e intentaría pasarlo como parámetro, lo que raramente es lo que quieres.
InitVar resuelve un patrón concreto: a veces necesitas un valor en el momento de la construcción para calcular algo, pero no tiene sentido guardarlo. seed_color en Palette es el ejemplo perfecto: se usa en __post_init__ para poblar colors, y después desaparece. Si lo declararas como campo normal, estarías serializando datos auxiliares como si fueran parte del estado del objeto.
field(compare=False) en _cache es una decisión de diseño explícita: le dices al decorador que ese campo no participa ni en __eq__ ni en __hash__. Dos paletas con el mismo nombre y los mismos colores pero cachés distintas son iguales desde la perspectiva del dominio. Esto evita que un artefacto de implementación rompa la igualdad semántica.
Errores que debes conocer
Error: usar frozen=True y __post_init__ juntos sin object.__setattr__, lo que lanza FrozenInstanceError al intentar asignar atributos.
# ❌ Wrong
@dataclass(frozen=True)
class Point:
x: float
y: float
norm: float = field(init=False)
def __post_init__(self):
self.norm = (self.x**2 + self.y**2) ** 0.5 # FrozenInstanceError
# ✅ Right
@dataclass(frozen=True)
class Point:
x: float
y: float
norm: float = field(init=False)
def __post_init__(self):
# frozen bloquea __setattr__, pero object.__setattr__ lo bypasea
# de forma controlada y explícita.
object.__setattr__(self, "norm", (self.x**2 + self.y**2) ** 0.5)
object.__setattr__ bypasea el __setattr__ generado por frozen=True y es el patrón oficial documentado para este caso.
Error: declarar un campo con valor mutable como default directo en lugar de usar default_factory.
# ❌ Wrong
@dataclass
class Bag:
items: list = [] # TypeError en tiempo de carga: mutable default no permitido
# ✅ Right
@dataclass
class Bag:
items: list = field(default_factory=list)
@dataclass detecta listas, dicts y sets como defaults directos y lanza ValueError inmediatamente; es uno de los pocos casos donde el decorador te protege activamente de ti mismo.
Error: asumir que order=True funciona mezclando campos de tipos incomparables.
# ❌ Wrong
@dataclass(order=True)
class Task:
priority: int
name: str
tags: list[str] # list no es comparable con < entre instancias distintas
t1 = Task(1, "a", ["x"])
t2 = Task(1, "a", ["y"])
t1 < t2 # TypeError en runtime si priority y name son iguales
# ✅ Right
@dataclass(order=True)
class Task:
priority: int
name: str
# Excluye tags de la comparación
tags: list[str] = field(default_factory=list, compare=False)
compare=False en field() excluye ese campo de todos los métodos de comparación generados, que es exactamente lo que necesitas cuando el campo no tiene un orden natural bien definido.
N° 104