NamedTuple vs dataclass: criterios para elegir sin dudar

Ambas herramientas resuelven el mismo problema superficial —evitar escribir __init__, __repr__ y __eq__ a mano— pero lo hacen desde fundamentos completamente distintos, y esa diferencia importa en el momento en que el código crece.

Un NamedTuple es una tupla. No se parece a una, no la envuelve: isinstance(obj, tuple) retorna True para cualquier instancia. Hereda toda la semántica de tupla: inmutable, hashable sin configuración extra, iterable posicionalmente, desempaquetable con a, b, c = obj. La “magia” es puramente estructural —el metaclase construye una subclase de tuple con índices nombrados.

Un dataclass, en cambio, es una clase Python normal a la que el decorador @dataclass le genera métodos automáticamente inspeccionando las anotaciones de tipo. No hay restricción de mutabilidad por defecto, no hay herencia de tuple, no hay comportamiento posicional implícito. Es un generador de boilerplate, no una nueva estructura de datos.

La consecuencia práctica más importante: el NamedTuple tiene el footprint de memoria de una tupla (sin __dict__, sin __weakref__ por defecto), mientras que el dataclass tiene el overhead de una instancia ordinaria. Para miles de objetos de datos pequeños, eso no es trivial.

¿Cuándo usar cuál? Usa NamedTuple cuando los datos son inmutables por diseño, cuando necesitas interoperar con código que espera tuplas (CSV rows, sqlite3, struct.unpack), o cuando el desempaquetado posicional es idiomático en tu dominio. Usa dataclass en cuanto necesites mutabilidad, lógica en métodos, herencia con campos propios en subclases, o validación en __post_init__.

Lo que se rompe si eliges mal: un NamedTuple con herencia de atributos en subclases es doloroso —las tuplas no están diseñadas para eso y el orden de campos se vuelve frágil. Un dataclass usado donde se esperaba una tupla lanza TypeError silenciosamente en desempaquetado o al pasarlo a funciones como csv.writer.

from __future__ import annotations

from typing import NamedTuple
from dataclasses import dataclass, field
import sys


# ── NamedTuple: coordenada 3D inmutable ──────────────────────────────────────

class Point3D(NamedTuple):
    x: float
    y: float
    z: float = 0.0  # valor por defecto permitido

    def distance_to_origin(self) -> float:
        return (self.x**2 + self.y**2 + self.z**2) ** 0.5


# ── dataclass: entidad mutable con lógica de negocio ─────────────────────────

@dataclass
class BoundingBox:
    min_point: Point3D
    max_point: Point3D
    label: str = ""
    # 'field' con default_factory evita el problema del mutable default
    tags: list[str] = field(default_factory=list)

    def __post_init__(self) -> None:
        # Validación que sería imposible de expresar en NamedTuple sin método
        if any(
            getattr(self.min_point, ax) > getattr(self.max_point, ax)
            for ax in ("x", "y", "z")
        ):
            raise ValueError("min_point debe ser ≤ max_point en todos los ejes")

    @property
    def volume(self) -> float:
        d = Point3D(
            self.max_point.x - self.min_point.x,
            self.max_point.y - self.min_point.y,
            self.max_point.z - self.min_point.z,
        )
        return d.x * d.y * d.z

    def expand(self, margin: float) -> None:
        # Mutabilidad intencionada: actualiza el estado del objeto
        self.min_point = Point3D(
            self.min_point.x - margin,
            self.min_point.y - margin,
            self.min_point.z - margin,
        )
        self.max_point = Point3D(
            self.max_point.x + margin,
            self.max_point.y + margin,
            self.max_point.z + margin,
        )


# ── Demostración de las diferencias clave ────────────────────────────────────

