`dataclasses` en profundidad: internos y opciones avanzadas

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.

104

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio