33. Data Classes para Dados Estruturados Simples
No Capítulo 30, aprendemos como criar classes para definir nossos próprios tipos. Nós escrevemos métodos __init__ para inicializar instâncias, métodos __repr__ para exibi-las, e métodos __eq__ para compará-las. Embora essa abordagem funcione perfeitamente, ela envolve escrever muito código repetitivo, especialmente quando uma classe existe principalmente para armazenar dados.
As data classes do Python oferecem uma forma mais limpa e concisa de criar classes que são principalmente contêineres de dados. Ao usar o decorator @dataclass, o Python gera automaticamente métodos comuns como __init__, __repr__ e __eq__ com base nos atributos da classe que você define. Isso reduz o boilerplate e deixa suas intenções mais claras.
33.1) O que são Data Classes e quando usá-las
Uma data class (classe de dados) é uma classe projetada principalmente para armazenar valores de dados. Em vez de escrever manualmente métodos de inicialização e comparação, você define os atributos que sua classe deve ter, e o Python gera automaticamente os métodos necessários.
Por que Data Classes importam
Considere uma classe comum para representar um livro:
class Book:
def __init__(self, title, author, year):
self.title = title
self.author = author
self.year = year
def __repr__(self):
return f"Book(title={self.title!r}, author={self.author!r}, year={self.year})"
def __eq__(self, other):
if not isinstance(other, Book):
return False
return (self.title == other.title and
self.author == other.author and
self.year == other.year)
book1 = Book("1984", "George Orwell", 1949)
print(book1) # Output: Book(title='1984', author='George Orwell', year=1949)
book2 = Book("1984", "George Orwell", 1949)
print(book1 == book2) # Output: TrueIsso funciona, mas repare quanto código nós escrevemos só para armazenar três informações. Os métodos __init__, __repr__ e __eq__ seguem padrões previsíveis — eles simplesmente lidam com os atributos que definimos.
As data classes eliminam essa repetição. Elas são particularmente úteis quando:
- Sua classe armazena principalmente dados em vez de implementar comportamentos complexos
- Você precisa de métodos padrão como inicialização, representação em string e comparação de igualdade
- Você quer um código mais claro e mais fácil de manter com menos boilerplate
- Você está criando objetos de configuração, objetos de transferência de dados ou registros simples
Data classes não substituem classes comuns — elas as complementam. Use classes comuns quando você precisa de lógica de inicialização personalizada, métodos complexos ou hierarquias de herança. Use data classes quando você precisa principalmente de um contêiner estruturado para dados relacionados.
A relação entre Data Classes e classes comuns
Data classes ainda são classes Python comuns. Elas suportam todos os recursos que aprendemos nos Capítulos 30-32: métodos, propriedades, herança e métodos especiais. O decorator @dataclass simplesmente automatiza a criação de métodos comuns, poupando você de escrever código repetitivo.
33.2) Criando Data Classes com @dataclass
Para criar uma data class, você importa o decorator dataclass do módulo dataclasses e aplica ele à definição da sua classe. Dentro da classe, você define atributos de classe com anotações de tipo que especificam quais dados a classe deve manter.
Sintaxe básica de Data Class
from dataclasses import dataclass
@dataclass
class Student:
name: str
student_id: int
gpa: float
# Criar instâncias
alice = Student("Alice Johnson", 12345, 3.8)
bob = Student("Bob Smith", 12346, 3.5)
print(alice) # Output: Student(name='Alice Johnson', student_id=12345, gpa=3.8)
print(bob) # Output: Student(name='Bob Smith', student_id=12346, gpa=3.5)Vamos detalhar o que o decorator @dataclass faz:
-
@dataclass: aplicar esse decorator faz com que o Python escreva automaticamente os métodos__init__,__repr__e__eq__para você -
__init__automático: o Python cria um método de inicialização que aceita esses três parâmetros na ordem em que eles são definidos e os atribui a atributos de instância -
__repr__automático: o Python cria uma representação em string mostrando o nome da classe e todos os valores dos atributos -
__eq__automático: o Python cria um método de comparação de igualdade que compara todos os atributos -
Converte anotações de tipo em atributos de instância: em uma classe comum, escrever
name: strno corpo da classe cria um atributo de classe. Mas o decorator@dataclassmuda esse comportamento — ele usa essas anotações de tipo para definir atributos de instância. Cada instância recebe seus próprios atributosname,student_idegpa.
A principal diferença em relação às classes comuns:
# Classe comum - estes são atributos de classe (compartilhados por todas as instâncias)
class RegularStudent:
name: str
student_id: int
# Data class - estes viram atributos de instância (cada instância tem os seus)
@dataclass
class DataStudent:
name: str
student_id: intEntendendo anotações de tipo em Data Classes
Em data classes, as anotações de tipo definem os atributos e documentam seus tipos esperados:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
in_stock: bool
# Usando os tipos corretos conforme documentado
laptop = Product("Laptop", 999.99, True)
print(laptop) # Output: Product(name='Laptop', price=999.99, in_stock=True)
# O Python não impõe tipos - isso roda sem erro
macbook = Product("Macbook", "expensive", True)
print(macbook) # Output: Product(name='Macbook', price='expensive', in_stock=True)
# Mas usar tipos errados vai causar problemas depois:
discounted = laptop.price * 0.9 # Works: 899.991
discounted = macbook.price * 0.9 # TypeError: can't multiply sequence by non-int of type 'float'
tax = laptop.price + 50 # Works: 1049.99
tax = macbook.price + 50 # TypeError: can only concatenate str (not "int") to strO Python não vai impedir você de passar os tipos errados ao criar uma instância de data class. As anotações de tipo são principalmente documentação — elas dizem a outros programadores (e a ferramentas de verificação de tipos como o mypy) quais tipos você espera, mas o Python não as impõe em runtime. Isso é consistente com a filosofia de tipagem dinâmica do Python.
No entanto, seguir as anotações de tipo torna seu código mais previsível e mais fácil de debugar. Quando você usa os tipos errados, erros vão aparecer depois, quando você tentar usar os dados, tornando os bugs mais difíceis de rastrear. Ferramentas de verificação de tipos podem detectar essas incompatibilidades antes de você rodar o código, ajudando você a encontrar problemas cedo.
Acessando e modificando atributos
Instâncias de data class funcionam exatamente como instâncias de classes comuns. Você acessa e modifica atributos usando notação de ponto:
from dataclasses import dataclass
@dataclass
class Employee:
name: str
position: str
salary: float
emp = Employee("Sarah Chen", "Software Engineer", 95000.0)
# Acessar atributos
print(emp.name) # Output: Sarah Chen
print(emp.position) # Output: Software Engineer
# Modificar atributos
emp.salary = 100000.0
emp.position = "Senior Software Engineer"
print(emp) # Output: Employee(name='Sarah Chen', position='Senior Software Engineer', salary=100000.0)Data classes são mutáveis por padrão — você pode alterar seus atributos após a criação. Isso é diferente de tuplas ou named tuples, que são imutáveis. Se você precisa de imutabilidade, você pode configurar a data class com frozen=True (vamos explorar isso na Seção 33.4).
33.3) Métodos gerados: __init__, __repr__ e __eq__
O decorator @dataclass gera automaticamente três métodos essenciais. Entender o que esses métodos fazem ajuda você a usar data classes de forma eficaz e saber quando personalizá-las.
O método __init__ gerado
O método __init__ inicializa uma nova instância com os valores fornecidos. O Python o gera com base na ordem das suas definições de atributos:
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
# O __init__ gerado aceita width e height nessa ordem
rect = Rectangle(10.5, 5.0)
print(rect.width) # Output: 10.5
print(rect.height) # Output: 5.0
# Você também pode usar argumentos nomeados
rect2 = Rectangle(height=8.0, width=12.0)
print(rect2.width) # Output: 12.0
print(rect2.height) # Output: 8.0O __init__ gerado é equivalente a escrever:
def __init__(self, width: float, height: float):
self.width = width
self.height = heightEssa geração automática poupa você de escrever código repetitivo de inicialização, especialmente para classes com muitos atributos.
O método __repr__ gerado
O método __repr__ fornece uma representação em string da instância que mostra todos os valores dos atributos. Isso é inestimável para debugging e logging:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
label: str
point = Point(3.5, 7.2, "A")
print(point) # Output: Point(x=3.5, y=7.2, label='A')
print(repr(point)) # Output: Point(x=3.5, y=7.2, label='A')O __repr__ gerado segue a convenção de mostrar o nome da classe e todos os atributos em um formato que poderia ser usado para recriar o objeto. Isso é muito mais útil do que a representação padrão que você teria sem __repr__: <__main__.Point object at 0x...>.
O método __eq__ gerado
O método __eq__ permite comparação de igualdade entre instâncias. Duas instâncias de data class são consideradas iguais se todos os seus atributos correspondentes forem iguais:
from dataclasses import dataclass
@dataclass
class Color:
red: int
green: int
blue: int
color1 = Color(255, 0, 0)
color2 = Color(255, 0, 0)
color3 = Color(0, 255, 0)
print(color1 == color2) # Output: True (same RGB values)
print(color1 == color3) # Output: False (different RGB values)
print(color1 is color2) # Output: False (different objects in memory)Essa comparação automática de igualdade é baseada em igualdade de valor, não de identidade. Mesmo que color1 e color2 sejam objetos diferentes na memória (como mostrado por is), eles são considerados iguais porque seus atributos batem.
O método __eq__ gerado compara atributos na ordem em que eles são definidos:
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
year: int
book1 = Book("1984", "George Orwell", 1949)
book2 = Book("1984", "George Orwell", 1949)
book3 = Book("Animal Farm", "George Orwell", 1945)
print(book1 == book2) # Output: True (all attributes match)
print(book1 == book3) # Output: False (title and year differ)
# Comparação com objetos que não são Book retorna False
print(book1 == "1984") # Output: False
print(book1 == None) # Output: FalseComparando métodos gerados com implementação manual
Para valorizar o que as data classes oferecem, vamos comparar a versão com data class com uma implementação manual:
from dataclasses import dataclass
# Versão data class (concisa)
@dataclass
class PersonData:
first_name: str
last_name: str
age: int
# Versão manual equivalente (verbosa)
class PersonManual:
def __init__(self, first_name: str, last_name: str, age: int):
self.first_name = first_name
self.last_name = last_name
self.age = age
def __repr__(self):
return f"PersonManual(first_name={self.first_name!r}, last_name={self.last_name!r}, age={self.age})"
def __eq__(self, other):
if not isinstance(other, PersonManual):
return False
return (self.first_name == other.first_name and
self.last_name == other.last_name and
self.age == other.age)
# Ambas funcionam de forma idêntica
p1 = PersonData("Alice", "Johnson", 30)
p2 = PersonManual("Alice", "Johnson", 30)
print(p1) # Output: PersonData(first_name='Alice', last_name='Johnson', age=30)
print(p2) # Output: PersonManual(first_name='Alice', last_name='Johnson', age=30)A versão com data class alcança a mesma funcionalidade com bem menos código. Essa redução de boilerplate torna seu código mais fácil de ler, manter e modificar.
Adicionando métodos personalizados a Data Classes
Data classes podem ter métodos personalizados assim como classes comuns. O decorator @dataclass só gera os métodos de inicialização, representação e igualdade — você fica livre para adicionar qualquer outra funcionalidade:
from dataclasses import dataclass
@dataclass
class Temperature:
celsius: float
def to_fahrenheit(self):
"""Convert temperature to Fahrenheit."""
return (self.celsius * 9/5) + 32
def to_kelvin(self):
"""Convert temperature to Kelvin."""
return self.celsius + 273.15
def is_freezing(self):
"""Check if temperature is at or below freezing point."""
return self.celsius <= 0
temp = Temperature(25.0)
print(temp) # Output: Temperature(celsius=25.0)
print(f"{temp.celsius}°C = {temp.to_fahrenheit()}°F") # Output: 25.0°C = 77.0°F
print(f"Kelvin: {temp.to_kelvin()}") # Output: Kelvin: 298.15
print(f"Freezing: {temp.is_freezing()}") # Output: Freezing: False
cold_temp = Temperature(-5.0)
print(f"Freezing: {cold_temp.is_freezing()}") # Output: Freezing: TrueData classes cuidam das partes repetitivas (inicialização, representação e comparação) enquanto deixam você adicionar métodos personalizados para suas necessidades específicas, como mostrado com os métodos de conversão de temperatura acima.
33.4) Valores padrão e opções de campo
Data classes suportam valores padrão para atributos, permitindo que você crie instâncias sem especificar cada parâmetro. Você também pode usar a função field() para configurar comportamentos avançados como excluir atributos de comparações ou controlar como eles aparecem na representação em string.
Fornecendo valores padrão
Você pode atribuir valores padrão aos atributos diretamente na definição da classe. Atributos com padrão precisam vir depois de atributos sem padrão:
from dataclasses import dataclass
@dataclass
class User:
username: str
email: str
is_active: bool = True # Valor padrão
role: str = "user" # Valor padrão
# Criar instâncias com e sem valores padrão
user1 = User("alice", "alice@example.com")
print(user1) # Output: User(username='alice', email='alice@example.com', is_active=True, role='user')
user2 = User("bob", "bob@example.com", False, "admin")
print(user2) # Output: User(username='bob', email='bob@example.com', is_active=False, role='admin')
# Use keyword arguments to override specific defaults
user3 = User("charlie", "charlie@example.com", role="moderator")
print(user3) # Output: User(username='charlie', email='charlie@example.com', is_active=True, role='moderator')A regra de ordenação (atributos sem padrão antes de atributos com padrão) evita ambiguidade no método __init__ gerado. Essa é a mesma exigência para parâmetros de função com valores padrão, que aprendemos no Capítulo 20.
Valores padrão mutáveis e por que eles não são permitidos
Data classes protegem você de um erro comum com padrões mutáveis. Se você tentar usar diretamente um objeto mutável como uma lista ou um dicionário como padrão, você vai receber um erro:
from dataclasses import dataclass
# Isso vai levantar um erro
@dataclass
class ShoppingCart:
customer: str
items: list = [] # ValueError: mutable default <class 'list'> for field items is not allowed: use default_factoryEsse erro evita o mesmo problema que vimos com argumentos padrão de funções no Capítulo 20, em que todas as instâncias compartilhariam o mesmo objeto mutável.
Usando field() com default_factory para padrões mutáveis
A solução é usar a função field() com default_factory, que cria um novo valor padrão para cada instância:
from dataclasses import dataclass, field
@dataclass
class ShoppingCart:
customer: str
items: list = field(default_factory=list) # Correto: nova lista por instância
# Agora cada instância recebe sua própria lista
cart1 = ShoppingCart("Alice")
cart1.items.append("Book")
print(cart1.items) # Output: ['Book']
cart2 = ShoppingCart("Bob")
print(cart2.items) # Output: [] - Bob tem uma lista vazia
cart2.items.append("Laptop")
print(cart1.items) # Output: ['Book'] - Alice's cart unchanged
print(cart2.items) # Output: ['Laptop'] - Bob's cart independentO parâmetro default_factory recebe uma função (como list, dict ou set) que será chamada para criar um novo valor padrão toda vez que você criar uma instância sem fornecer esse atributo. Por exemplo, default_factory=list significa que o Python vai chamar list() para criar uma nova lista vazia para cada instância.
Excluindo campos da comparação
Às vezes você quer que certos atributos sejam excluídos de comparações de igualdade. Use field(compare=False) para isso:
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class LogEntry:
message: str
level: str
timestamp: datetime = field(compare=False) # Não comparar timestamps
# Criar duas entradas de log com a mesma mensagem, mas horários diferentes
entry1 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 30))
entry2 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 35))
# Elas são iguais porque timestamp é excluído da comparação
print(entry1 == entry2) # Output: True
# Mas elas têm timestamps diferentes
print(entry1.timestamp) # Output: 2024-01-15 10:30:00
print(entry2.timestamp) # Output: 2024-01-15 10:35:00Isso é útil quando você tem campos de metadados (como timestamps, IDs ou contadores internos) que não devem afetar se duas instâncias são consideradas iguais.
Excluindo campos da representação
Você também pode excluir campos da representação em string usando field(repr=False):
from dataclasses import dataclass, field
@dataclass
class Account:
username: str
email: str
password: str = field(repr=False) # Não mostrar password no repr
account = Account("alice", "alice@example.com", "secret123")
print(account) # Output: Account(username='alice', email='alice@example.com')
# Password não é mostrado, mas ainda é armazenado
print(account.password) # Output: secret123Isso é particularmente útil para dados sensíveis como senhas, chaves de API ou estruturas de dados grandes que deixariam a representação poluída.
Tornando Data Classes imutáveis com frozen=True
Por padrão, instâncias de data class são mutáveis — você pode mudar seus atributos após a criação. Se você quiser instâncias imutáveis (como tuplas), use frozen=True:
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
point = Point(3.0, 4.0)
print(point) # Output: Point(x=3.0, y=4.0)
# Tentar modificar levanta um erro
try:
point.x = 5.0
except AttributeError as e:
print(f"Error: {e}") # Output: Error: cannot assign to field 'x'Data classes frozen são úteis quando você quer garantir a integridade dos dados ou usar instâncias como chaves de dicionário (já que chaves de dicionário precisam ser imutáveis). Quando uma data class é frozen, o Python também gera um método __hash__, tornando as instâncias hashable:
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
latitude: float
longitude: float
# Instâncias frozen podem ser chaves de dicionário
locations = {
Coordinate(40.7128, -74.0060): "New York",
Coordinate(51.5074, -0.1278): "London",
Coordinate(35.6762, 139.6503): "Tokyo"
}
nyc = Coordinate(40.7128, -74.0060)
print(locations[nyc]) # Output: New York33.5) Inicialização personalizada com __post_init__
Às vezes você precisa fazer configuração adicional depois que o método __init__ gerado roda. O método __post_init__ é chamado automaticamente após a inicialização, permitindo que você valide dados, calcule atributos derivados ou execute outras tarefas de setup.
Uso básico de __post_init__
O método __post_init__ é chamado depois que todos os atributos foram definidos pelo __init__ gerado:
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
area: float = 0.0 # Será calculado em __post_init__
def __post_init__(self):
"""Calculate area after initialization."""
self.area = self.width * self.height
rect = Rectangle(5.0, 3.0)
print(rect) # Output: Rectangle(width=5.0, height=3.0, area=15.0)
print(f"Area: {rect.area}") # Output: Area: 15.0O método __post_init__ tem acesso a todos os atributos de instância que foram definidos durante a inicialização. Isso é útil para calcular valores derivados que dependem de múltiplos atributos.
Validando dados em post_init
Um uso comum de __post_init__ é validar se os dados fornecidos atendem a determinados requisitos:
from dataclasses import dataclass
@dataclass
class BankAccount:
account_number: str
balance: float
def __post_init__(self):
"""Validate account data."""
if self.balance < 0:
raise ValueError("Balance cannot be negative")
# Conta válida
account1 = BankAccount("ACC001", 1000.0)
print(account1) # Output: BankAccount(account_number='ACC001', balance=1000.0)
# Conta inválida - saldo negativo
try:
account2 = BankAccount("ACC002", -500.0)
except ValueError as e:
print(f"Error: {e}") # Output: Error: Balance cannot be negativeEssa validação garante que instâncias estejam sempre em um estado válido. Se os dados não atendem aos requisitos, a instância nunca é criada, impedindo que objetos inválidos existam no seu programa.
Usando post_init com field(init=False)
Às vezes você quer um atributo que é calculado em __post_init__, mas não deve ser um parâmetro em __init__. Use field(init=False) para isso:
from dataclasses import dataclass, field
import math
@dataclass
class Circle:
radius: float
area: float = field(init=False) # Não é um parâmetro em __init__
circumference: float = field(init=False)
def __post_init__(self):
"""Compute area and circumference from radius."""
self.area = math.pi * self.radius ** 2
self.circumference = 2 * math.pi * self.radius
# Só radius é exigido durante a inicialização
circle = Circle(5.0)
print(circle) # Output: Circle(radius=5.0, area=78.53981633974483, circumference=31.41592653589793)
print(f"Area: {circle.area:.2f}") # Output: Area: 78.54
print(f"Circumference: {circle.circumference:.2f}") # Output: Circumference: 31.42Esse padrão é útil quando você tem atributos que sempre são calculados a partir de outros atributos e nunca devem ser definidos diretamente durante a inicialização.
Data classes representam um recurso moderno do Python que reduz boilerplate enquanto mantém todo o poder das classes. Elas são particularmente valiosas para criar código limpo e legível ao trabalhar com dados estruturados. Conforme você continua aprendendo Python, você vai ver data classes se tornando uma escolha natural para muitas tarefas de programação centradas em dados, complementando as classes comuns que você aprendeu nos Capítulos 30-32.