dataclasses: classes de dados concisas em Python

1. Visão geral

Ao escrever classes apenas para conter dados (como Order, Customer, Point…), você geralmente precisa escrever __init__, __repr__, __eq__ manualmente — é tudo código repetitivo. O módulo dataclasses (disponível desde Python 3.7) ajuda você a gerar esses métodos automaticamente com apenas um decorator. Após ler este artigo, você saberá como usar @dataclass para escrever classes de dados concisas, validar dados com __post_init__, tornar uma classe imutável e converter para dict/JSON.

2. O que é um dataclass básico

@dataclass é um decorator que lê as type hints na classe e depois gera automaticamente __init__, __repr__, __eq__ com base nesses campos.

from dataclasses import dataclass

@dataclass
class Customer:
    name: str
    email: str
    age: int = 0
  • @dataclass examina os atributos com type hints (name, email, age) e os trata como campos.
  • age: int = 0 tem um valor padrão, assim como parâmetros padrão em funções.
  • Você não precisa escrever __init__ — ele é gerado automaticamente na ordem correta dos campos.
c1 = Customer("An", "an@example.com")
print(c1)
Customer(name='An', email='an@example.com', age=0)

__repr__ é gerado automaticamente para exibir todos os nomes de campo e valores, muito útil para debug — as classes geralmente não têm isso por padrão.

3. Comparando com classes normais e namedtuple

Escrever a classe equivalente manualmente seria muito mais longo:

class CustomerPlain:
    def __init__(self, name, email, age=0):
        self.name = name
        self.email = email
        self.age = age

    def __repr__(self):
        return f"CustomerPlain(name={self.name!r}, email={self.email!r}, age={self.age!r})"

    def __eq__(self, other):
        if not isinstance(other, CustomerPlain):
            return NotImplemented
        return (self.name, self.email, self.age) == (other.name, other.email, other.age)

@dataclass economiza você de escrever toda essa parte. Em comparação com namedtuple, dataclass diferencia em:

  • Os objetos são mutáveis por padrão (você pode modificar c1.age = 20), namedtuple não é.
  • Suporta type hints explícitas, fácil de integrar com type checkers como mypy.
  • Suporta __post_init__, valores padrão complexos, herança — coisas que namedtuple não faz bem.

Nota: Se você só precisa de uma tupla imutável leve sem métodos, namedtuple ou typing.NamedTuple ainda é uma opção mais concisa.

4. field() e valores padrão mutáveis

Um erro muito comum: usar list/dict como valor padrão diretamente.

from dataclasses import dataclass, field

@dataclass
class Order:
    order_id: str
    items: list[str] = field(default_factory=list)
  • Se você escrever items: list[str] = [], Python vai lançar um erro ValueError: mutable default ... is not allowed — porque a lista padrão seria compartilhada entre todas as instâncias, levando a bugs difíceis de rastrear.
  • field(default_factory=list) instrui o dataclass a chamar list() nova para cada instância.
o1 = Order("A1")
o2 = Order("A2")
o1.items.append("Sách")

print(o1.items, o2.items)
['Sách'] []

Os dois objetos têm items independentes, exatamente como esperado.

5. Validando dados com __post_init__

O __init__ gerado por dataclass não permite inserir lógica de validação diretamente — mas há um hook __post_init__ que executa logo após __init__ terminar.

from dataclasses import dataclass

@dataclass
class PaymentRequest:
    amount: float
    currency: str = "VND"

    def __post_init__(self):
        if self.amount <= 0:
            raise ValueError("amount deve ser maior que 0")
        self.currency = self.currency.upper()
  • __post_init__ é chamado automaticamente após todos os campos serem atribuídos.
  • Aqui validamos amount e normalizamos currency para maiúsculas.
try:
    PaymentRequest(amount=-100)
except ValueError as e:
    print(e)
amount deve ser maior que 0

6. Classe imutável com frozen=True

Se você quer que o objeto não possa ser modificado após a criação (como um record em Java), use frozen=True.

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float
  • frozen=True bloqueia __setattr__ — qualquer atribuição após inicialização vai lançar um erro.
  • Graças à imutabilidade, objetos frozen podem ser usados como chave em dict ou elemento em set (porque dataclass gera __hash__ automaticamente quando frozen=True e eq=True).
p = Point(1.0, 2.0)
try:
    p.x = 5.0
except Exception as e:
    print(type(e).__name__, e)
FrozenInstanceError cannot assign to field 'x'

Nota: frozen=True só bloqueia atribuições de atributos no nível 1. Se um campo é uma list/dict (mutável), o conteúdo dentro ainda pode ser modificado. Para verdadeira imutabilidade, use tuple ou tipos imutáveis para esse campo.

7. Herança em dataclass

Dataclass suporta herança normal como qualquer classe Python, campos da classe filha vêm após os campos da classe pai.

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

