Leerpad
Dataclasses zijn zo’n Python-functie waarvan je, zodra je ze ontdekt, niet meer terug wilt naar hoe je het vroeger deed. Neem deze gewone class:
class Exercise:
def __init__(self, name, reps, sets, weight):
self.name = name
self.reps = reps
self.sets = sets
self.weight = weight
Voor mij is die class-definitie erg inefficiënt: in de __init__-methode herhaal je elke parameter minstens drie keer. Dat klinkt misschien niet als een groot probleem, maar denk eens aan hoe vaak je in je leven classes met veel meer parameters schrijft.
Vergelijk dat eens met het dataclasses-alternatief van bovenstaande code:
from dataclasses import dataclass
@dataclass
class Exercise:
name: str
reps: int
sets: int
weight: float # Weight in lbs
Dit bescheiden stukje code is vele malen beter dan een gewone class. De kleine @dataclass-decorator zorgt achter de schermen voor __init__-, __repr__- en __eq__-methodes, die je handmatig al gauw minstens 20 regels zouden kosten.
Daarnaast krijg je met één enkele regel ook toegang tot veel andere features, zoals vergelijkingsoperatoren, objectordering en immutabiliteit.
Het doel van deze tutorial is om je te laten zien waarom dataclasses een van de beste toevoegingen aan Python zijn als je van objectgeoriënteerd programmeren houdt.
Laten we beginnen!
Basisprincipes van Python-dataclasses
We behandelen eerst een paar fundamentele concepten van Python-dataclasses die ze zo nuttig maken.
Sommige methodes worden automatisch gegenereerd in dataclasses
Ondanks al hun features zijn dataclasses gewone classes die veel minder code nodig hebben om dezelfde functionaliteit te bieden. Hier is de class Exercise nog eens:
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'
Op dit moment heeft Exercise al __repr__- en __eq__-methodes geïmplementeerd. Laten we dat verifiëren:
repr(ex1)
"Exercise(name='Bench press', reps=10, sets=3, weight=52.5)"
De objectrepresentatie repr moet code teruggeven waarmee het object zichzelf kan reproduceren, en dat is precies wat we bij ex1 zien.
Ter vergelijking: Exercise gedefinieerd op de oude manier ziet er zo uit:
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>
Ziet er behoorlijk beroerd en nutteloos uit!
Laten we nu het bestaan van __eq__ controleren, de gelijkheidsoperator:
# 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)
Het vergelijken van de class met zichzelf en met een andere class met identieke parameters moet True opleveren:
ex1 == ex2
True
ex1 == ex1
True
En dat doet het! In gewone classes zou deze logica vervelend zijn om te schrijven.
Dataclasses vereisen typehints
Zoals je misschien is opgevallen, vereisen dataclasses typehints bij het definiëren van velden. Dataclasses accepteren zelfs elk type uit de module typing. Zo maak je bijvoorbeeld een veld dat elk Any-datatype kan accepteren:
from typing import Any
@dataclass
class Dummy:
attr: Any
Toch is het typisch Python dat, hoewel dataclasses typehints vereisen, types niet daadwerkelijk worden afgedwongen.
Zo kun je zonder fouten een instantie van Exercise maken met volledig onjuiste datatypes:
silly_exercise = Exercise("Bench press", "ten", "three sets", 52.5)
silly_exercise.sets
“Three sets”
Als je datatypes wilt afdwingen, moet je typecheckers gebruiken zoals Mypy.
Dataclasses staan standaardwaarden in velden toe
Tot nu toe hebben we geen defaults aan onze classes toegevoegd. Laten we dat oplossen:
@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)
Onthoud dat velden zonder default niet na velden met default mogen komen. De onderstaande code gooit bijvoorbeeld een foutmelding:
@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 de praktijk definieer je zelden defaults met de syntax name: type = value.
In plaats daarvan gebruik je de functie field, die meer controle geeft over elke velddefinitie:
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)
De functie field heeft meer parameters, zoals:
reprinitcomparedefault_factory
enzovoort. We bespreken deze in de volgende secties.
Dataclasses kunnen met een functie worden aangemaakt
Tot slot kun je de definitie van een dataclass nog korter maken met de functie 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)
Maar je levert in op leesbaarheid, dus ik raad niet aan om deze functie te gebruiken.
Geavanceerde Python-dataclasses
In deze sectie bespreken we geavanceerde features van dataclasses die extra voordelen bieden. Een daarvan is een default factory.
Default factories
Om default factories uit te leggen, maken we een andere class genaamd WorkoutSession die twee velden accepteert:
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
Door het type List te gebruiken, geven we aan dat WorkoutSession een lijst van Exercise-instanties accepteert.
# 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)
Op dit moment vereist elke sessie-instantie dat er oefeningen worden meegegeven bij de initialisatie. Maar zo trainen mensen niet: eerst starten ze een sessie (waarschijnlijk in een app) en vervolgens voegen ze oefeningen toe tijdens het trainen.
We moeten dus sessies kunnen maken zonder oefeningen en zonder duur. Laten we dit doen door een lege lijst als standaardwaarde voor exercises toe te voegen:
@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
We krijgen echter een foutmelding: dataclasses staan geen muteerbare standaardwaarden toe.
Gelukkig lossen we dit op met een 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)
De parameter default_factory accepteert een functie die een beginwaarde voor een dataclass-veld retourneert. Dat betekent dat hij elke willekeurige functie kan accepteren:
tupledictset- Elke door de gebruiker gedefinieerde functie
Dit geldt ongeacht of het resultaat van de functie muteerbaar is of niet.
Bedenk nu dat de meeste mensen hun training starten met warming-upoefeningen die vaak op elkaar lijken, ongeacht het soort workout. Sessies initialiseren zonder oefeningen is dus misschien niet wat sommige mensen willen.
Laten we in plaats daarvan een functie maken die drie warming-up-Exercises retourneert:
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)
Vanaf nu worden sessies standaard aangemaakt met een paar warming-upoefeningen. De nieuwe versie van WorkoutSession heeft daarom een standaardduur van vijf minuten.
Methodes toevoegen aan dataclasses
Omdat dataclasses gewone classes zijn, blijft het toevoegen van methodes hetzelfde. Laten we twee methodes toevoegen aan onze WorkoutSession-dataclass:
@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
Met deze methodes kunnen we nu nieuwe activiteiten in een sessie loggen:
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)
Maar er is een probleem:
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)
Als we de sessie printen, is de standaardrepresentatie te lang en slecht leesbaar omdat hij de code bevat om het object te reconstrueren. Laten we dat oplossen.
__repr__ en __str__ in dataclasses
Dataclasses implementeren __repr__ automatisch, maar niet __str__. Daarom valt de class terug op __repr__ wanneer we hem printen.
Laten we dit gedrag overschrijven door zelf __str__ te definiëren:
@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)
__repr__ is nog hetzelfde, maar wanneer we print aanroepen:
print(ex1)
Burpees: 15/3
De tekstuele representatie van de class is een stuk prettiger. Laten we nu ook WorkoutSession verbeteren:
@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.
Let op: Gebruik de knop “Explain code” onderaan de snippet voor een uitleg regel-voor-regel.
Nu hebben we een leesbare en compacte output.
Vergelijken in dataclasses
Voor veel classes is het logisch om objecten volgens bepaalde regels te vergelijken. Bij workouts kan dat de duur zijn, de intensiteit van de oefening of het gewicht.
Laten we eerst kijken wat er nu gebeurt als we twee workouts vergelijken:
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'
We krijgen een TypeError omdat dataclasses geen vergelijkingsoperatoren implementeren. Maar dat los je eenvoudig op door de parameter order op True te zetten:
@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
Deze keer werkt vergelijken, maar wat vergelijken we eigenlijk?
In dataclasses wordt vergeleken in de volgorde waarin de velden zijn gedefinieerd. Nu worden de classes vergeleken op basis van de workoutduur, omdat het eerste veld, exercises, niet-standaard objecten bevat.
We kunnen dit verifiëren door de duur van de woensdag-sessie te verhogen:
hiit_monday = WorkoutSession()
# hiit_monday.add_exercise(...)
hiit_wednesday = WorkoutSession()
hiit_wednesday.increase_duration(10)
hiit_monday > hiit_wednesday
False
Zoals verwacht kregen we False.
Maar wat als het eerste veld van Workout een ander type zou zijn, bijvoorbeeld een string? Laten we het proberen:
@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
Hoewel de sessie op maandag langer duurt, zegt de vergelijking dat hij kleiner is dan die van woensdag. Dat komt doordat “25” vóór “27” komt in Python-stringvergelijking.
Hoe behouden we nu de volgorde van de velden en sorteren we sessies toch op duur? Dat kan eenvoudig met de functie 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
Door compare voor een veld op False te zetten, sluiten we het uit bij het sorteren, zoals het bovenstaande resultaat laat zien.
Post-init-veldbewerking
Op dit moment hebben we een standaard sessieduur van vijf minuten vanwege de warming-up. Dat is alleen logisch als een gebruiker met een warming-up begint. Wat als die met andere oefeningen begint:
new_session = WorkoutSession([Exercise("Diamond push-ups", 10, 3)])
new_session.duration_minutes
5
Voor slechts één oefening is de totale duur vijf minuten, wat niet logisch is. Elke sessie moet zijn duur dynamisch inschatten op basis van het aantal sets per oefening. Dat betekent dat duration_minutes afhankelijk moet zijn van het veld exercises.
Laten we dat implementeren:
@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
...
Deze keer definiëren we duration_minutes met init op False om de initialisatie van het veld uit te stellen.
Vervolgens werken we in de speciale methode __post_init__ de waarde bij op basis van het totale aantal sets in elke Exercise.
Nu verhoogt duration_minutes zich bij initialisatie dynamisch met drie minuten voor elke set in elke oefening.
# Adding an exercise with three sets
hiit_friday = WorkoutSession([Exercise("Diamond push-ups", 10, 3)])
hiit_friday.duration_minutes
9
In het algemeen kun je, als je een veld wilt definiëren dat afhankelijk is van andere velden van je dataclass, de __post_init__-logica gebruiken.
Immutabiliteit in dataclasses
Onze WorkoutSession-dataclass is bijna klaar; hij moet alleen nog beschermd worden. Nu kun je hem vrij eenvoudig om zeep helpen:
hiit_friday.duration_minutes = 1000
hiit_friday.duration_minutes
1000
del hiit_friday.exercises
We willen alle velden van onze classes beschermen zodat ze alleen op de door ons gewenste manier kunnen worden aangepast. De @dataclass-decorator biedt hiervoor het handige argument frozen:
@dataclass(frozen=True)
class FrozenExercise:
name: str
reps: int
sets: int
weight: int | float = 0
ex1 = FrozenExercise("Muscle-ups", 5, 3)
Als we nu een veld willen wijzigen, krijgen we een foutmelding:
ex1.sets = 5
FrozenInstanceError: cannot assign to field 'sets'
Door frozen op True te zetten, worden automatisch __deleteattr__- en __setattr__-methodes toegevoegd voor elk veld, zodat ze na initialisatie beschermd zijn tegen verwijderen of bijwerken. Anderen kunnen ook geen nieuwe velden toevoegen:
ex1.new_field = 10
FrozenInstanceError: cannot assign to field 'new_field'
Deze functionaliteit zou bij traditionele classes tientallen regels code kosten.
Houd er echter rekening mee dat we onze classes niet echt immutabel kunnen maken. Laten we bijvoorbeeld WorkoutSession herschrijven met frozen op True:
@dataclass(frozen=True)
class ImmutableWorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
session1 = ImmutableWorkoutSession()
Zoals verwacht kunnen we de lijst met oefeningen niet direct aanpassen:
session1.exercises = [Exercise()]
Maar exercises is een lijst, en die is volledig muteerbaar, waardoor het volgende mogelijk is:
# Een element in een lijst wijzigen
# 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)
Om dus te beschermen tegen onbedoelde wijzigingen, is het aan te raden om immutabele objecten zoals tuples te gebruiken voor veldwaarden.
Overerving in dataclasses
Tot slot behandelen we nog de volgorde van velden in parent- en child-classes.
Omdat dataclasses gewone classes zijn, werkt overerving zoals gebruikelijk:
@dataclass(frozen=True)
class ImmutableWorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
@dataclass(frozen=True)
class CardioWorkoutSession(ImmutableWorkoutSession):
pass
Maar omdat het laatste veld in de parentclass (ImmutableWorkoutSession) een standaardwaarde heeft, moeten alle velden in child-classes ook standaardwaarden hebben.
Dit is bijvoorbeeld niet toegestaan:
@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
Nadelen van dataclasses en verdiepende bronnen
Dataclasses zijn sinds Python 3.7 gestaag verbeterd (ze waren vanaf het begin al sterk) en dekken veel use-cases waarin je anders classes zou schrijven. Maar ze kunnen nadelig zijn in de volgende situaties:
- Aangepaste
__init__-methodes - Aangepaste
__new__-methodes - Verschillende overervingspatronen
En nog veel meer, zoals besproken in deze uitstekende Reddit-thread. Wil je een gedetailleerdere onderbouwing van waarom dataclasses zijn geïntroduceerd en waarom ze geen drop-in-vervangers zijn voor gewone class-definities, lees dan PEP 557.
Als je in het algemeen geïnteresseerd bent in objectgeoriënteerd programmeren, is dit een cursus om je reis voort te zetten:
In de kern zijn dataclasses fancy structuren om data efficiënter op te slaan en op te vragen. Python heeft echter veel andere datastructuren die min of meer hetzelfde doen. Zo kun je in het laatste hoofdstuk van de Data Types for Data Science-cursus meer leren over counters, defaultdicts en namedtuples.

Ik ben een contentmaker op het gebied van data science met meer dan 2 jaar ervaring en een van de grootste achterbannen op Medium. Ik schrijf graag diepgaande artikelen over AI en ML met een vleugje sarcasme, want je moet íets doen om ze wat minder droog te maken. Ik heb meer dan 130 artikelen en een DataCamp-cursus gemaakt, met nog een in de maak. Mijn content is door meer dan 5 miljoen ogen bekeken, van wie 20k mij is gaan volgen op zowel Medium als LinkedIn.
