Curso de Python / Criar registros de dados com dataclass

dataclass gera métodos de inicialização, representação e igualdade a partir dos campos de uma classe. Isso reduz o código repetitivo de registros de dados, mas as anotações de tipo não impõem automaticamente tipos ou regras de negócio durante a execução.

Declarar campos e escolher padrões independentes

O decorador @dataclass, do módulo padrão dataclasses, identifica campos pelas anotações da classe. Na configuração usada aqui, gera métodos como __init__(), __repr__() e __eq__(). Você pode então criar um registro, imprimi-lo e compará-lo sem escrever manualmente cada um desses métodos.

Para um campo de lista cujo padrão deva ser independente em cada registro, use field(default_factory=list). A função list, sem parênteses nessa declaração, é chamada quando o padrão do campo é necessário. Isso difere de colocar uma lista compartilhada na definição, como visto na comparação de atributos de classe e de instância.

O recurso é adequado a classes centradas em dados, mas não elimina a necessidade de decidir suas responsabilidades. Uma classe com operações complexas pode exigir outra organização. A referência de dataclasses, campos e métodos gerados descreve as opções do decorador.

Executar registros com listas separadas e igualdade por campos

Salve o programa completo em data_records.py, com UTF-8, numa pasta de prática. Use Python 3.9 ou posterior para executar diretamente a anotação list[str] deste código. No terminal com o ambiente virtual selecionado se houver, execute python data_records.py. Em macOS ou Ubuntu sem ambiente virtual, use python3 data_records.py quando necessário. Não há dependências externas.

from dataclasses import dataclass, field

@dataclass
class Expense:
    name: str
    amount: int
    tags: list[str] = field(default_factory=list)

    def __post_init__(self):
        if type(self.amount) is not int or self.amount < 0:
            raise ValueError("invalid amount")

a = Expense("lunch", 8000)
b = Expense("bus", 2500)
a.tags.append("work")
print(a)
print(b.tags)
print(a == Expense("lunch", 8000, ["work"]))

O resultado esperado é:

Expense(name='lunch', amount=8000, tags=['work'])
[]
True

A representação da primeira linha é gerada por __repr__() e mostra os valores dos campos. lunch, bus e work são dados de exemplo, correspondentes a almoço, ônibus e trabalho. Acrescentar work a a.tags não altera b.tags, cuja lista padrão é outro objeto.

A última linha compara a com uma nova instância de Expense com os mesmos valores nos campos, incluindo as tags. A igualdade gerada considera esses campos e o mesmo tipo de classe. O resultado é True, embora as duas instâncias sejam objetos distintos.

Entender o alcance da validação e de frozen

O inicializador gerado chama __post_init__() após atribuir os campos. A verificação escrita nesse método exige que amount seja um inteiro comum não negativo, rejeitando valores como -1, True e 3.5. Essa validação é código explícito da aplicação: ela não é deduzida automaticamente de amount: int.

Os campos name e tags não são verificados nesse método. Anotar name: str não impede, por si só, fornecer um número; anotar list[str] não inspeciona automaticamente os itens recebidos. O padrão de fábrica também só é usado quando o argumento é omitido. Se você passar uma lista pronta, a instância guarda a lista recebida, sem fazer uma cópia automática.

Uma atribuição posterior como a.amount = -1 não chama novamente __post_init__(). Se o contrato exigir validar todas as alterações, considere uma interface apropriada, como a validação de atributos com property, ou um desenho que produza novos estados em vez de modificar os existentes.

@dataclass(frozen=True) rejeita atribuições comuns aos campos, mas não congela recursivamente os objetos referenciados por eles. Uma lista em tags ainda pode receber append(). Para representar dados que não devem mudar, escolha também estruturas internas adequadas, como tuplas, e defina o contrato; frozen isoladamente não fornece imutabilidade profunda.

Testar identidade, independência e conversão

  1. Execute o programa original. Acrescente ao fim print(a.tags is b.tags) e confirme False. As listas padrão são distintas. Retire a linha depois do teste.
  2. Na criação de a, troque somente 8000 por -1. A criação deve falhar com ValueError: invalid amount antes das impressões. Restaure 8000 e teste depois True ou 3.5 no mesmo ponto, um de cada vez.
  3. Para observar o limite temporal da validação, acrescente a.amount = -1 depois de criar b e antes das impressões. A atribuição é aceita e a representação passa a mostrar -1. Remova essa linha para restaurar o estado válido.
  4. No programa original restaurado, substitua a última comparação por print(a is Expense("lunch", 8000, ["work"])). O resultado é False, pois identidade de objeto e igualdade por campos são perguntas diferentes. Restaure ==.

Dois gastos com o mesmo nome, valor e tags podem representar eventos distintos. Não use a igualdade gerada como prova de que duas transações são a mesma: um identificador de evento ou outra regra de identidade pode ser necessário. Inversamente, adicionar um campo à comparação pode mudar o que o programa considera igual, por isso essa escolha merece testes próprios.

dataclasses.asdict() ajuda a produzir uma estrutura de dicionários e outros tipos a partir dos campos, mas não garante que todos os valores sejam aceitos por JSON. Uma data ou um Decimal continua exigindo uma política de representação. Teste a conversão e a gravação separadamente, como na prática de JSON e validação de dados, em vez de supor que “virou dicionário” significa “pronto para qualquer formato”.