Dataclasses são um recurso introduzido no Python 3.7 através do módulo dataclasses que permitem criar classes de dados de forma concisa e com menos código boilerplate. Elas geram automaticamente métodos como __init__, __repr__, __eq__ e outros, baseados nos campos da classe. Isso torna o código mais limpo e legível, especialmente quando você precisa de uma classe que é principalmente um contêiner de dados.

Nesta aula, vamos explorar como usar dataclasses, configurar campos e valores padrão, torná-las imutáveis com frozen=True, e compará-las com alternativas como namedtuple e dicionários comuns.

@dataclass

O decorador @dataclass é aplicado a uma classe para automaticamente gerar métodos especiais. Para usá-lo, importe dataclass do módulo dataclasses. A classe deve definir atributos com anotações de tipo (type hints). O decorador analisa essas anotações e cria o método __init__ com os parâmetros correspondentes, além de __repr__ e __eq__.

Exemplo básico:

from dataclasses import dataclass

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

p = Pessoa("Alice", 30, "alice@example.com")
print(p)  # Pessoa(nome='Alice', idade=30, email='alice@example.com')
print(p == Pessoa("Alice", 30, "alice@example.com"))  # True

Note que não precisamos escrever __init__ ou __repr__ manualmente. A ordem dos campos na definição da classe determina a ordem dos argumentos no construtor.

Campos e defaults

Você pode definir valores padrão para campos, inclusive usando a função field() para configurações mais avançadas, como campos que não devem ser incluídos na representação ou na comparação. Valores padrão são atribuídos diretamente na anotação.

Exemplo com valores padrão:

from dataclasses import dataclass

@dataclass
class Config:
    host: str = "localhost"
    port: int = 8080
    debug: bool = False

c = Config()
print(c)  # Config(host='localhost', port=8080, debug=False)
c2 = Config("0.0.0.0", 80, True)
print(c2)  # Config(host='0.0.0.0', port=80, debug=True)

A função field() permite especificar opções como default, default_factory (para tipos mutáveis como listas), repr, compare, hash e init. Exemplo:

from dataclasses import dataclass, field
from typing import List

@dataclass
class Aluno:
    nome: str
    notas: List[float] = field(default_factory=list)
    ativo: bool = True

a = Aluno("João")
a.notas.append(8.5)
print(a)  # Aluno(nome='João', notas=[8.5], ativo=True)

Se usássemos notas: List[float] = [], o valor padrão seria compartilhado entre todas as instâncias, causando bugs. default_factory resolve isso criando uma nova lista para cada instância.

frozen

O parâmetro frozen=True no decorador @dataclass torna a classe imutável: após a criação, não é possível alterar os atributos. Isso é útil para criar objetos que devem ser constantes ou seguros em contextos de hash. Dataclasses imutáveis também podem ser usadas como chaves de dicionários.

Exemplo:

from dataclasses import dataclass

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

p = Ponto(1.0, 2.0)
print(p.x)  # 1.0
# p.x = 3.0  # Isso lançaria FrozenInstanceError

d = {p: "origem"}  # Funciona, pois Ponto é hashable
print(d)  # {Ponto(x=1.0, y=2.0): 'origem'}

Com frozen=True, a dataclass automaticamente gera métodos __hash__ (se eq=True, que é o padrão). Isso permite que instâncias sejam usadas em conjuntos e como chaves de dicionário.

vs namedtuple e dict

Antes das dataclasses, era comum usar namedtuple (do módulo collections) ou dicionários para representar dados simples. Vamos comparar as abordagens.

namedtuple: Cria uma tupla com campos nomeados. É imutável e mais leve que uma classe, mas não permite métodos personalizados, valores padrão ou type hints de forma nativa (embora Python 3.6+ permita anotações). Exemplo:

from collections import namedtuple

Ponto = namedtuple("Ponto", ["x", "y"])
p = Ponto(1, 2)
print(p.x, p.y)  # 1 2
# p.x = 3  # Erro: tuplas são imutáveis

dict: Dicionários são flexíveis e dinâmicos, mas não fornecem acesso a atributos com ponto, validação de tipos ou métodos especiais. Exemplo:

p = {"x": 1, "y": 2}
print(p["x"])  # 1
p["x"] = 3  # Permitido

