Programma
Le data class sono una di quelle funzionalità di Python che, una volta scoperte, ti fanno dimenticare per sempre il vecchio modo di fare. Considera questa classe normale:
class Exercise:
def __init__(self, name, reps, sets, weight):
self.name = name
self.reps = reps
self.sets = sets
self.weight = weight
Per me, quella definizione di classe è molto inefficiente: nel metodo __init__ ripeti ogni parametro almeno tre volte. Può non sembrare un gran problema, ma pensa a quante volte nella tua vita scriverai classi con molti più parametri.
In confronto, dai un'occhiata all'alternativa con le data class del codice qui sopra:
from dataclasses import dataclass
@dataclass
class Exercise:
name: str
reps: int
sets: int
weight: float # Weight in lbs
Questo pezzetto di codice dall'aspetto modesto è di gran lunga migliore di una classe normale. Il piccolo decorator @dataclass implementa dietro le quinte le classi __init__, __repr__, __eq__, che a mano avrebbero richiesto almeno 20 righe di codice.
Inoltre, molte altre funzionalità, come gli operatori di confronto, l'ordinamento degli oggetti e l'immutabilità, sono a una sola riga di distanza dall'essere create magicamente per la nostra classe.
Quindi, lo scopo di questo tutorial è mostrarti perché le data class sono tra le cose migliori capitate a Python se ti piace la programmazione orientata agli oggetti.
Iniziamo!
Basi delle data class in Python
Copriamo alcune nozioni fondamentali delle data class di Python che le rendono così utili.
Alcuni metodi sono generati automaticamente nelle data class
Nonostante tutte le loro funzionalità, le data class sono classi normali che richiedono molto meno codice per implementare la stessa logica. Ecco di nuovo la classe Exercise:
from dataclasses import dataclass
@dataclass
class Exercise:
name: str
reps: int
sets: int
weight: float
ex1 = Exercise("Bench press", 10, 3, 52.5)
# Verifying Exercise is a regular class
ex1.name
'Bench press'
Al momento, Exercise ha già i metodi __repr__ e __eq__ implementati. Verifichiamolo:
repr(ex1)
"Exercise(name='Bench press', reps=10, sets=3, weight=52.5)"
La rappresentazione di un oggetto con repr deve restituire il codice in grado di ricrearlo, e possiamo vedere che è esattamente il caso di ex1.
In confronto, Exercise definita nel vecchio modo apparirebbe così:
class Exercise:
def __init__(self, name, reps, sets, weight):
self.name = name
self.reps = reps
self.sets = sets
self.weight = weight
ex3 = Exercise("Bench press", 10, 3, 52.5)
ex3
<__main__.Exercise at 0x7f6834100130>
Sembra davvero brutto e inutile!
Ora verifichiamo l'esistenza di __eq__, che è l'operatore di uguaglianza:
# Redefine the class
@dataclass
class Exercise:
name: str
reps: int
sets: int
weight: float
ex1 = Exercise("Bench press", 10, 3, 52.5)
ex2 = Exercise("Bench press", 10, 3, 52.5)
Confrontare la classe con se stessa e con un'altra classe con parametri identici deve restituire True:
ex1 == ex2
True
ex1 == ex1
True
E infatti! Nelle classi normali, questa logica sarebbe stata noiosa da scrivere.
Le data class richiedono gli hint di tipo
Come avrai notato, le data class richiedono gli hint di tipo quando si definiscono i campi. In effetti, le data class accettano qualsiasi tipo dal modulo typing. Per esempio, ecco come creare un campo che possa accettare il tipo Any:
from typing import Any
@dataclass
class Dummy:
attr: Any
Tuttavia, un'idiosincrasia di Python è che, sebbene le data class richiedano gli hint di tipo, i tipi non vengono effettivamente applicati.
Per esempio, creare un'istanza della classe Exercise con tipi di dato completamente errati può essere eseguito senza errori:
silly_exercise = Exercise("Bench press", "ten", "three sets", 52.5)
silly_exercise.sets
“Three sets”
Se vuoi far rispettare i tipi, devi usare type checker come Mypy.
Le data class consentono valori predefiniti nei campi
Finora non abbiamo aggiunto alcun valore predefinito alle nostre classi. Sistemiamo subito:
@dataclass
class Exercise:
name: str = "Push-ups"
reps: int = 10
sets: int = 3
weight: float = 0
# Now, all fields have defaults
ex5 = Exercise()
ex5
Exercise(name='Push-ups', reps=10, sets=3, weight=0)
Ricorda che i campi senza valore predefinito non possono seguire campi con valore predefinito. Per esempio, il codice sotto genererà un errore:
@dataclass
class Exercise:
name: str = "Push-ups"
reps: int = 10
sets: int = 3
weight: float # NOT ALLOWED
ex5 = Exercise()
ex5
TypeError: non-default argument 'weight' follows default argument
In pratica, raramente definirai i default con la sintassi name: type = value.
Al contrario, userai la funzione field, che permette un maggiore controllo su ogni definizione di campo:
from dataclasses import field
@dataclass
class Exercise:
name: str = field(default="Push-up")
reps: int = field(default=10)
sets: int = field(default=3)
weight: float = field(default=0)
# Now, all fields have defaults
ex5 = Exercise()
ex5
Exercise(name='Push-up', reps=10, sets=3, weight=0)
La funzione field ha altri parametri, come:
reprinitcomparedefault_factory
e così via. Ne parleremo nelle prossime sezioni.
Le data class possono essere create con una funzione
Un'ultima nota sulle basi delle data class: la loro definizione può essere ancora più breve usando la funzione make_dataclass:
from dataclasses import make_dataclass
Exercise = make_dataclass(
"Exercise",
[
("name", str),
("reps", int),
("sets", int),
("weight", float),
],
)
ex3 = Exercise("Deadlifts", 8, 3, 69.0)
ex3
Exercise(name='Deadlifts', reps=8, sets=3, weight=69.0)
Ma sacrificherai la leggibilità, quindi non consiglio di usare questa funzione.
Data class avanzate in Python
In questa sezione parleremo di funzionalità avanzate delle data class che portano ulteriori vantaggi. Una di queste è la factory di default.
Default factory
Per spiegare le factory di default, creiamo un'altra classe chiamata WorkoutSession che accetta due campi:
from dataclasses import dataclass
from typing import List
@dataclass
class Exercise:
name: str = "Push-ups"
reps: int = 10
sets: int = 3
weight: float = 0
@dataclass
class WorkoutSession:
exercises: List[Exercise]
duration_minutes: int
Usando il tipo List, stiamo specificando che WorkoutSession accetta una lista di istanze di Exercise.
# Define the Exercise instances for HIIT training
ex1 = Exercise(name="Burpees", reps=15, sets=3)
ex2 = Exercise(name="Mountain Climbers", reps=20, sets=3)
ex3 = Exercise(name="Jump Squats", reps=12, sets=3)
exercises_monday = [ex1, ex2, ex3]
hiit_monday = WorkoutSession(exercises=exercises_monday, duration_minutes=30)
Al momento, ogni istanza di workout richiede che gli esercizi siano inizializzati. Ma questo non rispecchia come si allenano le persone: prima avviano una sessione (probabilmente in un'app) e poi aggiungono gli esercizi man mano che si allenano.
Quindi dobbiamo poter creare sessioni senza esercizi e senza durata. Facciamolo aggiungendo una lista vuota come valore predefinito per exercises:
@dataclass
class WorkoutSession:
exercises: List[Exercise] = []
duration_minutes: int = None
hiit_monday = WorkoutSession("25-02-2024")
ValueError: mutable default <class 'list'> for field exercises is not allowed: use default_factory
Tuttavia, abbiamo ottenuto un errore: le data class non consentono valori predefiniti mutabili.
Per fortuna, possiamo risolvere usando una default factory:
@dataclass
class WorkoutSession:
exercises: List[Exercise] = field(default_factory=list) # PAY ATTENTION
duration_minutes: int = 0
hiit_monday = WorkoutSession()
hiit_monday
WorkoutSession(exercises=[], duration_minutes=0)
Il parametro default_factory accetta una funzione che restituisce un valore iniziale per un campo della data class. Questo significa che può accettare qualsiasi funzione arbitraria:
tupledictset- Qualsiasi funzione definita dall'utente
Questo è vero indipendentemente dal fatto che il risultato della funzione sia mutabile o meno.
Ora, se ci pensi, la maggior parte delle persone inizia l'allenamento con esercizi di riscaldamento che sono tipicamente simili per qualsiasi tipo di workout. Quindi inizializzare le sessioni senza esercizi potrebbe non essere ciò che alcuni vogliono.
Invece, creiamo una funzione che restituisca tre Exercise di riscaldamento:
def create_warmup():
return [
Exercise("Jumping jacks", 30, 1),
Exercise("Squat lunges", 10, 2),
Exercise("High jumps", 20, 1),
]
@dataclass
class WorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5 # Increase the default duration as well
hiit_monday = WorkoutSession()
hiit_monday
WorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), Exercise(name='Squat lunges', reps=10, sets=2, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0)], duration_minutes=5)
Ora, ogni volta che creiamo una sessione, arriverà con alcuni esercizi di riscaldamento già registrati. La nuova versione di WorkoutSession ha una durata predefinita di cinque minuti per tenerne conto.
Aggiungere metodi alle data class
Dato che le data class sono classi normali, aggiungere metodi funziona allo stesso modo. Aggiungiamo due metodi alla nostra data class WorkoutSession:
@dataclass
class WorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
def add_exercise(self, exercise: Exercise):
self.exercises.append(exercise)
def increase_duration(self, minutes: int):
self.duration_minutes += minutes
Usando questi metodi, ora possiamo registrare qualsiasi nuova attività in una sessione:
hiit_monday = WorkoutSession()
# Log a new exercise
new_exercise = Exercise("Deadlifts", 6, 4, 60)
hiit_monday.add_exercise(new_exercise)
hiit_monday.increase_duration(15)
Ma c'è un problema:
hiit_monday
WorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), Exercise(name='Squat lunges', reps=10, sets=2, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0), Exercise(name='Deadlifts', reps=6, sets=4, weight=60)], duration_minutes=20)
Quando stampiamo la sessione, la sua rappresentazione predefinita è troppo verbosa e illeggibile, poiché contiene il codice per ricreare l'oggetto. Sistemiamo.
__repr__ e __str__ nelle data class
Le data class implementano automaticamente __repr__ ma non __str__. Questo fa sì che la classe ricada su __repr__ quando la stampiamo con print.
Quindi, sovrascriviamo questo comportamento definendo noi stessi __str__:
@dataclass
class Exercise:
name: str = "Push-ups"
reps: int = 10
sets: int = 3
weight: float = 0
def __str__(self):
base = f"{self.name}: {self.reps}/{self.sets}"
if self.weight == 0:
return base
return base + f", {self.weight} lbs"
ex1 = Exercise(name="Burpees", reps=15, sets=3)
ex1
Exercise(name='Burpees', reps=15, sets=3, weight=0)
Il __repr__ è ancora lo stesso, ma quando chiamiamo print su di esso:
print(ex1)
Burpees: 15/3
La rappresentazione stringa della classe è molto più gradevole. Ora sistemiamo anche WorkoutSession:
@dataclass
class WorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5 # Increase the default duration as well
def add_exercise(self, exercise: Exercise):
self.exercises.append(exercise)
def increase_duration(self, minutes: int):
self.duration_minutes += minutes
def __str__(self):
base = ""
for ex in self.exercises:
base += str(ex) + "\n"
base += f"\nSession duration: {self.duration_minutes} minutes."
return base
hiit_monday = WorkoutSession()
print(hiit_monday)
Jumping jacks: 30/1
Squat lunges: 10/2
High jumps: 20/1
Session duration: 5 minutes.
Nota: usa il pulsante “Spiega il codice” in fondo allo snippet per ottenere una spiegazione riga per riga del codice.
Ora abbiamo un output leggibile e compatto.
Confronto nelle data class
Per molte classi ha senso confrontare i loro oggetti secondo una certa logica. Per i workout, può essere la durata, l'intensità dell'esercizio o il peso.
Per prima cosa, vediamo cosa succede se proviamo a confrontare due allenamenti nello stato attuale:
hiit_wednesday = WorkoutSession()
hiit_wednesday.add_exercise(Exercise("Pull-ups", 7, 3))
print(hiit_wednesday)
Jumping jacks: 30/1
Squat lunges: 10/2
High jumps: 20/1
Pull-ups: 7/3
Session duration: 5 minutes.
hiit_monday > hiit_wednesday
TypeError: '>' not supported between instances of 'WorkoutSession' and 'WorkoutSession'
Riceviamo un TypeError perché le data class non implementano gli operatori di confronto. Ma si può risolvere facilmente impostando il parametro order su True:
@dataclass(order=True)
class WorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
...
hiit_monday = WorkoutSession()
# hiit_monday.add_exercise(...)
hiit_monday.increase_duration(10)
hiit_wednesday = WorkoutSession()
hiit_monday > hiit_wednesday
True
Questa volta il confronto funziona, ma cosa stiamo confrontando esattamente?
Nelle data class, il confronto viene eseguito nell'ordine in cui sono definiti i campi. Al momento, le classi vengono confrontate in base alla durata dell'allenamento, dato che il primo campo, exercises, contiene oggetti non standard.
Possiamo verificarlo aumentando la durata della sessione del mercoledì:
hiit_monday = WorkoutSession()
# hiit_monday.add_exercise(...)
hiit_wednesday = WorkoutSession()
hiit_wednesday.increase_duration(10)
hiit_monday > hiit_wednesday
False
Come previsto, abbiamo ottenuto False.
Ma cosa succederebbe se il primo campo di Workout fosse di un altro tipo, diciamo una stringa? Proviamo a scoprirlo:
@dataclass(order=True)
class WorkoutSession:
date: str = None # DD-MM-YYYY
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
...
hiit_monday = WorkoutSession("25-02-2024")
hiit_monday.increase_duration(10)
hiit_wednesday = WorkoutSession("27-02-2024")
hiit_monday > hiit_wednesday
False
Anche se la sessione di lunedì dura di più, il confronto ci dice che è minore di quella di mercoledì. Il motivo è che “25” viene prima di “27” nel confronto tra stringhe in Python.
Quindi, come manteniamo l'ordine dei campi e allo stesso tempo ordiniamo le sessioni in base alla durata dell'allenamento? È facile con la funzione field:
@dataclass(order=True)
class WorkoutSession:
date: str = field(default=None, compare=False)
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
...
hiit_monday = WorkoutSession("25-02-2024")
hiit_monday.increase_duration(10)
hiit_wednesday = WorkoutSession("27-02-2024")
hiit_monday > hiit_wednesday
True
Impostando compare su False per un campo, lo escludiamo dall'ordinamento, come dimostra il risultato sopra.
Manipolazione dei campi post-init
Al momento, abbiamo una durata predefinita della sessione di cinque minuti per tener conto degli esercizi di riscaldamento. Tuttavia, questo ha senso solo se un utente avvia una sessione con un riscaldamento. E se iniziasse con altri esercizi?
new_session = WorkoutSession([Exercise("Diamond push-ups", 10, 3)])
new_session.duration_minutes
5
Per un solo esercizio, la durata totale è di cinque minuti, il che è illogico. Ogni sessione deve stimare dinamicamente la sua durata in base al numero di serie di ciascun esercizio. Questo significa che dovremmo rendere duration_minutes dipendente dal campo exercises.
Implementiamolo:
@dataclass
class WorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = field(default=0, init=False)
def __post_init__(self):
set_duration = 3
for ex in self.exercises:
self.duration_minutes += ex.sets * set_duration
...
Questa volta stiamo definendo duration_minutes con init impostato su False per ritardare l'inizializzazione del campo.
Poi, all'interno del metodo speciale __post_init__, aggiorniamo il suo valore in base al numero totale di serie in ogni Exercise.
Ora, quando inizializziamo WorkoutSession, duration_minutes aumenta dinamicamente di tre minuti per ogni serie in ciascun esercizio.
# Adding an exercise with three sets
hiit_friday = WorkoutSession([Exercise("Diamond push-ups", 10, 3)])
hiit_friday.duration_minutes
9
In generale, se vuoi definire un campo che dipende da altri campi della tua data class, puoi usare la logica di __post_init__.
Immutabilità nelle data class
La nostra data class WorkoutSession è quasi pronta; va solo protetta. Al momento, può essere rovinata piuttosto facilmente:
hiit_friday.duration_minutes = 1000
hiit_friday.duration_minutes
1000
del hiit_friday.exercises
Vogliamo proteggere tutti i campi delle nostre classi in modo che possano essere modificati solo come vogliamo. Per farlo, il decorator @dataclass offre un comodo argomento frozen:
@dataclass(frozen=True)
class FrozenExercise:
name: str
reps: int
sets: int
weight: int | float = 0
ex1 = FrozenExercise("Muscle-ups", 5, 3)
Ora, se vogliamo modificare un campo, otteniamo un errore:
ex1.sets = 5
FrozenInstanceError: cannot assign to field 'sets'
Impostare frozen su True aggiunge automaticamente i metodi __deleteattr__ e __setattr__ per ogni campo, così che siano protetti da eliminazioni o aggiornamenti dopo l'inizializzazione. Inoltre, non sarà possibile aggiungere nuovi campi:
ex1.new_field = 10
FrozenInstanceError: cannot assign to field 'new_field'
Questa funzionalità richiederebbe decine di righe di codice se stessimo usando classi tradizionali.
Tieni però presente che non possiamo rendere le nostre classi veramente immutabili. Per esempio, riscriviamo WorkoutSession con frozen impostato su True:
@dataclass(frozen=True)
class ImmutableWorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
session1 = ImmutableWorkoutSession()
Come previsto, non possiamo modificare direttamente la lista degli esercizi:
session1.exercises = [Exercise()]
Tuttavia, exercises è una lista, quindi pienamente mutabile, il che rende possibile la seguente operazione:
# Modificare uno degli elementi in una lista
# Changing one of the elements in a list
session1.exercises[1] = FrozenExercise("Totally new exercise", 5, 5)
print(session1)
ImmutableWorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), FrozenExercise(name='Totally new exercise', reps=5, sets=5, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0)], duration_minutes=5)
Quindi, per proteggersi da modifiche accidentali, si consiglia di usare oggetti immutabili come le tuple per i valori dei campi.
Ereditarietà nelle data class
Un ultimo punto che tratteremo è l'ordine dei campi nelle classi genitore e figlie.
Poiché le data class sono classi normali, l'ereditarietà funziona come al solito:
@dataclass(frozen=True)
class ImmutableWorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
@dataclass(frozen=True)
class CardioWorkoutSession(ImmutableWorkoutSession):
pass
Ma, dato che l'ultimo campo nella classe genitore (ImmutableWorkoutSession) ha un valore predefinito, tutti i campi nelle classi figlie devono avere valori predefiniti.
Per esempio, questo non è consentito:
@dataclass(frozen=True)
class ImmutableWorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
@dataclass(frozen=True)
class CardioWorkoutSession(ImmutableWorkoutSession):
intensity_level: str # Not allowed, must have a default
TypeError: non-default argument 'intensity_level' follows default argument
Svantaggi delle data class e risorse aggiuntive
Le data class sono migliorate costantemente da Python 3.7 (erano ottime già all'inizio) e coprono molti casi d'uso in cui potresti dover scrivere classi. Ma possono essere svantaggiose nei seguenti scenari:
- Metodi
__init__personalizzati - Metodi
__new__personalizzati - Vari pattern di ereditarietà
E molti altri, come discusso in questo ottimo thread su Reddit. Se vuoi una motivazione più dettagliata sul perché sono state introdotte le data class e perché non sono sostituti diretti delle definizioni di classi tradizionali, leggi PEP 557.
Se ti interessa la programmazione orientata agli oggetti in generale, ecco un corso per proseguire il tuo percorso:
Fondamentalmente, le data class sono strutture più eleganti per conservare e recuperare i dati in modo più efficiente. Tuttavia, Python ha molte altre strutture dati che svolgono questo compito in modo più o meno simile. Per esempio, puoi imparare a conoscere i counter, i defaultdict e i namedtuple nell'ultimo capitolo del corso Data Types for Data Science.

Sono un creator di contenuti sulla data science con oltre 2 anni di esperienza e uno dei profili con più seguito su Medium. Mi piace scrivere articoli dettagliati su AI e ML con un pizzico di sarcasmo, perché qualcosa bisogna pur fare per renderli un po' meno noiosi. Ho pubblicato più di 130 articoli e anche un corso su DataCamp, con un altro in arrivo. I miei contenuti sono stati visti da oltre 5 milioni di occhi, e 20.000 di loro sono diventati follower sia su Medium che su LinkedIn.