def demo() -> None:
    p = Point3D(1.0, 2.0, 3.0)

    # NamedTuple se comporta como tupla en todos los contextos
    x, y, z = p                          # desempaquetado posicional
    assert isinstance(p, tuple)          # es literalmente una tupla
    assert hash(p) == hash((1.0, 2.0, 3.0))  # hashable, mismo hash que la tupla equivalente

    # Uso natural en contextos que esperan secuencias
    row = list(p)                        # [1.0, 2.0, 3.0]
    assert p[0] == p.x                   # acceso por índice O por nombre

    box = BoundingBox(
        min_point=Point3D(0.0, 0.0, 0.0),
        max_point=Point3D(2.0, 3.0, 4.0),
        label="sensor_area",
    )
    print(f"Volumen inicial: {box.volume}")   # 24.0
    box.expand(0.5)
    print(f"Volumen expandido: {box.volume}") # 75.0
    box.tags.append("outdoor")               # lista mutable sin sorpresas

    # Comparación de memoria para 100 000 instancias
    nt_size = sys.getsizeof(p)               # típicamente 72 bytes
    dc_size = sys.getsizeof(box.min_point)   # mismo NamedTuple aquí
    # Un dataclass equivalente ocuparía ~48 bytes extra por __dict__
    print(f"Tamaño NamedTuple: {nt_size}B")


if __name__ == "__main__":
    demo()

Qué está pasando y por qué cada decisión importa

Point3D extiende NamedTuple directamente en la clase. El método distance_to_origin demuestra algo no obvio: puedes añadir métodos a un NamedTuple, con la condición de que no intenten modificar el estado (lanzarán AttributeError). El campo z con default funciona igual que en un dataclass, pero el orden importa: los campos con default deben ir al final, exactamente como en una firma de función —porque son la firma del __new__ de la tupla subyacente.

La línea assert hash(p) == hash((1.0, 2.0, 3.0)) no es trivial: el hash de Point3D(1.0, 2.0, 3.0) es idéntico al de la tupla plain (1.0, 2.0, 3.0). Esto tiene implicaciones reales: puedes usarlo como clave de diccionario intercambiablemente con tuplas, pero también significa que dos NamedTuple de tipos distintos pero con los mismos valores colisionarán en un set. Si eso es un problema, necesitas un dataclass con frozen=True, que genera su propio hash basado en la identidad del tipo.

BoundingBox como dataclass existe para mostrar dónde el modelo de tupla se quiebra. El método expand muta self.min_point y self.max_point. Fíjate que incluso aquí los usamos como NamedTuple inmutables —se reemplazan completamente en lugar de modificarse in-place. Eso es composición limpia: los datos atómicos son inmutables, el contenedor que los organiza puede mutar.

__post_init__ hace validación cruzada entre campos. En un NamedTuple podrías añadir __new__ para algo similar, pero la sintaxis es incómoda y rompe la claridad declarativa que es precisamente el punto de usar NamedTuple.

El field(default_factory=list) en tags resuelve el clásico problema del mutable default en Python: si escribieras tags: list[str] = [], todos los BoundingBox compartirían la misma lista. El decorador @dataclass detecta defaults mutables sin field() y lanza ValueError en tiempo de definición de clase, que es exactamente el comportamiento correcto.

Errores que debes conocer

Error: usar un NamedTuple donde se necesita herencia con campos adicionales en la subclase.

# ❌ Wrong
class Point2D(NamedTuple):
    x: float
    y: float

class Point3D(Point2D):  # TypeError en runtime
    z: float             # el campo z se ignora silenciosamente en algunas versiones
# ✅ Right
@dataclass
class Point2D:
    x: float
    y: float

@dataclass
class Point3D(Point2D):
    z: float  # se añade correctamente al final del __init__ generado

Las tuplas tienen longitud fija definida en la clase base; subclasificar para añadir campos rompe esa invariante y Python no lo impide de forma consistente.


Error: asumir que frozen=True en dataclass es equivalente a NamedTuple en todos los sentidos.

# ❌ Wrong — frozen dataclass no es hashable igual que NamedTuple
from dataclasses import dataclass

@dataclass(frozen=True)
class FrozenPoint:
    x: float
    y: float

p1 = FrozenPoint(1.0, 2.0)
# hash(p1) != hash((1.0, 2.0)) — no es intercambiable con tuplas en dicts/sets
assert isinstance(p1, tuple)  # False — sigue siendo una clase normal
# ✅ Right — usa NamedTuple cuando la interoperabilidad con tuple es requisito
from typing import NamedTuple

class Point(NamedTuple):
    x: float
    y: float

p = Point(1.0, 2.0)
assert isinstance(p, tuple)           # True
assert hash(p) == hash((1.0, 2.0))   # True

frozen=True hace el objeto inmutable y hashable, pero el hash se calcula sobre los valores usando __hash__ generado para la clase —distinto del hash de una tupla equivalente— y isinstance(obj, tuple) sigue siendo False.

106

Dejar un comentario

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

Scroll al inicio