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 (int, etc.).

Net als alles bij variabelen en functies: gebruik duidelijke, omschrijvende namen voor je klassen. X is misschien een goede naam voor een social media platform, een klasse met de naam X vertelt weinig over wat het doet.

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.

Opdracht 1: Groet

Neem de klasse Gebruiker als uitgangspunt. Voeg nu een methode toe die de gebruiker groet. Maak het mogelijk de groet formeel en informeel uit te voeren.

Klik om het antwoord te tonen

Een mogelijke uitwerking is als volgt:

class Gebruiker:
    _rol = "Gebruiker"

    def __init__(self, naam, email):
        self._naam = naam
        self._email = email

    def groet(self, formeel=False):
        if formeel:
            return f"Gegroet, {self._naam}."

        return f"Hé {self._naam}!"

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.

Opdracht 2: Gebruiker toevoegen

Voeg aan Beheerder een methode toe die het mogelijk maakt een nieuwe Gebruiker toe te voegen. Laat zien dat dit werkt, laat ook zien dat deze methode voor een Gebruiker niet beschikbaar is.

Klik om het antwoord te tonen

Neem de klasse Gebruiker als basis:

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`
        """

Dit is een mogelijke manier om Beheerder een nieuwe gebruiker te laten toevoegen:

class Beheerder(Gebruiker):
    _rol = "Beheerder"

    def voeg_gebruiker_toe(self, naam, email):
        return Gebruiker(naam=naam, email=email)


beheerder = Beheerder(naam="Ali", email="ali@example.com")
john = beheerder.voeg_gebruiker_toe(
    naam="John",
    email="john@example.com"
)

print(john.info())
# Naam: John
# E-mail: john@example.com
# Rol: Gebruiker


maria = john.voeg_gebruiker_toe(naam="Maria", email="maria@example.com")
# Fout

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:

Overerving

De relatie is van het type "is een". Een Beheerder is een Gebruiker.

Compositie

De relatie is van het type "heeft een". Een Gebruiker heeft een Adres.

Opdracht 3: Profiel

Breidt de klasse Gebruiker uit met een profiel. Hanteer hierbij compositie, dus vergelijkbaar met het adres. Om het kort te houden bevat het profiel alleen een bio (een stukje over de gebruiker).

Zorg ervoor dat de bio via een methode opgehaald én aangepast kan worden vanuit de gebruiker.

Klik om het antwoord te tonen

Een mogelijke uitwerking is (de klasse Gebruiker is kort gehouden):

class Profiel:

    def __init__(self, bio):
        self._bio = bio

    def verkrijg_bio(self):
        return self._bio

    def update_bio(self, nieuwe_tekst):
        self._bio = nieuwe_tekst


class Gebruiker:
    _rol = "Gebruiker"

    def __init__(self, profiel):
        self._profiel = profiel

    def bio(self):
        return self._profiel.verkrijg_bio()

    def update_bio(self, nieuwe_tekst):
        self._profiel.update_bio(nieuwe_tekst=nieuwe_tekst)


johns_profiel = Profiel(bio="Lorem Ipsum")
john = Gebruiker(profiel=johns_profiel)

print(john.bio())  # Lorem Ipsum

john.update_bio(nieuwe_tekst="Ipsum Lorem")
print(john.bio())  # Ipsum Lorem

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.