Dataclasses: simplificando classes de dados

1. Introdução às Dataclasses

Classes de dados são um padrão comum em Python: estruturas que servem principalmente para armazenar valores, com pouca ou nenhuma lógica de negócio. Tradicionalmente, escrevê-las exigia muito código boilerplate — __init__, __repr__, __eq__ — tornando o código verboso e propenso a erros.

# Classe tradicional - muito boilerplate
class Pessoa:
    def __init__(self, nome: str, idade: int, email: str):
        self.nome = nome
        self.idade = idade
        self.email = email

    def __repr__(self):
        return f"Pessoa(nome='{self.nome}', idade={self.idade}, email='{self.email}')"

    def __eq__(self, other):
        if not isinstance(other, Pessoa):
            return NotImplemented
        return (self.nome, self.idade, self.email) == (other.nome, other.idade, other.email)

Com @dataclass, introduzido no Python 3.7 (PEP 557), isso se reduz drasticamente:

from dataclasses import dataclass

@dataclass
class Pessoa:
    nome: str
    idade: int
    email: str

O decorador gera automaticamente __init__, __repr__, __eq__ e __hash__ (se apropriado), baseado nas anotações de tipo. Menos código, menos bugs, mais legibilidade.

2. Configuração e Parâmetros do Decorador

O decorador @dataclass aceita parâmetros que controlam quais métodos especiais são gerados:

@dataclass(init=True, repr=True, eq=True, order=False, frozen=False, slots=False)
class Config:
    debug: bool = False
    timeout: int = 30
  • init: Controla a geração do __init__. Útil quando você quer definir construtores personalizados.
  • repr: Gera __repr__ automático. Desative se precisar de representação customizada.
  • eq: Gera __eq__. Essencial para comparações entre instâncias.
  • order: Quando True, gera __lt__, __le__, __gt__, __ge__, permitindo ordenação.
  • frozen: Torna a instância imutável (como uma tupla nomeada, mas mais flexível).
  • slots (Python 3.10+): Gera __slots__, economizando memória e acelerando acesso a atributos.
@dataclass(order=True)
class Produto:
    nome: str
    preco: float

p1 = Produto("Mouse", 50.0)
p2 = Produto("Teclado", 120.0)
print(p1 < p2)  # True (compara por nome, depois preço)

3. Campos e Tipos com field()

A função field() oferece controle granular sobre cada campo:

from dataclasses import dataclass, field
from typing import List

@dataclass
class Pedido:
    itens: List[str] = field(default_factory=list)
    desconto: float = field(default=0.0, repr=False)
    id_pedido: int = field(init=False, default=0)

    def __post_init__(self):
        self.id_pedido = hash(tuple(self.itens))

Argumentos principais de field():
- default: Valor padrão para o campo.
- default_factory: Função sem argumentos que gera o valor padrão (necessário para tipos mutáveis como listas).
- init: Se False, o campo não aparece no __init__.
- repr: Se False, o campo é omitido do __repr__.
- compare: Se False, o campo é ignorado em comparações.
- hash: Se False, o campo não participa do cálculo de hash.

@dataclass
class Usuario:
    nome: str
    email: str = field(repr=False)  # Não exibe email em logs
    permissoes: set = field(default_factory=set)  # Valor padrão mutável

4. Herança e Ordem dos Campos

Dataclasses suportam herança, mas a ordem dos campos segue regras específicas:

@dataclass
class Base:
    x: int = 0
    y: str = "default"

@dataclass
class Derivada(Base):
    z: float = 0.0
    x: int = 10  # Sobrescreve campo da base

d = Derivada()  # x=10, y="default", z=0.0

Campos da classe base vêm primeiro, seguidos pelos campos da subclasse. InitVar permite variáveis de inicialização que não são armazenadas como campos:

from dataclasses import InitVar

@dataclass
class Conexao:
    host: str
    porta: int
    timeout: InitVar[float] = 30.0  # Só para inicialização

    def __post_init__(self, timeout):
        self._timeout_real = timeout if timeout > 0 else 10.0

InitVar é útil para processar dados temporários durante a construção sem poluir o estado do objeto.

5. Métodos Especiais e Comportamento Pós-Inicialização

__post_init__ é chamado automaticamente após __init__. Ideal para validações e transformações:

from datetime import datetime
import re

@dataclass
class UsuarioValidado:
    nome: str
    email: str
    criado_em: datetime = field(init=False)

    def __post_init__(self):
        if not re.match(r"[^@]+@[^@]+\.[^@]+", self.email):
            raise ValueError(f"Email inválido: {self.email}")
        self.criado_em = datetime.now()

try:
    u = UsuarioValidado("João", "email-invalido")
except ValueError as e:
    print(e)  # Email inválido: email-invalido

Com InitVar, podemos receber dados temporários e processá-los:

@dataclass
class Relatorio:
    titulo: str
    dados_brutos: InitVar[list]
    dados_processados: list = field(init=False)

    def __post_init__(self, dados_brutos):
        self.dados_processados = [d for d in dados_brutos if d > 0]

6. Imutabilidade com frozen=True e Boas Práticas

Objetos imutáveis são mais seguros em contextos concorrentes e como chaves de dicionários:

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

p = Ponto(1.0, 2.0)
# p.x = 3.0  # Erro: cannot assign to field 'x'

# Para modificar em __post_init__:
@dataclass(frozen=True)
class PontoNormalizado:
    x: float
    y: float

    def __post_init__(self):
        # Usar object.__setattr__ para contornar frozen
        norm = (self.x**2 + self.y**2)**0.5
        object.__setattr__(self, 'x', self.x / norm)
        object.__setattr__(self, 'y', self.y / norm)

Quando usar o quê?
- Dataclass: Estruturas de dados com lógica adicional (validação, métodos).
- NamedTuple: Imutável, mais leve, ideal para dados simples e desempenho crítico.
- Dicionário: Flexível, mas sem validação de tipos ou autocompletar em IDEs.

7. Integração com Outros Recursos do Python

Propriedades derivadas:

@dataclass
class Retangulo:
    largura: float
    altura: float

    @property
    def area(self) -> float:
        return self.largura * self.altura

    @property
    def perimetro(self) -> float:
        return 2 * (self.largura + self.altura)

Protocolos e duck typing:

from typing import Protocol

class Serializavel(Protocol):
    def to_dict(self) -> dict: ...

@dataclass
class Item:
    nome: str
    preco: float

    def to_dict(self) -> dict:
        return {"nome": self.nome, "preco": self.preco}

def exportar(obj: Serializavel):
    return obj.to_dict()

Serialização com asdict e astuple:

from dataclasses import asdict, astuple

@dataclass
classe Endereco:
    rua: str
    numero: int

@dataclass
class Cliente:
    nome: str
    endereco: Endereco

c = Cliente("Ana", Endereco("Rua A", 123))
print(asdict(c))   # {'nome': 'Ana', 'endereco': {'rua': 'Rua A', 'numero': 123}}
print(astuple(c))  # ('Ana', Endereco(rua='Rua A', numero=123))

asdict é especialmente útil para converter dataclasses aninhadas em dicionários prontos para serialização JSON.

Dataclasses transformaram a maneira como escrevemos classes de dados em Python, reduzindo boilerplate e aumentando a expressividade. Combinadas com type hints, validação pós-inicialização e imutabilidade opcional, são uma ferramenta indispensável no arsenal do desenvolvedor Python moderno.

Referências