Werken met klassen
Inleiding
In Werken met functies heb je geleerd hoe je functies kunt gebruiken om herhaling van code voorkomen. Is een bepaalde taak vaak nodig, dan kun je er een functie van maken en deze elke keer aanroepen als het nodig is. In dit hoofdstuk leer je wat een klasse is, class in het Engels. Ook een klasse gebruik je - onder andere - om herhaling van code te voorkomen, maar is uitgebreider dan een functie.
In de volgende paragraaf leer je eerst wat een klasse is en hoe je er een maakt. Vervolgens leer je ermee te werken.
Leerdoelen
Aan het einde van dit hoofdstuk:
-
Begrijp je wat klassen zijn
-
Begrijp je wanneer je klassen gebruikt
-
Begrijp je wat een methode is en welke type methodes er zijn
-
Begrijp je wat overerving is en wat compositie is
Klassen
Zonder dat je het weet ben je al verschillende klassen tegengekomen. Voer in Thonny of in een shell maar eens het volgende uit:
print(type("Python"))
# <class 'str'>
print(type(5))
# <class 'int'>
Vraag je het type op van een tekst, dan krijg je terug dat het type een klasse str
is. Sterker, elke ingebouwd type is een bepaalde klasse. Met een klasse kun je nu ook je eigen types definiëren. Feitelijk zijn typen en klassen dus hetzelfde. Het heeft met historie te maken dat beiden begrippen bestaan. In (veel) oudere versies van Python was er verschil tussen ingebouwde typen en gebruiker-gedefinieerde klassen.
Een klasse is dus een type, maar wat kun je ermee? Een klasse bepaalt de structuur en het gedrag van één of meer objecten. De structuur verwijst naar welke gegevens de klasse kan bevatten, aangeduid met attributen. Hierover lees je later meer. Het gedrag verwijst naar wat een object kan, met methodes. Dit heb je ook al gezien bij de ingebouwde types:
mijn_tekst = "Lorem Ipsum" # type <class 'str'>
mijn_getal = 42 # type <class 'int'>
# Roep de methode 'lower()' aan op `mijn_tekst`
mijn_tekst.lower() # lorem ipsum
# Roep de methode 'lower()' aan op `mijn_getal`
mijn_getal.lower() # Fout, int heeft geen methode `lower`
Hier zie je welk gedrag mogelijk is afhankelijk van de klasse (type). Een str
heeft de methode lower()
(dat alle tekst naar kleine letters omzet), maar een int
heeft deze methode niet. Die heeft weer andere methodes (ander gedrag). Andere voorbeelden zijn de list
(.append()
, .pop()
, etc.) of tuple
(.count()
, .index()
).
Over methodes lees je in een volgende paragraaf meer.
Je eigen type maken
Met een klasse maak je dus een eigen type. Hieronder zie je een eenvoudig voorbeeld:
class Gebruiker:
pass
Je maakt een klasse door het woord class
te laten volgen door een zelfgekozen naam, gevolgd door een dubbele punt (:
). Daaronder begint het blok. Omdat dit voorbeeld (nog) niets doet, maar je geen leeg blok mag hebben gebruik je pass
om het blok te vullen. Hiermee geef je aan dat je niets wil doen.
De conventie voor klassennamen is om CamelCase te gebruiken. Dus Gebruiker, MijnKlasse, EenKlasseMetEenLangeNaam, etc. De uitzondering hierop zijn de ingebouwde types ( Net als alles bij variabelen en functies: gebruik duidelijke, omschrijvende namen voor je klassen. |
Een klasse is een blauwdruk voor objecten. Maak je een object aan op basis van een klasse, dan noem je dat object een instantie van die klasse.
# Maak twee instanties van `Gebruiker` aan
john = Gebruiker()
maria = Gebruiker()
print(type(john)) # <class '__main__.Gebruiker'>
print(type(maria)) # <class '__main__.Gebruiker'>
Hier maak je twee instanties van de klasse Gebruiker
aan. Vraag je het type op, dan zie je inderdaad Gebruiker
terug.
Een eigen type maken wordt met name handig als je ook je eigen methodes gaat toevoegen, ofwel gedrag gaat toevoegen. Een methode is feitelijk hetzelfde als een functie, maar dan als onderdeel van een klasse. Waar je een functie direct aanroept, roep je een methode altijd aan als onderdeel van de klasse waar het toe behoort.
Als voorbeeld kun je de methode info
toevoegen aan de klasse Gebruiker
:
class Gebruiker:
def info(self):
return "Gebruiker"
Je ziet dat je een methode hetzelfde aanmaakt als een functie, maar nu in het blok van de klasse. Verder valt op dat het eerste (en in dit geval enige) argument self
heet. De methode die je hier hebt gemaakt heet een instantiemethode, omdat het een methode is die werkt met de instanties van de klasse. Een instantiemethode moet als eerste argument een verwijzing naar de instantie bevatten. Het is niet verplicht om dit self
te noemen, maar dit is wel zeer gebruikelijk.
john = Gebruiker()
john.info() # "Gebruiker"
In bovenstaand voorbeeld heb je een instantie van de klasse Gebruiker
gemaakt, genaamd john
. Vervolgens roep je de methode info
aan (die nu nog niet zoveel doet). Bij het aanroepen van de methode laat je self
achterwege. In de methode verwijst self
nu naar de instantie john
(naar zichzelf dus, vandaar de conventie om het self
te noemen).
In dit voorbeeld gebruik je self
niet, dus waarom is het nuttig? Om dat te illustreren breidt je de klasse nog meer uit, met een speciale initialisatiemethode __init__
. Zoals de naam doet vermoeden wordt deze methode automatisch aangeroepen als de klasse wordt geïnitialiseerd, ofwel als je john = Gebruiker()
uitvoert. De __init__
methode gebruik je om attributen te initialiseren.
Ook __init__
heeft als eerste argument self
, maar kan ook meer argumenten ontvangen (dit geldt overigens voor elke methode in een klasse):
class Gebruiker:
def __init__(self, naam, email):
self.rol = "Gebruiker"
self.naam = naam
self.email = email
def info(self):
return f"""
Naam: {self.naam}\n
E-mail: {self.email}\n
Rol: {self.rol}\n
"""
In de __init__
methode wijs je op regel vier de ontvangen argumenten naam
en email
toe aan de attributen naam
en email
. self
verwijst naar de instantie, en naam
en email
worden nu attributen van de instantie. Verder voeg je een attribuut rol
toe, die voor alle gebruikers (voor alle instanties) hetzelfde is.
In de info
methode kun je nu verwijzen naar de attributen, zoals op regel 10 t/m 12. Maak je nu een instantie aan, dan moet je de argumenten naam
en email
opgeven. Dit werkt net als bij functies: je mag de naam weglaten, maar je mag het ook expliciet benoemen.
john = Gebruiker(naam="John", email="john@example.com")
john.info()
# Naam: John
# E-mail: john@example.com
# Rol: Gebruiker
print(john.email) # john@example.com
Op de laatste regel zie je dat het attribuut email
beschikbaar is voor deze instantie. Maak je een andere instantie aan, dan krijg je andere waarden:
maria = Gebruiker(naam="Maria", email="maria@example.com")
maria.info()
# Naam: Maria
# E-mail: maria@example.com
# Rol: Gebruiker
print(maria.email) # maria@example.com
Attributen kun je ook per instantie wijzigen:
maria.email = "maria42@example.com" # Wijzig e-mailadres van deze instantie
print(maria.email) # maria42@example.com
maria.info() # Teruggegeven informatie is ook veranderd
Het kan zinvol zijn om attributen niet direct te aan te passen, maar via een methode. Python is een flexible taal en het zal je niet verbieden attributen direct aan te passen. Maar als je beperkingen wil opleggen aan wat mogelijk is met een attribuut, kun je dat als volgt oplossen:
class Gebruiker:
def __init__(self, naam, email):
self._rol = "Gebruiker"
self._naam = naam
self._email = email
def info(self):
return f"""
Naam: {self._naam}\n
E-mail: {self._email}\n
Rol: {self._rol}\n
"""
def wijzig_email(self, nieuw_email):
# Voer hier enkele checks uit
# of het e-mailadres geldig is
self._email = nieuw_email
Door een underscore voor de attributen te zetten, zeg je als het ware: "Gebruik deze attributen niet direct". Nogmaals: Python zal het niet voorkomen, het is eerder een conventie.
Met de nieuwe methode wijzig_email
pas je nu het e-mailadres aan met het nieuw opgegeven e-mailadres. In deze methode kun je eerst allerlei checks uitvoeren (is het e-mailadres geldig, is het een e-mailadres dat tot het bedrijf behoort, is het e-mailadres al niet aan een andere gebruiker gekoppeld, etc.).
Zou je de gebruiker van deze klasse het e-mailadres direct laten wijzigen (zoals in het eerdere voorbeeld), dan bestaat het gevaar dat al deze checks niet zijn uitgevoerd.
Overerving
Een belangrijk concept bij klassen is overerving. Dit houdt in dat je een klasse baseert op een andere klasse. Om dit te illustreren gebruik je nogmaals de klasse Gebruiker
:
class Gebruiker:
def __init__(self, naam, email):
self._rol = "Gebruiker"
self._naam = naam
self._email = email
def info(self):
return f"""
Naam: {self._naam}\n
E-mail: {self._email}\n
Rol: {self._rol}\n
"""
Stel dat je nu naast gebruikers, ook beheerders wil maken. Een beheerder is vergelijkbaar met een reguliere gebruiker, maar heeft - uiteraard - een andere rol.
Nu kun je een heel nieuwe klasse Beheerder
maken, en de __init__
en info
methodes herschrijven met een enkele aanpassing. Met overerving kun je echter gebruikmaken van het feit dat een groot deel van de gegevens hetzelfde is.
class Beheerder(Gebruiker):
pass
ali = Beheerder(naam="Ali", email="ali@example.com")
print(ali.info())
# Naam: Ali
# E-mail: ali@example.com
# Rol: Gebruiker
Op regel één zie je dat Beheerder
is gebaseerd op Gebruiker
. Hierdoor heeft Beheerder
direct dezelfde attributen (naam, e-mail) en methodes (info
) als Gebruiker
. De klasse Gebruiker
noem je een parent class of een super class, Beheerder
noem je een child class of een sub class.
Het probleem is dat de rol nog als "Gebruiker" staat genoteerd. Er zijn dus enkele aanpassingen nodig. De rol wordt gedefinieerd in de __init__
methode en vervolgens gebruikt in de info
methode.
Een handigheid bij overerven is dat het alle kenmerken (attributen, methodes) van de super class overneemt in de sub class, maar dat deze wel aanpasbaar zijn. Neem onderstaande voorbeeld:
class Beheerder(Gebruiker):
def __init__(self, naam, email):
super().__init__(naam, email)
self._rol = "Beheerder"
ali = Beheerder(naam="Ali", email="ali@example.com")
print(ali.info())
# Naam: Ali
# E-mail: ali@example.com
# Rol: Beheerder
In deze aangepaste versie van Beheerder
definieer je de __init__
methode opnieuw. Net als in de Gebruiker
klasse ontvangt het de argumenten naam
en email
, welke aan de __init__
methode van de super class worden doorgegeven. Dit doe je met het speciale woord super()
.
Met super()
roep je de super class aan. In dit geval roep je de __init__
methode van de super class, dus van Gebruiker
aan. Die __init__
methode zorgt ervoor dat de attributen naam
en email
worden ingesteld, ook voor de sub class. Op regel vijf stel je tot slot self._rol
in met de juiste rol "Beheerder". Hiermee overschrijf je de self._rol
van Gebruiker
als het ware.
Met een minimale aanpassing zorg je er zo voor dat een Beheerder
de juiste rol krijgt en dat dit ook terugkomt in de info
methode.
Klassenattributen
Tot dusver heb je de rol gedefinieerd in de __init__
methode. De rol is echter voor alle instanties hetzelfde. Elke instantie van Gebruiker
heeft de rol "Gebruiker" en elke instantie van Beheerder
heeft de rol "Beheerder".
Informatie dat instantie-overstijgend is, kun je ook in een klasse-variabele kwijt:
class Gebruiker:
_rol = "Gebruiker"
def __init__(self, naam, email):
self._naam = naam
self._email = email
def info(self):
return f"""
Naam: {self._naam}\n
E-mail: {self._email}\n
Rol: {self._rol}\n # rol nog beschikbaar via `self`
"""
Het attribuut _rol
is nu buiten __init__
gedefinieerd, direct als attribuut van de klasse Gebruiker
. Je kunt het nu ook aanroepen met Gebruiker._rol
. Maar ook elke instantie van Gebruiker
heeft toegang tot _rol
. Dit kan via self
, zoals in de info
methode, maar ook direct via het attribuut:
print(Gebruiker._rol)
john = Gebruiker("John", "john@example.com")
print(john._rol) # Gebruiker
Dit maakt de definitie van de klasse Beheerder
nog eenvoudiger, omdat je nu __init__
niet meer hoeft te overschrijven:
class Beheerder(Gebruiker):
_rol = "Beheerder"
ali = Beheerder(naam="Ali", email="ali@example.com")
print(ali.info())
# Naam: Ali
# E-mail: ali@example.com
# Rol: Beheerder
Let wel op: wijzig je de een klassenattribuut via de klasse, dan wijzigt het voor alle instanties!
class Beheerder(Gebruiker):
_rol = "Beheerder"
beheerder1 = Beheerder("Ali", "ali@example.com")
beheerder2 = Beheerder("John", "john@example.com")
print(beheerder1._rol) # Beheerder
print(beheerder2._rol) # Beheerder
# Update de rol van de klasse
Beheerder._rol = "Administrator"
print(beheerder1._rol) # Administrator
print(beheerder2._rol) # Administrator
Compositie
Naast overerving is compositie een veelgebruikte strategie bij het ontwerpen van klassen. Zoals het woord doet vermoeden gebruik je compositie om objecten met meerdere klassen op te bouwen.
Stel dat je allerlei gegevens over je gebruiker wil opslaan en beheren, zoals adresgegevens en profielgegevens (afbeelding, weergavenaam, website, etc.).
Je kunt de Gebruiker
klasse uitbreiden met allerlei attributen. Maar dit maakt de klasse erg uitgebreid (en daarmee wellicht onoverzichtelijk). En wat als je in je systeem ook een Factuur
klasse hebt, die precies dezelfde adresgegevens heeft? Soms is het dus beter om klassen op te splitsen. In dit voorbeeld maak je een (vereenvoudigde) klasse Adres
.
class Adres:
def __init__(self, postcode, huisnummer):
self.postcode = postcode
self.huisnummer = huisnummer
def verkrijg_compleet_adres(self):
"""
Haal ergens op basis van postcode en huisnummer
het volledige adres op
"""
pass
Vervolgens breidt je de Gebruiker
klasse uit met deze adresgegevens:
class Gebruiker:
_rol = "Gebruiker"
def __init__(self, naam, email, adres):
self._naam = naam
self._email = email
self._adres = adres
# ...
def verkrijg_adres(self):
return self._adres.verkrijg_compleet_adres()
De __init__
methode is uitgebreid met adres
, een instantie van Adres
. Verder is er een methode verkrijg_adres
toegevoegd, die niets anders doet dan een methode van Adres
aanroepen. Dit is zeker niet verplicht, maar is wel gebruikelijk. Iemand die Gebruiker
in de code gebruikt, hoeft zich zo niet druk te maken over waar de adresgegevens vandaan komen.
adres = Adres("1234AB", "1")
gebruiker = Gebruiker("Maria", "maria@example.com", adres)
gebruiker._adres.verkrijg_compleet_adres() # Dit mag
gebruiker.verkrijg_adres() # Maar dit heeft de voorkeur
Overerving en compositie zijn twee belangrijke concepten binnen het objectgeoriënteerd programmeren. Een goede manier om er over na te denken is:
|
Verschillende typen methodes
Naast instantie methodes, welke je het meest zult gebruiken en die je tot dusver hebt gebruikt, zijn er nog andere soorten methodes beschikbaar in een klasse.
Instantiemethode
Een instantiemethode gebruik je om met (data van) instanties te werken. Deze methodes zijn enkel beschikbaar via een instantie (gebruiker.info()
) en hebben altijd als eerste argument een verwijzing naar de instantie (self
).
Klassemethode
Een klassemethode is een methode die je kunt aanroepen vanaf de klasse zelf. Je hebt er ook toegang mee tot attributen die tot de klasse behoren, zoals _rol
. Je dient de methode te markeren als klassemethode door er @classmethod
boven te plaatsen. In plaats van self
als eerste argument, gebruik je cls
als eerste argument. Bijvoorbeeld bij Gebruiker
:
class Gebruiker:
_rol = "Gebruiker"
@classmethod
def verkrijg_rol(cls):
return cls._rol
john = Gebruiker()
john.verkrijg_rol()
Gebruiker.verkrijg_rol()
Naast dat je de methode vanaf de klasse kunt aanroepen, kan dit ook vanuit de instantie. Klassemethodes zijn nuttig als je iets wil doen dat vooraf gaat aan aanmaken van instanties. Ook worden ze vaak gebruikt bij constructors, bijvoorbeeld bij Gebruiker:
class Gebruiker:
_rol = "Gebruiker"
def __init__(self, naam, email):
self._naam = naam
self._email = email
def info(self):
return f"""
Naam: {self._naam}\n
E-mail: {self._email}\n
Rol: {self._rol}\n
"""
@classmethod
def maak_anonieme_gebruiker(cls):
return Gebruiker(naam="John Doe", email="johndoe@example.com")
john_doe = Gebruiker.maak_anonieme_gebruiker()
print(john_doe.info())
Statische methode
Tot slot zijn er statische methodes, die geen toegang hebben tot attributen. Je markeert een methode als statisch door er @staticmethod
boven te noteren. Verder heeft het geen self
of cls
als argumenten, omdat het geen (directe) toegang tot attributen heeft.
Een mogelijke situatie waarbij het logisch kan zijn om het wel te gebruiken is de methode voeg_gebruiker_toe
die je in opdracht1 hebt toegevoegd. Deze methode heeft geen toegang nodig tot attributen van de beheerder, maar is wel een methode die duidelijk tot een beheerder behoort (reguliere gebruikers hebben de methode niet).
In de praktijk zul je vaker zien dat voeg_gebruiker_toe
een reguliere functie is, die op een andere manier beperkt is zodat alleen beheerders er toegang toe hebben. Maar het is dus goed mogelijk om het als statische methode aan Beheerder
toe te voegen.
class Beheerder:
_rol = "Beheerder"
@staticmethod
def voeg_gebruiker_toe(naam, email): # Geen self!
return Gebruiker(naam=naam, email=email)
Speciale methode
Een klasse heeft ook allerlei speciale methodes, vaak dundermethodes genoemd, omdat ze zijn omvat in double underscores. Een ben je er al tegengekomen: __init__
. Andere voorbeelden zijn __new__
, welke wordt aangeroepen bij het aanmaken van een nieuwe instantie van een klasse (nog voor __init__
) en __add__
wat bepaalt hoe optelling werkt voor de gegeven klasse.
Zie de python documentatie voor veelvoorkomende speciale methodes en hun werking.
Wanneer gebruik je klassen?
Een klasse is een belangrijke pijler in het objectgeoriënteerd programmeren (zie kader). Werken met klassen heeft een aantal voordelen:
-
Inkapseling (encapsulation): klassen houden data (via attributen) en de methodes om met die data te werken in een enkel object bij elkaar.
-
Abstractie: klassen abstraheren complex gedrag weg. Een gebruiker van de klasse hoeft enkel te weten welke methode het moet aanroepen, niet hoe die methode werkt.
-
Hergebruik: met behulp van overerving en compositie maak je het mogelijk delen van je code te hergebruiken, zodat herhaling voorkomen wordt.
-
Consistentie: door je klassen goed in te richten met de juiste methodes om je data te bewerken, zorg je ervoor dat je gegevens altijd in een geldige staat zijn.
-
Organisatie: Door inkapseling, abstractie en hergebruik zijn klassen een goede manier om je code te organiseren op een begrijpelijke wijze.
Tot slot zijn klassen voor veel mensen een 'natuurlijke' manier om over zaken na te denken. Vaak verwijst een klasse naar iets uit de echte wereld (een gebruiker, bijvoorbeeld). 'In het echt' heeft de gebruiker ook eigenschappen (naam, e-mail) en wil je iets doen met die gegevens.
Klassen kunnen dus zeer nuttig zijn om je code overzichtelijk en begrijpelijk te houden, maar dat wil niet zeggen dat het altijd de beste oplossing is. Werken met klassen kan ook nadelen hebben. Als er (te)veel abstractielagen in het systeem zijn, dan wordt het wellicht juist ingewikkelder om de code te begrijpen. Soms is een goede functie alles wat je nodig hebt!
Toch zul je klassen veel tegenkomen, ook als je besluit ze zelf niet toe te passen. Het is daarom goed om te begrijpen hoe ze werken.
Objectgeoriënteerd programmeren is een stijl van programmeren waarbij je gebruik maakt van objecten om je programma op te bouwen. Een object omvat de gegevens en de mogelijkheid om die gegevens te verwerken. Een klasse vertegenwoordigd in dit systeem een object. De attributen bevatten de gegevens, met de methodes verwerk je de gegevens. Lees op Wikipedia meer over object georiënteerd programmeren. |