Python te da cuatro formas razonables de guardar unos cuantos valores relacionados. Elegir mal rara vez es fatal y siempre es un poco molesto, así que vale la pena dedicarle diez minutos una vez.
Las cuatro
d = {'x': 3, 'y': 4}
print(d, d['x'])
print('typo is silent:', d.get('z'))
try:
d['z']
except KeyError as err:
print('KeyError:', err)
{'x': 3, 'y': 4} 3
typo is silent: None
KeyError: 'z'
class P1:
def __init__(self, x, y): self.x, self.y = x, y
print(P1(3,4).x)
@dataclass
class P2:
x: int
y: int
print(P2(3,4), P2(3,4) == P2(3,4))
class P3(NamedTuple):
x: int
y: int
p3 = P3(3,4)
print(p3, p3.x, tuple(p3), p3 == (3,4))
3
P2(x=3, y=4) True
P3(x=3, y=4) 3 (3, 4) True
La dataclass te da __init__, __repr__ y __eq__ a partir de las anotaciones. El NamedTuple te da todo eso más inmutabilidad, desempaquetado de tuplas, e igualdad con tuplas normales.
Mutabilidad
p2 = P2(3,4); p2.x = 99
print('dataclass mutable:', p2)
try:
p3.x = 99
except AttributeError as err:
print('NamedTuple immutable:', type(err).__name__ + ':', err)
@dataclass(frozen=True)
class P4:
x: int
y: int
print('frozen dataclass hashable:', hash(P4(3,4)) == hash(P4(3,4)))
dataclass mutable: P2(x=99, y=4)
NamedTuple immutable: AttributeError: can't set attribute
frozen dataclass hashable: True
frozen=True te da una dataclass con las garantías de un NamedTuple, y de paso la posibilidad de usarla como clave.
La pregunta que decide: qué pasa con un error de tipeo
Esta es la que de verdad importa en el día a día.
p = P2(3,4)
p.z = 5 # a plain/dataclass object accepts anything
print('dataclass accepts a typo:', p.z)
try:
P3(3,4).z
except AttributeError as err:
print('NamedTuple rejects it:', type(err).__name__ + ':', err)
dataclass accepts a typo: 5
NamedTuple rejects it: 'P3' object has no attribute 'z'
Un diccionario devuelve None desde .get() sin decir nada. Una clase normal o una dataclass acepta en silencio un atributo nuevo que nunca declaraste: p.z = 5 no es un error, así que una asignación mal escrita crea un campo que nadie lee. Un NamedTuple rechaza las dos cosas.
Ese es el argumento más fuerte a favor de NamedTuple o de una dataclass congelada para los datos que pasas de un lado a otro: el error de tipeo falla donde lo cometiste, no tres funciones después. La parte 10 muestra cómo __slots__ le da la misma protección a una clase mutable.
Memoria
print('dict ', sys.getsizeof({'x':3,'y':4}))
print('NamedTuple ', sys.getsizeof(P3(3,4)))
print('class ', sys.getsizeof(P1(3,4)) + sys.getsizeof(P1(3,4).__dict__))
print('dataclass ', sys.getsizeof(P2(3,4)) + sys.getsizeof(P2(3,4).__dict__))
dict 184
NamedTuple 56
class 328
dataclass 304
Un NamedTuple es una tupla con nombres, así que no carga un __dict__ por instancia. Las clases y las dataclasses sí, y eso domina su tamaño.
Toma estos números como una forma, no como un benchmark: varían según la versión y según lo que guardes. La forma es estable: el NamedTuple es mucho más chico, el diccionario queda en medio, y los objetos con __dict__ son los más grandes. Solo importa con cientos de miles de instancias.
Cómo elegir
dict — cuando la forma es genuinamente dinámica, viene de JSON, o no conoces las claves de antemano. Deja de convertir JSON externo en objetos por costumbre.
NamedTuple — un valor chico e inmutable que pasas de un lado a otro y comparas. El más barato, el más seguro contra errores de tipeo, y funciona con desempaquetado de tuplas.
dataclass — la opción por defecto para cualquier cosa que hayas escrito tú con más de unos tres campos, o que tenga que cambiar después de construirse. Agrega frozen=True salvo que tengas una razón para no hacerlo.
clase normal — cuando el comportamiento importa más que los datos: métodos, validación en __init__, properties calculadas. Esa es la cuenta bancaria de la parte 1, y es un trabajo distinto.
Qué recordar
-
Un error de tipeo en un diccionario es silencioso; en una clase o dataclass crea un campo en silencio.
-
NamedTuple y las dataclasses con
frozen=Truerechazan las dos cosas. -
Recurre a una dataclass por defecto, con
frozen=Truesalvo que algo tenga que mutar. -
Deja el JSON externo como diccionario hasta que tengas una razón para darle un tipo.