Python & AI Tutorials Logo
Programmazione Python

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:

python
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: True

Questo 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.

Classe normale

init manuale

repr manuale

eq manuale

Metodi personalizzati

Data class

Decoratore @dataclass

init generato automaticamente

repr generato automaticamente

eq generato automaticamente

Metodi personalizzati

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

python
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:

  1. @dataclass: applicare questo decoratore fa sì che Python scriva automaticamente per te i metodi __init__, __repr__ e __eq__

  2. __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

  3. __repr__ automatico: Python crea una rappresentazione testuale che mostra il nome della classe e tutti i valori degli attributi

  4. __eq__ automatico: Python crea un metodo di confronto di uguaglianza che confronta tutti gli attributi

  5. Converte le annotazioni di tipo in attributi di istanza: in una classe normale, scrivere name: str nel corpo della classe crea un attributo di classe. Ma il decoratore @dataclass cambia questo comportamento: usa queste annotazioni di tipo per definire invece attributi di istanza. Ogni istanza ottiene i propri attributi name, student_id e gpa.

La differenza principale rispetto alle classi normali:

python
# 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: int

Comprendere le annotazioni di tipo nelle data class

Nelle data class, le annotazioni di tipo definiscono gli attributi e documentano i tipi attesi:

python
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 str

Python 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:

python
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:

python
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.0

Il __init__ generato è equivalente a scrivere:

python
def __init__(self, width: float, height: float):
    self.width = width
    self.height = height

Questa 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:

python
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:

python
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:

python
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: False

Confrontare i metodi generati con l’implementazione manuale

Per apprezzare cosa offrono le data class, confrontiamo la versione data class con un’implementazione manuale:

python
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à:

python
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: True

Le 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:

python
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:

python
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_factory

Questo 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:

python
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 independent

Il 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:

python
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:00

Questo è 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):

python
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: secret123

Questo è 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:

python
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:

python
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 York

33.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:

python
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.0

Il 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:

python
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 negative

Questa 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:

python
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.42

Questo 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.


© 2025. Primesoft Co., Ltd.
support@primesoft.ai