Dataclass: Oferece o melhor dos dois mundos: mutabilidade opcional, type hints, valores padrão, métodos automáticos e possibilidade de adicionar métodos personalizados. Comparado a namedtuple, as dataclasses são mais flexíveis e legíveis para classes complexas. Comparado a dict, fornecem uma interface mais segura e autodocumentada.

Tabela comparativa:

  • namedtuple: Imutável, leve, sem type hints nativos, sem valores padrão, sem métodos personalizados.
  • dict: Mutável, dinâmico, sem estrutura fixa, sem acesso por atributo, sem validação.
  • dataclass: Mutável (ou imutável com frozen), type hints, valores padrão, métodos automáticos, suporte a herança.

Em geral, prefira dataclasses para representar dados estruturados em seu código, a menos que precise de imutabilidade simples (use namedtuple) ou flexibilidade total (use dict).

Boas práticas

  • Sempre use type hints nos campos da dataclass; isso melhora a legibilidade e permite que ferramentas de análise estática verifiquem seu código.
  • Use field(default_factory=...) para valores padrão mutáveis, evitando o problema de estado compartilhado.
  • Para classes que representam entidades imutáveis (como coordenadas), use frozen=True e aproveite a hashability.
  • Não abuse de dataclasses para classes com comportamento complexo; elas são projetadas para armazenar dados, não para lógica de negócio pesada.

Referências

Exercícios

  1. Crie uma dataclass chamada Livro com campos: titulo (str), autor (str), ano (int) e preco (float). Instancie um livro e imprima sua representação.
  2. ✓ Resposta:
    from dataclasses import dataclass
    
    @dataclass
    class Livro:
        titulo: str
        autor: str
        ano: int
        preco: float
    
    livro = Livro("1984", "George Orwell", 1949, 29.90)
    print(livro)  # Livro(titulo='1984', autor='George Orwell', ano=1949, preco=29.9)
  3. Defina uma dataclass Configuracao com campos host (str) com valor padrão "localhost", porta (int) com valor padrão 8080, e ssl (bool) com valor padrão False. Crie uma instância sem argumentos e outra com argumentos personalizados.
  4. ✓ Resposta:
    from dataclasses import dataclass
    
    @dataclass
    class Configuracao:
        host: str = "localhost"
        porta: int = 8080
        ssl: bool = False
    
    c1 = Configuracao()
    print(c1)  # Configuracao(host='localhost', porta=8080, ssl=False)
    c2 = Configuracao("example.com", 443, True)
    print(c2)  # Configuracao(host='example.com', porta=443, ssl=True)
  5. Crie uma dataclass Aluno com campos nome (str), matricula (int) e notas (List[float]) usando field(default_factory=list). Adicione um método que calcule a média das notas. Instancie um aluno, adicione notas e exiba a média.
  6. ✓ Resposta:
    from dataclasses import dataclass, field
    from typing import List
    
    @dataclass
    class Aluno:
        nome: str
        matricula: int
        notas: List[float] = field(default_factory=list)
    
        def media(self) -> float:
            if not self.notas:
                return 0.0
            return sum(self.notas) / len(self.notas)
    
    aluno = Aluno("Maria", 12345)
    aluno.notas.extend([7.5, 8.0, 9.2])
    print(aluno.media())  # 8.2333...
  7. Crie uma dataclass imutável Ponto2D com campos x e y (float). Verifique se a instância é hashable e use-a como chave em um dicionário.
  8. ✓ Resposta:
    from dataclasses import dataclass
    
    @dataclass(frozen=True)
    class Ponto2D:
        x: float
        y: float
    
    p1 = Ponto2D(1.0, 2.0)
    p2 = Ponto2D(3.0, 4.0)
    print(hash(p1))  # Valor hash
    coordenadas = {p1: "origem", p2: "destino"}
    print(coordenadas)  # {Ponto2D(x=1.0, y=2.0): 'origem', Ponto2D(x=3.0, y=4.0): 'destino'}
  9. Converta a seguinte namedtuple para uma dataclass equivalente e adicione um campo email com valor padrão vazio: Usuario = namedtuple('Usuario', ['nome', 'idade']).
  10. ✓ Resposta:
    from dataclasses import dataclass
    
    @dataclass
    class Usuario:
        nome: str
        idade: int
        email: str = ""
    
    u = Usuario("João", 25)
    print(u)  # Usuario(nome='João', idade=25, email='')
    u2 = Usuario("Maria", 30, "maria@example.com")
    print(u2)  # Usuario(nome='Maria', idade=30, email='maria@example.com')