33. Data class per dati strutturati semplici
Nel Capitolo 30 abbiamo imparato a creare classi per definire i nostri tipi. Abbiamo scritto metodi __init__ per inizializzare le istanze, metodi __repr__ per visualizzarle e metodi __eq__ per confrontarle. Anche se questo approccio funziona perfettamente, comporta la scrittura di molto codice ripetitivo, soprattutto quando una classe esiste principalmente per memorizzare dati.
Le data class (data classes) di Python offrono un modo più pulito e conciso per creare classi che sono principalmente contenitori di dati. Usando il decoratore @dataclass, Python genera automaticamente metodi comuni come __init__, __repr__ e __eq__ in base agli attributi della classe che definisci. Questo riduce il codice boilerplate e rende più chiare le tue intenzioni.
33.1) Cosa sono le data class e quando usarle
Una data class (data class) è una classe progettata principalmente per memorizzare valori di dati. Invece di scrivere manualmente metodi di inizializzazione e confronto, definisci gli attributi che la tua classe dovrebbe avere e Python genera automaticamente i metodi necessari.
Perché le data class sono importanti
Considera una classe normale per rappresentare un libro:
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: TrueQuesto funziona, ma nota quanto codice abbiamo scritto solo per memorizzare tre pezzi di informazione. I metodi __init__, __repr__ e __eq__ seguono schemi prevedibili: si limitano a gestire gli attributi che abbiamo definito.
Le data class eliminano questa ripetizione. Sono particolarmente utili quando:
- La tua classe memorizza principalmente dati invece di implementare comportamenti complessi
- Ti servono metodi standard come inizializzazione, rappresentazione come stringa e confronto di uguaglianza
- Vuoi codice più chiaro e manutenibile con meno boilerplate
- Stai creando oggetti di configurazione, oggetti di trasferimento dati o record semplici
Le data class non sostituiscono le classi normali: le completano. Usa classi normali quando ti serve logica di inizializzazione personalizzata, metodi complessi o gerarchie di ereditarietà. Usa le data class quando ti serve soprattutto un contenitore strutturato per dati correlati.
La relazione tra data class e classi normali
Le data class sono comunque classi Python normali. Supportano tutte le funzionalità che abbiamo imparato nei Capitoli 30-32: metodi, proprietà, ereditarietà e metodi speciali. Il decoratore @dataclass automatizza semplicemente la creazione di metodi comuni, evitandoti di scrivere codice ripetitivo.
33.2) Creare data class con @dataclass
Per creare una data class, importi il decoratore dataclass dal modulo dataclasses e lo applichi alla definizione della tua classe. All’interno della classe, definisci attributi di classe con annotazioni di tipo che specificano quali dati la classe dovrebbe contenere.
Sintassi base di una data class
from dataclasses import dataclass
@dataclass
class Student:
name: str
student_id: int
gpa: float
# Crea istanze
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)Scomponiamo cosa fa il decoratore @dataclass:
-
@dataclass: applicare questo decoratore fa sì che Python scriva automaticamente per te i metodi__init__,__repr__e__eq__ -
__init__automatico: Python crea un metodo di inizializzazione che accetta questi tre parametri nell’ordine in cui sono definiti e li assegna agli attributi dell’istanza -
__repr__automatico: Python crea una rappresentazione testuale che mostra il nome della classe e tutti i valori degli attributi -
__eq__automatico: Python crea un metodo di confronto di uguaglianza che confronta tutti gli attributi -
Converte le annotazioni di tipo in attributi di istanza: in una classe normale, scrivere
name: strnel corpo della classe crea un attributo di classe. Ma il decoratore@dataclasscambia questo comportamento: usa queste annotazioni di tipo per definire invece attributi di istanza. Ogni istanza ottiene i propri attributiname,student_idegpa.
La differenza principale rispetto alle classi normali:
# Classe normale - questi sono attributi di classe (condivisi da tutte le istanze)
class RegularStudent:
name: str
student_id: int
# Data class - questi diventano attributi di istanza (ogni istanza ha i propri)
@dataclass
class DataStudent:
name: str
student_id: intComprendere le annotazioni di tipo nelle data class
Nelle data class, le annotazioni di tipo definiscono gli attributi e documentano i tipi attesi:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
in_stock: bool
# Usare i tipi corretti come documentato
laptop = Product("Laptop", 999.99, True)
print(laptop) # Output: Product(name='Laptop', price=999.99, in_stock=True)
# Python non applica i tipi - questo gira senza errori
macbook = Product("Macbook", "expensive", True)
print(macbook) # Output: Product(name='Macbook', price='expensive', in_stock=True)
# Ma usare tipi sbagliati causerà problemi più avanti:
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 strPython non ti impedirà di passare tipi sbagliati quando crei un’istanza di una data class. Le annotazioni di tipo sono principalmente documentazione: dicono ad altri programmatori (e a strumenti di controllo dei tipi come mypy) quali tipi ti aspetti, ma Python non le impone a runtime. Questo è coerente con la filosofia della tipizzazione dinamica di Python.
Tuttavia, seguire le annotazioni di tipo rende il tuo codice più prevedibile e più facile da fare debugging. Quando usi tipi sbagliati, gli errori compariranno più tardi quando cerchi di usare i dati, rendendo i bug più difficili da rintracciare. Gli strumenti di type-checking possono intercettare queste incongruenze prima di eseguire il codice, aiutandoti a trovare i problemi in anticipo.
Accedere e modificare gli attributi
Le istanze di una data class funzionano esattamente come le istanze di una classe normale. Accedi e modifichi gli attributi usando la notazione con il punto:
from dataclasses import dataclass
@dataclass
class Employee:
name: str
position: str
salary: float
emp = Employee("Sarah Chen", "Software Engineer", 95000.0)
# Accedere agli attributi
print(emp.name) # Output: Sarah Chen
print(emp.position) # Output: Software Engineer
# Modificare gli attributi
emp.salary = 100000.0
emp.position = "Senior Software Engineer"
print(emp) # Output: Employee(name='Sarah Chen', position='Senior Software Engineer', salary=100000.0)Le data class sono mutabili per impostazione predefinita: puoi cambiare i loro attributi dopo la creazione. Questo è diverso dalle tuple o dalle named tuple, che sono immutabili. Se ti serve l’immutabilità, puoi configurare la data class con frozen=True (lo esploreremo nella Sezione 33.4).
33.3) Metodi generati: __init__, __repr__ e __eq__
Il decoratore @dataclass genera automaticamente tre metodi essenziali. Capire cosa fanno questi metodi ti aiuta a usare le data class in modo efficace e a sapere quando personalizzarle.
Il metodo __init__ generato
Il metodo __init__ inizializza una nuova istanza con i valori forniti. Python lo genera in base all’ordine delle definizioni dei tuoi attributi:
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
# Il __init__ generato accetta width e height in quell'ordine
rect = Rectangle(10.5, 5.0)
print(rect.width) # Output: 10.5
print(rect.height) # Output: 5.0
# Puoi anche usare argomenti keyword
rect2 = Rectangle(height=8.0, width=12.0)
print(rect2.width) # Output: 12.0
print(rect2.height) # Output: 8.0Il __init__ generato è equivalente a scrivere:
def __init__(self, width: float, height: float):
self.width = width
self.height = heightQuesta generazione automatica ti evita di scrivere codice di inizializzazione ripetitivo, specialmente per classi con molti attributi.
Il metodo __repr__ generato
Il metodo __repr__ fornisce una rappresentazione testuale dell’istanza che mostra tutti i valori degli attributi. Questo è prezioso per il debugging e il 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')Il __repr__ generato segue la convenzione di mostrare il nome della classe e tutti gli attributi in un formato che potrebbe essere usato per ricreare l’oggetto. Questo è molto più utile della rappresentazione predefinita che otterresti senza __repr__: <__main__.Point object at 0x...>.
Il metodo __eq__ generato
Il metodo __eq__ abilita il confronto di uguaglianza tra istanze. Due istanze di data class sono considerate uguali se tutti i loro attributi corrispondenti sono uguali:
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)Questo confronto di uguaglianza automatico si basa sull’uguaglianza per valore, non sull’identità. Anche se color1 e color2 sono oggetti diversi in memoria (come mostra is), sono considerati uguali perché i loro attributi corrispondono.
Il metodo __eq__ generato confronta gli attributi nell’ordine in cui sono definiti:
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)
# Il confronto con oggetti non-Book restituisce False
print(book1 == "1984") # Output: False
print(book1 == None) # Output: FalseConfrontare i metodi generati con l’implementazione manuale
Per apprezzare cosa offrono le data class, confrontiamo la versione data class con un’implementazione manuale:
from dataclasses import dataclass
# Versione data class (concisa)
@dataclass
class PersonData:
first_name: str
last_name: str
age: int
# Versione manuale 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)
# Entrambe funzionano in modo identico
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)La versione data class ottiene la stessa funzionalità con molto meno codice. Questa riduzione del boilerplate rende il tuo codice più facile da leggere, mantenere e modificare.
Aggiungere metodi personalizzati alle data class
Le data class possono avere metodi personalizzati proprio come le classi normali. Il decoratore @dataclass genera solo i metodi di inizializzazione, rappresentazione e uguaglianza: sei libero di aggiungere qualsiasi altra funzionalità:
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: TrueLe data class gestiscono le parti ripetitive (inizializzazione, rappresentazione e confronto) lasciandoti aggiungere metodi personalizzati per le tue esigenze specifiche, come mostrato sopra con i metodi di conversione della temperatura.
33.4) Valori predefiniti e opzioni dei campi
Le data class supportano valori predefiniti per gli attributi, consentendoti di creare istanze senza specificare ogni parametro. Puoi anche usare la funzione field() per configurare comportamenti avanzati come escludere attributi dai confronti o controllare come compaiono nella rappresentazione come stringa.
Fornire valori predefiniti
Puoi assegnare valori predefiniti agli attributi direttamente nella definizione della classe. Gli attributi con valori predefiniti devono venire dopo gli attributi senza valori predefiniti:
from dataclasses import dataclass
@dataclass
class User:
username: str
email: str
is_active: bool = True # Valore predefinito
role: str = "user" # Valore predefinito
# Crea istanze con e senza valori predefiniti
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')
# Usa argomenti keyword per sovrascrivere specifici valori predefiniti
user3 = User("charlie", "charlie@example.com", role="moderator")
print(user3) # Output: User(username='charlie', email='charlie@example.com', is_active=True, role='moderator')La regola dell’ordine (attributi senza default prima degli attributi con default) evita ambiguità nel metodo __init__ generato. È lo stesso requisito dei parametri di funzione con valori predefiniti, che abbiamo imparato nel Capitolo 20.
Valori predefiniti mutabili e perché non sono consentiti
Le data class ti proteggono da un errore comune con i default mutabili. Se provi a usare direttamente come default un oggetto mutabile come una lista o un dizionario, otterrai un errore:
from dataclasses import dataclass
# Questo solleverà un errore
@dataclass
class ShoppingCart:
customer: str
items: list = [] # ValueError: mutable default <class 'list'> for field items is not allowed: use default_factoryQuesto errore previene lo stesso problema che abbiamo visto con gli argomenti predefiniti delle funzioni nel Capitolo 20, in cui tutte le istanze condividerebbero lo stesso oggetto mutabile.
Usare field() con default_factory per default mutabili
La soluzione è usare la funzione field() con default_factory, che crea un nuovo valore predefinito per ogni istanza:
from dataclasses import dataclass, field
@dataclass
class ShoppingCart:
customer: str
items: list = field(default_factory=list) # Corretto: nuova lista per istanza
# Ora ogni istanza ottiene la propria lista
cart1 = ShoppingCart("Alice")
cart1.items.append("Book")
print(cart1.items) # Output: ['Book']
cart2 = ShoppingCart("Bob")
print(cart2.items) # Output: [] - Bob has an empty list
cart2.items.append("Laptop")
print(cart1.items) # Output: ['Book'] - Alice's cart unchanged
print(cart2.items) # Output: ['Laptop'] - Bob's cart independentIl parametro default_factory accetta una funzione (come list, dict o set) che verrà chiamata per creare un nuovo valore predefinito ogni volta che crei un’istanza senza fornire quell’attributo. Per esempio, default_factory=list significa che Python chiamerà list() per creare una nuova lista vuota per ogni istanza.
Escludere campi dal confronto
A volte vuoi che certi attributi siano esclusi dai confronti di uguaglianza. Usa field(compare=False) per questo:
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class LogEntry:
message: str
level: str
timestamp: datetime = field(compare=False) # Non confrontare i timestamp
# Crea due voci di log con lo stesso messaggio ma orari diversi
entry1 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 30))
entry2 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 35))
# Sono uguali perché timestamp è escluso dal confronto
print(entry1 == entry2) # Output: True
# Ma hanno timestamp diversi
print(entry1.timestamp) # Output: 2024-01-15 10:30:00
print(entry2.timestamp) # Output: 2024-01-15 10:35:00Questo è utile quando hai campi di metadati (come timestamp, ID o contatori interni) che non dovrebbero influire sul fatto che due istanze siano considerate uguali.
Escludere campi dalla rappresentazione
Puoi anche escludere campi dalla rappresentazione come stringa usando field(repr=False):
from dataclasses import dataclass, field
@dataclass
class Account:
username: str
email: str
password: str = field(repr=False) # Non mostrare password in repr
account = Account("alice", "alice@example.com", "secret123")
print(account) # Output: Account(username='alice', email='alice@example.com')
# La password non è mostrata, ma è comunque memorizzata
print(account.password) # Output: secret123Questo è particolarmente utile per dati sensibili come password, chiavi API o strutture dati grandi che ingombrerebbero la rappresentazione.
Rendere le data class immutabili con frozen=True
Per impostazione predefinita, le istanze di data class sono mutabili: puoi cambiare i loro attributi dopo la creazione. Se vuoi istanze immutabili (come le tuple), usa 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)
# Tentare di modificare solleva un errore
try:
point.x = 5.0
except AttributeError as e:
print(f"Error: {e}") # Output: Error: cannot assign to field 'x'Le data class congelate (frozen) sono utili quando vuoi garantire l’integrità dei dati o usare istanze come chiavi di dizionario (dato che le chiavi dei dizionari devono essere immutabili). Quando una data class è congelata, Python genera anche un metodo __hash__, rendendo le istanze hashable:
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
latitude: float
longitude: float
# Le istanze frozen possono essere chiavi di dizionario
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) Inizializzazione personalizzata con __post_init__
A volte hai bisogno di eseguire configurazioni aggiuntive dopo che il metodo __init__ generato è stato eseguito. Il metodo __post_init__ viene chiamato automaticamente dopo l’inizializzazione, permettendoti di validare i dati, calcolare attributi derivati o svolgere altre attività di setup.
Uso base di __post_init__
Il metodo __post_init__ viene chiamato dopo che tutti gli attributi sono stati impostati dal __init__ generato:
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
area: float = 0.0 # Verrà calcolata in __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.0Il metodo __post_init__ ha accesso a tutti gli attributi dell’istanza che sono stati impostati durante l’inizializzazione. Questo è utile per calcolare valori derivati che dipendono da più attributi.
Validare i dati in post_init
Un uso comune di __post_init__ è validare che i dati forniti soddisfino certi requisiti:
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")
# Account valido
account1 = BankAccount("ACC001", 1000.0)
print(account1) # Output: BankAccount(account_number='ACC001', balance=1000.0)
# Account non valido - saldo negativo
try:
account2 = BankAccount("ACC002", -500.0)
except ValueError as e:
print(f"Error: {e}") # Output: Error: Balance cannot be negativeQuesta validazione assicura che le istanze siano sempre in uno stato valido. Se i dati non soddisfano i requisiti, l’istanza non viene mai creata, impedendo che oggetti non validi esistano nel tuo programma.
Usare post_init con field(init=False)
A volte vuoi un attributo calcolato in __post_init__ ma che non dovrebbe essere un parametro in __init__. Usa field(init=False) per questo:
from dataclasses import dataclass, field
import math
@dataclass
class Circle:
radius: float
area: float = field(init=False) # Non è un parametro in __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
# Durante l'inizializzazione è richiesto solo radius
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.42Questo pattern è utile quando hai attributi che sono sempre calcolati a partire da altri attributi e che non dovrebbero mai essere impostati direttamente durante l’inizializzazione.
Le data class rappresentano una moderna funzionalità di Python che riduce il boilerplate mantenendo tutta la potenza delle classi. Sono particolarmente preziose per creare codice pulito e leggibile quando si lavora con dati strutturati. Man mano che continui a imparare Python, scoprirai che le data class diventano una scelta naturale per molti compiti di programmazione incentrati sui dati, completando le classi normali che hai imparato nei Capitoli 30-32.