O Python te dá quatro jeitos razoáveis de guardar alguns valores relacionados. Escolher mal raramente é fatal e sempre é levemente irritante, então vale gastar dez minutos uma vez.
Os quatro
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
A dataclass te dá __init__, __repr__ e __eq__ a partir das anotações. A NamedTuple te dá tudo isso mais imutabilidade, desempacotamento de tupla, e igualdade com tuplas comuns.
Mutabilidade
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 dá uma dataclass com as garantias de uma NamedTuple, e hashabilidade junto.
A pergunta que decide: o que acontece com um erro de digitação
Essa é a que realmente importa no dia a dia.
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'
Um dicionário devolve None em silêncio no .get(). Uma classe comum ou dataclass silenciosamente aceita um atributo novo que você nunca declarou — p.z = 5 não é erro, então uma atribuição com o nome errado cria um campo que ninguém lê. Uma NamedTuple recusa os dois.
Esse é o argumento mais forte a favor de NamedTuple ou dataclass congelada para dados que circulam: o erro de digitação falha onde você o cometeu, não três funções adiante. A parte 10 mostra como __slots__ dá a uma classe mutável a mesma proteção.
Memória
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
Uma NamedTuple é uma tupla com nomes, então ela não carrega um __dict__ por instância. Classes e dataclasses carregam, e isso domina o tamanho delas.
Trate esses números como um formato, não como benchmark — eles variam por versão e pelo que está guardado. O formato é estável: NamedTuple é bem menor, dicionário fica no meio, objetos com __dict__ são os maiores. Só importa na casa das centenas de milhares de instâncias.
Como escolher
dict — o formato é genuinamente dinâmico, vem de JSON, ou você não sabe as chaves de antemão. Pare de converter JSON externo em objetos por hábito.
NamedTuple — um valor pequeno e imutável que você passa adiante e compara. O mais barato, o mais seguro contra erros de digitação, funciona com desempacotamento de tupla.
dataclass — o padrão para qualquer coisa que você mesmo escreveu com mais de uns três campos, ou que precisa mudar depois de construída. Use frozen=True a menos que tenha um motivo para não usar.
classe comum — quando comportamento importa mais que os dados: métodos, validação no __init__, properties calculadas. Essa é a conta bancária da parte 1, e é outro trabalho.
O que lembrar
-
Um erro de digitação num dicionário é silencioso; numa classe ou dataclass ele cria um campo em silêncio.
-
NamedTuple e dataclasses com
frozen=Truerecusam os dois. -
Use dataclass por padrão, com
frozen=Truea menos que algo precise mudar. -
Mantenha JSON externo como dicionário até ter um motivo para dar um tipo a ele.