@dataclass
class Employee(Person):
    salary: float = 0.0
  • Employee herda name, age de Person e depois adiciona salary.
  • A ordem dos parâmetros no __init__ gerado é: name, age, salary — mantém a ordem de declaração.
e = Employee("Bình", 30, salary=15_000_000)
print(e)
Employee(name='Bình', age=30, salary=15000000.0)

Nota: Campos sem padrão devem vir antes de campos com padrão em toda a cadeia de herança. Se a classe pai tem um campo com padrão e a classe filha adiciona um campo sem padrão, Python lançará TypeError ao definir a classe.

8. Comparação e ordenação com order=True

Por padrão, dataclass só gera __eq__. Para comparações <, >, <=, use order=True.

from dataclasses import dataclass, field

@dataclass(order=True)
class Product:
    price: float
    name: str = field(compare=False)
  • order=True gera __lt__, __le__, __gt__, __ge__, comparando na ordem dos campos (como comparação de tuplas).
  • field(compare=False) exclui name da comparação — compara apenas por price.
products = [Product(50_000, "Bút"), Product(20_000, "Vở")]
print(sorted(products))
[Product(price=20000, name='Vở'), Product(price=50000, name='Bút')]

sorted() usa __lt__ gerado pelo dataclass, sem precisar escrever key=lambda p: p.price manualmente.

9. Convertendo para dict e tuple

dataclasses fornece as funções utilitárias asdict e astuple para serializar objetos.

from dataclasses import dataclass, asdict, astuple

@dataclass
class Address:
    city: str
    country: str

@dataclass
class Customer2:
    name: str
    address: Address
cust = Customer2("Chi", Address("Hà Nội", "Việt Nam"))
print(asdict(cust))
print(astuple(cust))
{'name': 'Chi', 'address': {'city': 'Hà Nội', 'country': 'Việt Nam'}}
('Chi', ('Hà Nội', 'Việt Nam'))
  • asdict converte recursivamente até dataclass aninhado (Address) em dict aninhado — muito útil para retornar JSON via API.
  • astuple é similar mas retorna tuplas aninhadas.

Nota: asdict/astuple faz deepcopy de todos os dados. Com objetos grandes ou contendo campos que não podem ser copiados (file handle, connection…), chamar essa função será lento ou falhará.

10. Escrevendo testes para dataclass

Como __eq__ é gerado automaticamente baseado nos valores dos campos, comparar objetos em testes é bem natural com pytest.

import pytest
from dataclasses import FrozenInstanceError

def test_customer_equality():
    c1 = Customer("An", "an@example.com", 20)
    c2 = Customer("An", "an@example.com", 20)
    assert c1 == c2

def test_payment_request_rejects_non_positive_amount():
    with pytest.raises(ValueError):
        PaymentRequest(amount=0)

def test_point_is_frozen():
    p = Point(1.0, 2.0)
    with pytest.raises(FrozenInstanceError):
        p.x = 10.0
  • test_customer_equality verifica que dois objetos são instâncias diferentes mas com os mesmos valores de campo ainda são == — correto graças ao __eq__ gerado automaticamente.
  • test_payment_request_rejects_non_positive_amount verifica que __post_init__ valida corretamente.
  • test_point_is_frozen confirma que frozen=True bloqueia atribuições de atributos.

11. Erros comuns e quando não usar

  • Esquecer default_factory para padrões mutáveis: usar = [] ou = {} diretamente vai dar erro ao declarar a classe, deve usar field(default_factory=...).
  • Misturar campos com e sem padrão na ordem errada na herança: causa TypeError ao carregar o módulo, difícil de debugar se as classes estão espalhadas em vários arquivos.
  • Pensar que frozen=True é deep immutable: campos do tipo list/dict dentro ainda podem ter seu conteúdo modificado, apenas não é possível reatribuir o atributo.
  • Usar dataclass para lógica de negócio complexa: se a classe tem muitos métodos de processamento, relacionamentos complexos com outros objetos, dataclass só ajuda com inicialização/comparação — ainda precisa escrever a lógica manualmente, não abuse para “fingir” que a classe é mais simples do que realmente é.
  • Precisa de compatibilidade com Python < 3.7: dataclasses não é nativo, precisa instalar o package dataclasses do PyPI (backport) ou usar attrs.

Nota: Se precisar de validação complexa, serialização customizada ou integração profunda com JSON schema, bibliotecas como pydantic costumam ser mais poderosas que dataclasses puro — vale considerar em projetos maiores.

12. Conclusão

dataclasses ajuda você a eliminar boa parte do boilerplate ao escrever classes de dados: gera __init__, __repr__, __eq__ automaticamente, suporta validação via __post_init__, imutabilidade via frozen=True e serialização fácil com asdict/astuple. Para classes de dados simples a médias, é uma escolha bem mais concisa e legível que escrever manualmente. Quando as necessidades de validação ou serialização são mais complexas, você pode considerar pydantic ou attrs como alternativas.

pythondataclassesoopclean-code