TOPIC 10 Objectgericht Programmeren Deel 2: Klassen Bouwen

In het vorige topic hebben we gezien hoe we objecten kunnen gebruiken van klassen die door derden gemaakt zijn. We hebben gezien dat klassen overeenkomen met types en dat de naam van de klasse gebruikt wordt als constructor om nieuwe objecten van die klasse aan te maken. Deze objecten bevatten attributen; onderdelen die een welbepaalde naam hebben. De objecten bevatten ook methoden wat eigenlijk functies zijn die “in” het object zitten. Zowel de attributen als de methoden kunnen we “vastpakken” (t.t.z. lezen in het geval van attributen en oproepen in het geval van methoden) m.b.v. de puntsyntax: o.a leest een attribuut met naam a uit object o en o.m(...) roept methode m op van object o. Tenslotte kunnen we attributen ook toekennen: o.a = v kent de nieuwe waarde v toe aan attribuut a in object o.

In dit topic bestuderen we hoe we zélf klassen kunnen bouwen. Zoals we zullen zien dienen we daarvoor een naam te verzinnen en dienen we de attributen en de methoden aan de klassen toe te voegen.

Een zeer krachtig werktuig bij het schrijven van grote programma’s is het feit dat we klassen kunnen organiseren in hiërarchieën. Zo kunnen we bijvoorbeeld de klassen Person, Male en Female organiseren zodat Male en Female speciale gevallen zijn van Person. Zoals we zullen zien zijn hierdoor alle attributen en methoden van Person ook meteen automatisch bruikbaar voor Male en Female. Dit heet overerving (Eng: inheritance) en zal een zeer krachtig middel zijn om objectgerichte programma’s te schrijven.

In dit topic leggen we alles uit aan de hand van zeer kleine en eenvoudige voorbeelden. Het topic wordt echter afgesloten met een uitgebreid voorbeeld waarbij we objectgericht programmeren aanwenden om een heus computerspel te bouwen met een grafische interface. Maar eerst dienen we klassen uit te leggen.

Klassen


Stel dat we een programma willen schrijven waarmee we todo-lijstjes kunnen beheren. De bedoeling is dus om een soort agenda bij te houden waarmee we de beschrijving van taken (in de vorm van een string) kunnen associëren met een datum tegen wanneer die taken afgewerkt dienen te worden. We zouden dit kunnen doen in Python door een lijst van koppels aan te leggen waarbij ieder koppel bestaat uit een string (de taak) en een datum. De datum zouden we kunnen voorstellen als een 3-tupel met getallen om de dag, de maand en het jaar aan te geven.

taken = [ ("Wiskundetaak", (1,2,2021)), ("Programmeerproject", (2,4,2021)) ]

Een ingewikkeld programma schrijven door steeds weer tupels en lijsten aan mekaar te lijmen wordt al gauw zeer ingewikkeld. Bovendien is het makkelijk te vergeten wat de componenten van de tupels en de lijsten betekenen. Is (2,4,2021) de tweede april van 2021 of bedoelden we de vierde februari toen we dat geschreven hebben? Daarom hebben we in het vorige topic gewerkt met objecten. Zo is een box in VPython een “entiteit” waarvan we als gebruiker (bijvoorbeeld op het moment dat we het Game of Life gingen programmeren) niet hoeven te weten hoe het exact wordt voorgesteld in het computergeheugen en welke de onderdelen precies zijn. We kunnen deze gedachtengang doortrekken naar onze todo-lijst en een datum voorstellen als een nieuw soort object. Hiervoor dienen we dus Python uit te breiden met een nieuw type Datum. Dat gebeurt met onderstaande declaratie:

class Datum(object):
    def  __init__(self, dag, maand, jaar):
        self.dag   = dag
        self.maand = maand
        self.jaar  = jaar
    def vergelijk_jaar(self,ander):
        if self.jaar < ander.jaar:
            return -1
        elif self.jaar > ander.jaar: 
            return 1
        else: 
            return 0
    def volgend_jaar(self):
            return Datum(self.dag, self.maand, self.jaar+1)

Dit stukje Python code toont de definitie van de klasse Datum. Deze definitie bevat verschillende aspecten:

Constructor

Herinner van het vorige topic dat nieuwe objecten aangemaakt worden door de constructor op te roepen. In bovenstaande declaratie is de constructor een methode met de speciale naam __init__. In ons geval krijgt die 4 argumenten. Dat zijn de 3 argumenten van de oproep van de constructor, maar ook een extra argument die het zonet gemaakte object aanduidt. Meestal kiest men hiervoor de naam self. Herinner dat de constructor wordt opgeroepen door de naam van de klasse als Python functie te gebruiken. Hieronder zien we dat de constructor van Datum wordt aangeroepen met 3 argumenten. We kunnen meerdere objecten van type datum aanmaken door de constructior meerdere keren op te roepen.

d1 = Datum(1,2,2021)
d2 = Datum(2,4,2021)
d1
<__main__.Datum at 0x7fbc3fdbb3d0>
d2
<__main__.Datum at 0x7fbc3fdbb390>
d1 == d2
False
d3 = Datum(2,4,2021)
d3
<__main__.Datum at 0x7fbc3fd68050>
d2 == d3
False

In de realiteit wordt dus bovenstaande methode __init__ aangeroepen. De argumenten 1, 2 en 2021 worden uiteraard aan de methode doorgegeven zoals gewoonlijk. Maar Python zal naast de argumenten die we zelf hebben meegegeven ook nog het nieuw object meegeven als eerste argument aan de constructor.

In de body van __init__ zien we dat er drie toekenningen gebeuren. De variabelen die worden aangemaakt zijn echter geen gewone lokale variabelen in de body van de methode. Dat zouden immers variabelen zijn die na de oproep van de methode niet meer bestaan. In ons geval zorgt het self. voorvoegsel ervoor dat de variabele gemaakt wordt in het zopas gemaakte object. Bovenstaande constructor maakt dus drie nieuwe variabelen in het object en verbindt deze variabelen met de waarden van de argumenten. In het kort zal een oproep van Datum(1,2,2021) dus een nieuw object maken van het type Datum en zal de constructor worden uitgevoerd van de bijhorende klasse. Deze zal dan drie variabelen toevoegen aan het object en ze initialiseren. Het object zélf is het resultaat dat teruggegeven wordt uit de oproep.

Attributen

Herinner van het vorige topic dat we een object hebben voorgesteld als een “doosje” met gegevens erin. Deze werden de attributen van het object genoemd. De zonet toegevoegde variabelen vormen precies deze attributen. Iedere Datum is dus een object met drie attributen erin: dag, maand en jaar.

De vergelijking van d2 en d3 in bovenstaande experimentje benadrukt nog eens dat als je de constructor twee keer aanroept dat je echt twee verschillende objecten krijgt zelfs al bevatten beiden dezelfde dag, maand en jaar.

Methoden

Naast de constructor toont bovenstaande klassedefinitie de definitie van twee methoden, m.n. vergelijk_jaar en volgend_jaar. Dit betekent dus dat de objecten van het type Datum niet alleen drie attributen maar ook twee methoden kennen. Hoe je attributen en methoden gebruikt werd uitgelegd in het vorige topic. Hieronder zien we bijvoorbeeld wat dat geeft voor onze Datum objecten:

d1.jaar
2021
d1.vergelijk_jaar(d2)
0
d4= d1.volgend_jaar()
d4
<__main__.Datum at 0x7fbc3fb7cd50>
d4.jaar
2022

Merk op dat alle methoden gedefinieerd dienen te worden met een extra eerste parameter. Deze zal steeds corresponderen met het object waarop die methode wordt opgeroepen. Door de expressie d1.vergelijk_jaar(d2) te evalueren zal de methode vergelijk_jaar worden opgeroepen met twee argumenten. Het eerste argument wordt door Python automatisch ingevuld en is dus het object waarop de methode wordt opgeroepen (in ons geval d1). Het tweede argument is het argument dat we zelf hebben meegegeven bij de oproep. Het is een wijdverspreide conventie om de extra parameter self te noemen, maar dat is een kwestie van smaak.

image

De klasse beschrijft dus hoe al haar objecten er zullen uitzien en welke methoden op die objecten kunnen worden opgeroepen. Een klasse is dus een sjabloon voor haar objecten, net zoals een koekjesvorm een sjabloon is voor alle koekjes die we ermee snijden. De constructor oproepen kan je zien als het snijden van een extra koekje. Ieder object kan dus gezien worden als een afgietsel van de klasse. Dat wordt duidelijke van zodra we het type van een Datum opvragen. Python antwoordt dat het object in de variabele d1 een “instantie” is van de klasse Datum. In OO terminologie zeggen we dat een object een instantie is van een klasse.

type(d1)
__main__.Datum

Speciale Methoden

In het vorige topic hebben we gezien dat Python zogenoemde syntactische suiker kent. Dat zijn syntactische vormen die er voor ons mensen heel mooi en natuurlijk uitzien maar die door de Python read-fase omgezet worden naar primitievere taalvormen. Zo wordt bijvoorbeeld de toepassing van een vermenigvuldigingsoperator omgezet in een oproep van de methode __mul__. Dit heeft als grote voordeel dat de objecten van elke klasse waarop we deze methode definiëren automatisch ook in vermenigvuldigingsexpressies kunnen gebruikt worden. Zo hebben we in het vorige topic gezien dat * ook toepasbaar is op objecten van de klasse’s array en matrix.

We hebben tot nu toe twee voorbeelden gezien van zulke speciale methoden, namelijk __mul__ en __init__. De eerste wordt gebruikt telkens we een * schrijven met objecten van de klasse. De tweede wordt gebruikt telkens we de naam van de klasse gebruiken om de constructor op te roepen. Python kent vele zulke speciale methoden. Onderstaand voorbeeld laat dit zien door onze klasse Datum te herschrijven met twee nieuwe speciale methoden, m.n. __str__ en __eq__.

class Datum(object):
    def  __init__(self, dag, maand, jaar):
        self.dag   = dag
        self.maand = maand
        self.jaar  = jaar

    def __str__(self):
        monthnames = ['jan','feb','mar','apr', 'may','jun','jul','aug','sept','oct','nov','dec']
        return str(self.dag) + ' ' + monthnames[self.maand-1] + ' ' + str(self.jaar)

    def __eq__(self,other):
        return self.dag == other.dag and self.maand == other.maand and self.jaar == other.jaar

__str__ wordt opgeroepen telkens we een object willen omzetten naar een string m.b.v. een oproep van de str functie. De methode wordt ook automatisch opgeroepen door het print statement. Een object wordt uitschreven door de REPL als <__main__.Datum at 0x10cca8a50>.

d1 = Datum(1,2,2021)
str(d1)
'1 feb 2021'
print(d1)
1 feb 2021
d1
<__main__.Datum at 0x7fbc3ff13490>

De __eq__ methode wordt opgeroepen telkens we de == operator gebruiken om objecten met mekaar te vergelijken. Waar in onze eerdere experimenten het vergelijken van 2 verschillende datum objecten steeds False opleverde zullen we nu twee datum objecten gelijk achten als dag, maand en jaar gelijk zijn. Dat is wat er in de impleemntatie van __eq__ staat.

Zoals steeds in dit boek is het niet de bedoeling dat je alle speciale methoden van buiten gaat leren. De meest gebruikte methoden zal je na wat oefening automatisch kennen. Voor de volledigheid volgt hier een tabel waarin de speciale methoden gelinkt worden aan de operatoren en functies die in hun oproep zal resulteren.

Speciale methode

Syntactische Suiker

__init__

Oproep van de constructor

__str__

Om een string representatie te krijgen

__mul__

*

__add__

+

__eq__

==

Meegeleverde Klassen

In het vorige topic hebben we gezien dat een Python installatie reeds vele meegeleverde klassen bevat. Dat zijn dus geen types die in de programmeertaal Python zijn ingebouwd maar modules waar code inzit die klassedefinitie bevat. Zo is er ook een meegeleverde module datetime die de nodige definities bevat om datums voor te stellen. De klasse heet date en hieronder zien we hoe we haar objecten dienen te gebruiken.

import datetime as dt
d5 = dt.date(3000,2,1)
str(d5)
'3000-02-01'
d6 = dt.date(3000,3,2)
str(d6)
'3000-03-02'
d5 == d6
False
d5 <= d6
True
d5.weekday()
5
d6
datetime.date(3000, 3, 2)

Deze klasse date is veel rijker dan degene die we zelf schreven. Merk op ze de dag, de maand en het jaar in een andere volgorde in de constructor neemt en dat ze ook een andere printvorm kent. De implementatie van date is slim genoeg om onmogelijk datums niet aan te maken. Kijk maar naar de foutboodschappen die komen bij volgend experimentjes.

dt.date(3000,15,2)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-29-d8652d5c5590> in <module>
----> 1 dt.date(3000,15,2)

ValueError: month must be in 1..12
dt.date(3000,4,31)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-30-5a649d83c4f4> in <module>
----> 1 dt.date(3000,4,31)

ValueError: day is out of range for month

De module datetime bevat ook een klasse timedelta waarmee allerlei tijdintervallen kunnen geconstrueerd worden. In de experimentjes hieronder wordt een tijdsinterval van 2 weken (14 dagen) aangemaakt en een nieuwe datum bepaald als “14 dagen verder dan” een gegeven datum door een data object en een timedelta object gewoon op te tellen.

diff = dt.timedelta(weeks=2)
diff
datetime.timedelta(days=14)
d5+diff
datetime.date(3000, 2, 15)

Objectgericht Programmeren

We kunnen dus nu onze visie op “objectgericht programmeren” van het vorige topic aanvullen. Het is een programmeerstijl waarbij je nieuwe types maakt door klassen te definiëren en door het gedrag van de objecten in de methodes van die klassen onder te brengen. Programma’s worden dan uitgedrukt als functies (of methoden) die objecten met mekaar doen samenwerken door methoden op te roepen (die op hun beurt objecten als argumenten krijgen). Het aantal klassen en de objecten die we kunnen uitdrukken is schier oneindig. We hebben gezien dat datums objecten van de klasse date zijn, dat lijsten objecten van de klasse list zijn, enz. Maar we zouden bijvoorbeeld ook de klasse Hond kunnen definiëren met fido en blackie als twee instanties. Enkel je creativiteit vormt een grens op wat je kan uitdrukken!

Encapsulatie


Encapsulatie en Abstractie

De hele bedoeling achter objectgericht programmeren is om programma’s schrijven eenvoudiger te maken. Indien je een programma schrijft dat datums manipuleert (zoals bijvoorbeeld een agenda applicatie) kan je dat programma beter schrijven in termen van de methodes die toepasbaar zijn op datums i.p.v. in termen van naakte tupels en lijsten. Dat laatste is in principe wel mogelijk maar leidt tot code die veel minder leesbaar en aanpasbaar is. De namen van de methoden zorgen ervoor dat het programma leesbaar wordt. In topic 3 hebben we al procedurele abstractie uitgelegd. Klassen voegen hier echter nog een tweede vorm van abstractie aan toe, namelijk data abstractie. Door het gebruik van de methoden van de klasse date dienen we niet langer te weten dat die datum eigenlijk intern is opgebouwd uit drie attributen. Een klasse zorgt er dus voor dat de attributen van de objecten gegroepeerd zijn en “weggestopt” zitten in die objecten zodat de gebruikers niet alle details moeten kennen. In de vakliteratuur wordt dit ook wel information hiding genoemd: je stopt de attributen weg in de binnenkant van de objecten en je gebruikt enkel de methoden om met de objecten te communiceren. De opbouw van de objecten wordt aldus ge-encapsuleerd (in mooi Nederlands: ingekapseld). Dat heeft als grote voordeel dat je code leesbaarder wordt en dat je later de interne samenstelling van de klasse zelfs grondig kan veranderen zolang er maar methoden voorzien worden die hetzelfde gedrag bewerkstelligen.

Voorbeeld

Stel dat we bijvoorbeeld een grafisch tekenprogramma aan het maken zijn en dat we de notie van “een rechthoek” nodig hebben. Dat zouden we op 2 manieren kunnen aanpakken. De eerste manier is om een rechthoek voor te stellen als een object dat uit de x- en y-coördinaten van de linkerbenedenhoek en de rechterbovenhoek van de rechthoek bestaat. Dat zou er als volgt uitzien:

class Rechthoek(object):
    def __init__(self,lox,loy,rbx,rby):
        self.lox = lox
        self.loy = loy
        self.rbx = rbx
        self.rby = rby
    def omtrek(self):
        return 2*(self.rbx-self.lox+self.rby-self.loy)

Hieronder zien we hoe we onze nieuwe klasse kunnen gebruiken. We maken een rechthoek aan door de constructor op te roepen met 4 argumenten. Dan berekenen we de omtrek van de rechthoek.

r1 = Rechthoek(1,1,7,5)
r1.omtrek()
20

Een totaal andere aanpak zou er kunnen in bestaan van de rechthoek voor te stellen als een x- en een y-coördinaat die het centrum van de rechthoek aangeven, plus twee getallen die de lengte en de breedte van de rechthoek aangeven. Dat zou er als volgt kunnen uitzien. Merk op dat de methode omtrek dezelfde naam draagt als voorheen maar een compleet andere implementatie bevat.

class Rechthoek_bis(object):
    def __init__(self,cx,cy,l,b):
        self.cx = cx
        self.cy = cy
        self.l  = l
        self.b  = b

    def omtrek(self):
        return 2*(self.l+self.b)  

Opnieuw kunnen we onze klasse gebruiken in de REPL. Ook dit keer roepen we de constructor op met 4 getallen (maar nu hebben deze echter een geheel andere betekenis). De oproep van omtrek gebeurt op precies dezelfde manier als voorheen.

r2 = Rechthoek_bis(4,3,6,4)
r2.omtrek()
20

Dit voorbeeld toont het principe van encapsulatie mooi aan. In beide gevallen is de interne structuur van een rechthoek “weggestopt” in de (twee verschillende implementaties van de) klasse. De gebruiker (dit zijn wij die aan het experimenteren zijn in de REPL) roept de methode omtrek op en hoeft niet te weten welke interne structuur we eerder hadden gekozen: de code r.omtrek() ziet er in beide gevallen precies gelijk uit! Natuurlijk moet je bij het aanroepen van de constructor wel weten wat de betekenis is van de 4 parameters. Dat is typisch wat je in de documentatie of handleiding van een module of bibliotheek terugvindt.

Encapsulatie en Polymorphisme

Het feit dat de methoden in een klasse zitten laat ons toe van voor verschillende klassen methodenamen te gebruiken die gelijk zijn. Zo heeft bijvoorbeeld ieder complex getal een methode __mul__ (die ervoor zorgt dat * de complexe vermenigvuldiging correct implementeert) maar heeft iedere Fraction ook een methode __mul__ (die ervoor zorgt dat * de vermenigvuldiging van breuken correct implementeert). We verwijzen voor beide voorbeelden naar het vorige topic.

Wanneer twee verschillende dingen (hier: complexe vermenigvuldiging en de vermenigvuldiging van breuken) dezelfde naam krijgen, spreekt men in de computerwetenschappen van polymorfisme wat letterlijk “veelvormigheid” betekent.

Polymorphisme laat dus toe dat verschillende klassen methoden met dezelfde naam kunnen implementeren. Objecten van verschillende klassen kunnen hierdoor dus op een andere manier reageren voor “eenzelfde” methode aanroep. Dit kunnen we uitbuiten om onze programma’s beter te structuren. Laten we als voorbeeld ons tekenprogramma van hierboven uitbreiden zodat we niet alleen rechthoeken maar ook cirkels kunnen maken. We voegen daarom volgende klasse toe:

class Cirkel(object):
    def __init__(self,cx,cy,s):
        self.cx = cx
        self.cy = cy
        self.s  = s
    def omtrek(self):
        return 2*3.14*self.s

Merk op dat de methode omtrek in de klasse Cirkel er helemaal hetzelfde uitziet (t.t.z. de naam en het aantal parameters) als de gelijknamige methode in één van de klassen Rechthoek die we hierboven gedefinieerd hadden. De oproep ervan gebeurt dan ook op totaal dezelfde manier. Maar de body van de omtrek methode is helemaal anders. Als we omtrek van een Cirkel object vragen wordt er echt wel andere code uitgevoerd.

c = Cirkel(4,7,3)
c.omtrek()
18.84

Maar het polymorfisme wordt pas echt nuttig indien we objecten van beide klassen door elkaar beginnen te gebruiken. Volgende lijst zou een kleine tekening kunnen voorstellen die een rechthoek en 2 cirkels bevat.

tekening = [Cirkel(1,1,4), Rechthoek(1,1,4,5), Cirkel(3,4,5)]

Het volgende experiment loopt met een for lus de lijst af en roept omtrek op op ieder object dat in die lijst zit. Hier gebeurt de magie! De uitdrukking e.omtrek() zal in de body van de lus telkens weer uitgevoerd worden met een ander object uit de lijst. Voor cirkels wordt de implementatie uit de klasse Cirkel opgeroepen en voor rechthoeken wordt de implementatie uit de klasse Rechthoek opgeroepen. En de keuze van welke implementatie wordt opgeroepen gebeurt volledig automatisch! We hebben bijvoorbeeld helemaal geen if-test moeten schrijven om af te toetsen wat de klasse van ieder object is.

for e in tekening: 
    print (e.omtrek())
25.12
14
31.400000000000002

Polymorfisme laat dus toe dat één Python uitdrukking zeer veel verschillende technische betekenissen zal hebben afhankelijk van de objecten die tijdens de uitvoering in de variabelen zitten. Eigenlijk is dit polymorfisme iets wat we gratis krijgen door het principe van encapsulatie. We roepen “blindelings” de methode omtrek op en het is het object dat voor het puntje staat dat beslist welke implementatie uitgevoerd zal worden.

Overerving en Overschrijving


Er rest ons nog één begrip uit te leggen uit het rijk van objectgericht programmeren: overerving oftewel inheritance.

Om dit begrip te motiveren gaan we nog even door met het bouwen van ons tekenprogramma. Naast cirkels en rechthoeken willen we ook gewoon punten, driehoeken en vierkanten kunnen tekenen. Al gauw ontstaat de behoefte om al deze begrippen in categorieën onder te verdelen. Zo kunnen we zeggen dat een vierkant eigenlijk een speciaal geval is van een rechthoek. We kunnen ook zeggen dat zowel rechthoeken (en dus automatisch ook vierkanten), cirkels als driehoeken allen “figuren” zijn. We kunnen al onze figuren een x-coördinaat en een y-coördinaat geven om hun positie in het vlak aan te geven. De methode verplaats zouden we dan in principe maar één keer moeten schrijven want het verplaatsen van een figuur zal enkel en alleen bestaan uit het veranderen van de positie zonder dat we daarbij hoeven rekening te houden met het soort figuur dat we verplaatsen. Met wat we tot nu toe hebben gezien zouden we cirkels, rechthoeken, vierkanten, driehoeken en punten elk als aparte klassen moeten definiëren en zouden we de methode verplaats dus 5 keer moeten schrijven (of copy/pasten).

Het onderverdelen van klassen in categorieën en het schrijven van methoden die voor hele categorieën klassen toepasbaar zijn wordt mogelijk gemaakt door overerving.

We beginnen met het bouwen van twee klassen, namelijk de klasse Punt en de klasse Figuur. Een punt is een vrij simpel object dat enkel twee coördinaten bevat en verder niks. Een Figuur wordt voorgesteld als “een ding dat we op een bepaalde positie in het vlak kunnen plaatsen en dat we kunnen verschuiven over een bepaalde x,y afstand”. De positie is een Punt. Al de code om dit te bewerkstelligen wordt in de volgende definities gegoten:

class Punt(object):
    def __init__(self,x,y):
        self.x = x
        self.y = y
        
class Figuur(object):
    def __init__(self,x,y):
        self.centrum = Punt(x,y)
    def schuif(self,x,y):
        self.centrum.x = self.centrum.x + x
        self.centrum.y = self.centrum.y + y
    def plaats(self,x,y):
        self.centrum.x = x
        self.centrum.y = y   

Merk op dat we bij de klassedefinitie steeds weer object tussen haakjes zien staan. Bij een klassedefinitie dienen we namelijk de superklasse van een klasse aan te geven. De superklasse specificeert de “is-een” relatie van een klasse. De lijn code class Punt(object) drukt dus niets minder uit dan dat ieder punt een object is. De vers gedefinieerde klasse heet een subklasse van die superklasse. Iedere klasse die we tot nu toe gedefinieerd hebben is dus een subklasse van de klasse object welke ingebakken zit in Python.

We kunnen nu zélf subklassen gaan maken. Onderstaande code definieert opnieuw een klasse Cirkel. De definitie geeft echter aan dat deze klasse een subklasse is van de klasse Figuur (en niet van object).

class Cirkel(Figuur):
    def __init__(self,x,y,s):
        Figuur.__init__(self,x,y)
        self.straal = s

    def omtrek(self):
        return 2*3.14*self.straal

De betekenis hiervan reikt echter verder dan het simpele feit dat we hiermee uitdrukken dat iedere cirkel een figuur is. Subklassen erven namelijk alle attributen en methoden over van hun superklassen. Dat wil dus zeggen dat we objecten van de klasse Cirkel ook de methoden schuif en plaats kunnen oproepen zonder dat we daarvoor enig extra programmeerwerk hoeven te verrichten. Dit is het geval voor alle subklassen van de klasse Cirkel. Bovendien is de overervingsrelatie transitief. Alle subklassen van de klasse Cirkel zullen dus op hun beurt de methoden overerven.

c = Cirkel(1,1,2)
c.omtrek()
12.56
c.schuif(5,6)
c.centrum.x
6
c.centrum.y
7

Het is leerrijk om de constructor __init__ van de klasse Cirkel te bestuderen. Deze bestaat uit twee lijnen code. In de eerste lijn zien we Figuur.__init__(self,x,y). De constructor van de klasse Cirkel roept dus eerst de constructor op van de klasse Figuur en zal nadien een attribuut met naam straal toevoegen aan het zopas gemaakte object. Een Cirkel zal dus 3 attributen hebben: x, y en straal.

Het mechanisme van “subclassing” zal er dus voor zorgen dat een Cirkel 4 methoden bevat: één constructor, de methode schuif (overgeërfd), de methode plaats (overgeërfd) en de methode omtrek.

We maken onze oefening af door de klasse Rechthoek te definiëren als subklasse van Figuur (omdat iedere rechthoek een figuur is) en door Vierkant verder als subklasse van Rechthoek te definiëren (omdat ieder vierkant ook een rechthoek is). De volledige klassehiërarchie is zichtbaar in figuur onderstaande figuur. image

class Rechthoek(Figuur):
    def __init__(self,x,y,l,b):
        Figuur.__init__(self,x,y)
        self.lengte = l
        self.breedte = b
    def omtrek(self):
        return 2*(self.lengte+self.breedte)

class Vierkant(Rechthoek):
    def __init__(self,x,y,z):
         Rechthoek.__init__(self,x,y,z,z)

Een vierkant wordt aangemaakt door de lengte van beide zijden van de rechthoek gelijk te kiezen. Daarom heeft de constructor van Vierkant slechts 3 parameters (op het object self na) en wordt meteen de constructor van Rechthoek opgeroepen waarbij z twee keer gebruikt wordt. Merk op dat de methode omtrek automatisch door Vierkant overgeërfd wordt (evenals alle methoden die Rechthoek op zijn beurt van Figuur overerft). Volgende experimentjes illustreren de overerving van attributen en methoden duidelijk: we pakken attributen en methoden vast van een Vierkant maar in feite zijn deze allemaal overgeërfd uit de klasse Rechthoek.

v = Vierkant(1,1,3)
v.omtrek()
12
v.schuif(1,1)
v.centrum.x
2

Gevalstudie: Een Projectielspel


In deze sectie leggen we alle puzzelstukken van objectgericht programmeren samen om een heus computerspel te bouwen. Het spel bestaat uit een speelveld dat een kanon bevat. Op regelmatige tijdstippen positioneert de loop zich in een willekeurige richting en wordt er een kanonbal afgevuurd. Wanneer een bal tegen een zijwand botst wordt hij teruggekaatst. Indien de bal tegen de hemel vliegt of op de grond terecht komt beschouwen we hem als “verloren”. Indien we tijdig met onze muis bovenop de bal hooveren (t.t.z. gewoon op dezelfde positie op het scherm komen) verdwijnt de bal van het scherm en beschouwen we hem als “gered”. Om het spel wat interessanter te maken varen er twee wolken (een witte en een zwarte) heen en weer in de hemel. Kanonballen vliegen zonder problemen door de witte wolk heen maar worden door de zwarte wolk verticaal naar beneden gekatapulteerd. Wanneer twee ballen botsen worden ze beiden weerkaatst in de omgekeerde richting. Onderaan geeft een scorebord op ieder ogenblik aan hoeveel ballen er verloren en gered werden.

Alle code nodig om het spel te spelen is netjes opgeslagen in de module Kanonspel. De code wordt hierna in detail uitgelegd maar we demonstreren het spel al een eerste keer om de uitleg te kaderen. We gebruiken voor de visualisatie van het spel VPython.

from Kanonspel import *
test = Canon_Game(600, 20, 3, 3)
test.play()

Algemeen Ontwerp

Het projectielspel bestaat grosso modo uit een klasse voor het spel zelf, een verzamelingen van klassen voor de logische spelelementen en een verzameling van klassen voor de visualisatie-elementen.

Het spel

Het spel heeft een aantal kenmerken zoals de groottte van het speelveld en een manier om te controleren hoelang het spel loopt. Maar het hart van het spel is een een lijst van spelelementen t.t.z. objecten die de wolken, het kanon en de ballen voorstellen. Het spel wordt gespeeld door aan elk van deze spelelementen op geregelde tijdstippen te vragen van zich te “updaten”. Dat gebeurt in een spellus.

Logische spelelementen

Dit zijn verschillende soorten objecten die de logische spelelementen beschrijven bijvoorbeeld een kanon, kanonballen en wolken. Spelelementen kennen hun eigen toestand. Voor bewegende elementen zoals wolken en kanonballen omvat dat bijvoorbeeld hun positie en snelheid. Voor het kanon moet bv. ook de hoek van de loop vastgelegd worden.

Ieder spelelement moet een update methode implementeren, e.g. een methode die bepaalt hoe de status van het element eventueel dient te veranderen. Ballen zullen een beetje verder vliegen, wolken zullen zich verplaatsen, het kanon zal met een zekere frequentie een bal afschieten, enzoverder. Die update methode zal in de spellus worden aangeroepen.

Visualisatie-elementen

Naast de objecten die de logische toestand van de spelelementen bevatten zal ieder element een verwijzing hebben naar een “zusterobject” dat de visuele representatie van dat element voorstelt. In deze implementatie worden voor de visualisatie VPython objecten gebruikt. Een kanon wordt bijvoorbeeld voorgesteld door een object waarin een een VPython sphere en een VPython cilinder worden gekombineerd.

De visuele elementen kennen eveneens een methode update die gebeurlijk door de update van het corresponderende spelelement wordt opgeroepen nadat de logische status van het spelelement aangepast wordt. Het is de bedoeling dat de update methode voor de visuele elementen deze aanpassingen ook visueel doorvertalen door de positie, kleur of zichtbaarheid van de VPython objecten te veranderen.

Het scheiden van de logica en de visualisatie van een applicatie is een gekend ‘softwarepatroon’ dat toelaat om op een later tijdstip bijvoorbeeld een totaal andere bibiliotheek te gaan gebruiken voor de visualisatie zonder dat er in het logische deel van de code moet ingegrepen worden. In wat volgt worden eerst alle logische spelelementen in detail uitgelegd. Je zal zien dat die telkens een attribuut view hebben en dat daar soms een update naar toe gestuurd wordt maar de uitleg daarover komt in de sectie erna die de visualisatie-elementen bespreekt.

We importeren natuurlijk vpython maar ook de modules time en random omdat die wat elementen bevatten die we gebruiken. De eerste klase Canon_Game stelt het spel zélf voor. De bedoeling is dat er slechts één object van deze klasse gemaakt zal worden om het spel te spelen. Indien we de constructor bekijken zien we welke attributen dit object zal hebben.

from vpython import *
from time import *
from random import *

class Canon_Game(object):
    def  __init__(self, size=600, maxout=20, speed=3, pace=3):
        self.maxout = maxout
        self.size = size # all other objects will be sized relative to this 
        self.speed = speed * size #speed of the game relative to size
        self.pace = pace  #pace of shooting
        self.lost = 0 #counter for balls out
        self.saved = 0 #counter for balls saved
        self.view = Game_View(self)
        self.items = [Canon(self), BlackCloud(self), WhiteCloud(self)] #start items in the game
    def activate(self, item):
        self.items.append(item)
    def deactivate(self,item):
        self.items.remove(item)
    def incr_lost(self):
        self.lost = self.lost + 1
        self.view.update()
    def incr_saved(self):
        self.saved = self.saved + 1
        self.view.update()
    def play(self):
        t = 0
        dt = 0.01
        while (self.lost < self.maxout):
            rate(50)
            for itm in self.items:
                itm.update(dt)
                t = t + dt

Bij constructie van het spel (zie __init__) wordt de grootte van het speelveld (size), het aantal verloren kogels dat het spel doet eindigen (maxout), de vliegsnelheid van de kanonballen (speed) en de frequentie met dewelke een nieuwe bal zal verschijnen (pace) meegegeven. Daarnaast worden ook nog attributen voorzien voor het aantal geredde ballen saved en het aantal buiten gevlogen ballen lost en wordt er een lijst items van drie “standaard spelelementen” aangemaakt. Dat zijn het kanon, een witte wolk en een zwarte wolk. Merk op dat het spel zichzelf meegeeft aan de constructor van al deze items zodat die op elk moment weten in welk spel ze ingebakken zitten. Tenslotte is er ook het view attribuut dat verwijst naar de visuele representatie.

Het spel bevat twee methoden om nog andere items toe te voegen en te verwijderen. Dit gebeurt telkens een bal aan het spel wordt toegevoegd omdxat het kanon een schot lost (activate) en telkens een bal buiten vliegt of met de muis wordt gered (deactivate). Het spel heeft ook twee methodes om het aantal gemiste en geredde ballen op te hogen. Merk op dat deze methoden meteen na het verhogen van de score aan de view vragen om zich te updaten zodat de nieuwe scores ook op het scherm zullen terecht komen.

De methode play implementeert de spellus en dient te worden opgeroepen om het spel in gang te zetten. Deze laat de tijd t lopen in stapjes van dt zolang lost kleiner blijft dan maxout. Zoals eerder uitgelegd moet je een goede combinatie van dt en rate vinden om het spel vloeiend te laten bewegen en er op snelle en trage computers ongeveer hetzelfde te laten uitzien. In elke stap van de lus wordt aan elk element in items gevraagd om zichzelf te updaten, i.e. hun nieuwe toestand na verloop van dt te berekenen.

De Spelelementen

Laat ons nu één voor één de spelelementen bestuderen. Deze elementen werden ondergebracht in de klassehiërarchie die getoond wordt in onderstaande figuur. De top van deze hiërarchie is de klasse Game_Item, elk type spelelement is (indirect) een subklasse van Game_Item.

image

In de constructor van Game_Item wordt een argument van het type Game meegegen. Omdat subklassen de constructor van hun superklassen aanroepen zal elk spelelement weten tot wel spel het behoort. De grootte van een spelelement is standaard een zesde van de spelgrootte. Een groter spel zal dus relatief ook grotere spelelementen hebben (een groter kanon dat op zijn beurt dikkere ballen zal afschieten). De standaardpositie van het spelelement is \((0,0)\) maar dat wordt nadien aangepast tijdens de uitvoering van het spel.

class Game_Item(object):
    def  __init__(self, game):
        self.game = game
        self.size = game.size/6.0
        self.x = 0
        self.y = 0

Bekijk nu hieronder de constructor van de klasse Canon. Een kanon krijgt bij constructie een loop met een random hoek angle binnen zekere grenzen (door de functie uniform op te roepen) en een kaliber caliber dat op zijn beurt relatief is aan de grootte van het kanon. Er is extra attribuut timer dat in de update methode wordt gebruikt om de frekwentei van afvuren te controleren. Tenslotte heeft een kanon een specifieke visuele representatie die wordt bijgehouden in het attribuut view.

De methode update die door het spel op regelmatige tijdstippen wordt opgeroepen zal de timer van het kanon bijstellen en zien of het reeds voldoende lang geleden is dat er een schot werd afgevuurd. Indien dat inderdaad zo is, wordt de bijgehouden timer weer op nul gezet, wordt de loop op een willekeurige hoek gepositioneerd (opnieuw door uniform), wordt het schot daadwerkelijk afgevoerd door de methode shoot op te roepen en wordt nadien de view van het kanon gevraagd zich te updaten.

De methode shoot voert het schot uit door een nieuwe bal als actief spelelement toe te voegen door self.game.activate op te roepen waardoor de nieuwe bal in de lijst van spelelementen terecht komt. De grootte van de bal is bepaald door het kaliber van het kanon en de snelheidsvector wordt bepaald door de snelheid van het spel (voor de lengte van de vector) en hoek van de de loop (voor de richting van de snelheidsvector). De snelheid was een instelling van het spel en krijgen we door self.game.speed vast te pakken.

In dit voorbeeld spel wordt slechts 1 kanon aangemaakt in de constructor van het spel maar je kan gerust varianten bedenken waar er meerdere kanonnen in het spel worden gezet. Elk kanon is een instantie van deze klasse Canon.

class Canon(Game_Item):
    def  __init__(self, game):
        Game_Item.__init__(self, game)
        self.angle = uniform(-pi/2*0.8,pi/2*0.8)# gun positioned at random in upper part
        self.caliber = self.size/5.0
        self.timer = 0
        self.view = Canon_View(self)
    def shoot(self):
        self.game.activate(Ball(self.game, self.caliber, self.size*sin(self.angle), self.size*cos(self.angle),
                                self.game.speed*sin(self.angle), self.game.speed*cos(self.angle)))
    def update(self,dt):
        self.timer = self.timer + dt
        if self.timer > 1/self.game.pace:
            self.timer = 0
            self.angle = uniform(-pi/2*0.8,pi/2*0.8)
            self.shoot()
            self.view.update()

Alle bewegende spelelementen (t.t.z. de wolken en de ballen) zijn speciale gevallen van Moving_Item. Een Moving_Item heeft een snelheid zowel in de x- als in de y-richting. Dit zijn de attributen velx en vely. Deze vormen samen de snelheidsvector van het bewegend spelelement. Ze worden in de constructor standaard op 0 gezet maar dat gedrag kan door de constructor van de subklassen overschreven worden en natuurlijk ook tijdens het spel veranderd worden.

class Moving_Item(Game_Item):
    def  __init__(self, game):
        Game_Item.__init__(self, game)
        self.velx = 0
        self.vely = 0
    def update(self,dt):
        newx = self.x + self.velx*dt
        newy = self.y + self.vely*dt
        in_side = self.game.size/2 - self.size/2
        if (in_side > newx > -in_side):
            self.x = newx
        else:
            self.velx = -self.velx
            if self.x > 0:
                self.x = in_side
            else:
                self.x = -in_side
        if (in_side > newy > -in_side):
            self.y = newy
        else: 
            self.vely = -self.vely
            if self.y > 0:
                self.y = in_side
            else:
                self.y = -in_side
        self.view.update()

Het updaten van een Moving_Item bestaat uit het berekenen van een mogelijks nieuwe positie door de huidige positie te verhogen met de snelheid maal de verlopen tijd dt. Indien met deze nieuwe positie het item buiten het speelveld valt wordt de snelheidsvector omgedraaid en wordt de positie van het spelelement ingeperkt tot net op de rand van het speelveld. Wanneer het item niet buiten het speelveld terecht komt wordt die positie van het item effectief de nieuw berekende positie. Na het aanpassen van zijn positie zal het bewegend item zichzelf moeten hertekenen. Dit wordt gedaan door update op te roepen op de visuele representatie van het bewegend item. Merk op dat we weten dat het spelelement buiten het speelveld dreigt te vallen als zijn middelpunt (wat gegeven wordt door self.x en self.y) meer naar rechts of naar links ligt dan de rand van het spel plus of minus de helft van de grootte van het spelelement. Maak een tekening met het coördinatenstelsel van het speelveld om jezelf hiervan te vergewissen!

Een speciaal soort bewegend item is de wolk. Een wolk krijgt bij constructie een random positie (horizontaal tussen de grenzen van het speelveld en verticaal tussen een bepaalde “band” die door de constanten \(0,6\) en \(0,8\) hieronder weergegeven wordt) en een x-snelheid die random bepaald wordt maar relatief is tov de snelheid van het spel; wolken zullen altijd trager vliegen dan kanonballen. De y-snelheid is de default 0; wolken varen horizontaal.

Merk op dat wolken gewoon de update van het bewegend spelelement erven en dus zullen varen en botsen op de zijwanden. Het enige waarin de constructie van witte en zwarte wolken verschilt is dat hun visuele representatie anders is. Dat gebeurt door in de constructor respectievelijk de constructor van de visuele representatie Cloud_View op te roepen met ’white’ dan wel ’black’ en het resultata in het attribuut view op te slaan.

In dit voorbeeld spel wordt slechts 1 witte en 1 zwarte wolk aangemaakt in de constructor van het spel maar je kan gerust varianten bedenken waar er meer wolken in het spel worden gezet. Elke wolk is een instantie van ofwel WhiteCloud ofwel BlackCloud. Een instantie van Cloud heeft geen visualisatie zusterobject en het is niet de bedoeling om hier instanties van te maken.

class Cloud(Moving_Item):
    def  __init__(self, game):
        Moving_Item.__init__(self, game)
        self.x = uniform(-self.game.size/2+self.size, self.game.size/2-self.size) #random x position 
        self.y = uniform(0.6*self.game.size/2,0.8*self.game.size/2) #random y position in upper part
        self.velx = uniform(-self.game.speed/4,self.game.speed/4) #rondom speed relative to game speed

class WhiteCloud(Cloud):
    def  __init__(self, game):
        Cloud.__init__(self, game)
        self.view = Cloud_View(self,'white')

class BlackCloud(Cloud):
    def  __init__(self, game):
        Cloud.__init__(self, game)
        self.view = Cloud_View(self,'black')

We bestuderen ten slotte het gedrag van de ballen. Een bal wordt door het kanon afgeschoten en krijgt van het kanon een grootte (overeenkomend met het kaliber) en een snelheidsvector (weergegeven door velx en vely). Bij constructie zal de bal zijn view associëren met een Ball_View welke zijn zusterobject is dat de visuele representatie voor zijn rekening neemt.

class Ball(Moving_Item):
    def  __init__(self, game, size, x, y, velx, vely):
        Moving_Item.__init__(self, game)
        self.size = size
        self.x = x
        self.y = y
        self.velx = velx
        self.vely = vely
        self.view = Ball_View(self)
    def handle_scores(self):
        in_side = self.game.size/2 - self.size/2
        if not (-in_side < self.y < in_side):
            self.game.incr_lost()
            self.game.deactivate(self)
        elif self.view.picked():
            self.game.incr_saved()
            self.game.deactivate(self)
            self.view.erase()
    def handle_colisions(self):
        for itm in self.game.items: 
            if abs(self.x-itm.x) < (self.size+itm.size)/2 and abs(self.y-itm.y) < (self.size+itm.size)/2:
                if type(itm) == BlackCloud:
                    self.velx = 0
                    self.vely = -self.game.speed*2
                elif type(itm) == Ball:
                    self.velx = -self.velx
                    self.vely = -self.vely
                    itm.velx = -itm.velx
                    itm.vely = -itm.vely
    def update(self,dt):
        Moving_Item.update(self, dt)
        self.handle_scores()
        self.handle_colisions()

Een bal heeft een “rijker” gedrag dan de andere bewegende spelelementen. De update doet eerst een standaard update van Moving_Item waardoor ballen zullen botsen op de zijwanden. Daarnaast zal de update de handle_scores aanroepen om vast te stellen of een bal door de muis ‘gegrepen’ wordt en de bal als saved registeren of om vast te stellen dat de bal onder of boven aan het de rand van het speelveld zit en de bal als lost registeren. In beide gevallen wordt de bal uit het spel gehaald door hem logisch uit de lijst van items van het spel weg te halen. Ballen die ‘uit’ vliegen blijven gewoon aan de rand van het speelveld plakken. Alleen als de bal door de muis gegrepen werd wordt hij ook effectief uit de visualisatie weggegomd. Checken of de muis de bal grijpt en het uitgommen wordt gedelegeerd naar de methoden picked en erase van het visualisatie zusterobject van de bal. Dat is ook een standaard patroon. Je verwacht dat de bibliotheek die je gebruikt voor de visualisatie dit kan realiseren maar je wil het detail van hoe dat moet niet hier in je code binnenbrengen. picked moet gewoon een True of False teruggeven (zit de muis boven de visualisatie van de bal), erase doet wat moet om de bal te doen verdwijnen van het scherm.

Tenslotte gaat de update door de aanroep van handle_colisions ook botsingen afhandelen door na te kijken of de bal in een botsing verwikkeld is geraakt met een ander spelelement. Hiertoe zal de bal alle items van het spel één voor één aflopen en nakijken of de afstand van de x en de y-coördinaten van beide middelpunten kleiner is dan helft van de som van de groottes. Indien er daadwerkelijk een botsing gedetecteerd is (dat is de buitenste if test) wordt getest of de bal botst met een zwarte wolk of met een andere bal. In het geval van een zwarte wolk wordt de bal steil naar beneden gestuurd (twee keer de snelheid van het spel). In het geval van botsing met een andere bal worden de snelheidsvectoren van beide ballen omgedraaid.

In dit voorbeeld spel worden veel bal objecten aangemaakt, i.e. telkens het kanon schiet.

Grafische Voorstelling

Nu we de klassehiërarchie bestudeerd hebben die de logica van het spel bevat, bestuderen we vervolgens een (parallelle) hiërarchie van klassen die zorgen voor de visuele representatie van elk der spelelementen waarvoor we op VPython en beroep gaan doen.

De top van de klassehiërarchie is de klasse View. Elke visuele representatie zal hier (indirect) een subklasse van zijn. Het enige dat we over iedere view weten is dat hij de visuele representatie voorstelt van een spelelement of van het spel. Dat noemen we de owner van de view.

Kort samengevat: Het spel zelf en alle concrete spelelementen bevatten een attribuut view dat een zusterobject met de visuele voorstelling van dat logische spelelement bevat. Omgekeerd zal elk visueel element een attribuut owner hebben dat verwijst naar het zusterobject spelelement dat het voorstelt. M.a.w. spelelementen en visuele elementen kennen mekaar wederzijds via de attributen view en owner.

Wanneer de toestand van een logisch spelelement wijzigt door de eigen update methode zal deze methode update op de view oproepen zodat de visuele voorstelling zich navenant aanpast. De update van het visueel element kan via de owner op elk moment alle toestandsgegevens van het spelelement opvragen om zichzelf correct grafisch weer te geven. Bijvoorbeeld een visuele representatie van een bal zal altijd zijn positie opvragen aan het logische bal object dat zijn owner is.

Merk op dat het logische spel een 2D spel is. Spelelementen hebben alleen een x- en y-coördinaat. VPython is een 3D bibliotheek. De z-coördinaat in de VPython objecten wordt consistent op 0 gezet.

class View(object):
    def  __init__(self, owner):
        self.owner = owner

Een view voor het spel zelf zullen we Game_View noemen. De constructor begint met een VPython scene aan te maken met een grijze achtergrondkleur en hoogte en breedte gelijk aan de grootte van het spel zelf. De userzoom wordt uitgezet omdat standaard in VPythom de muis gebruikt wordt om op de scene die wordt voorgesteld in of uit te zoomen. Maar wij willen de muis gebruiken om ballen te vangen. Er wordt 1 box aangemaakt met een blauwe kleur die als speelveld dient. De constructor voegt eigenlijk maar één attribuut toe aan Game_View en dat is een label dat de string van het scorebord voorstelt waar de startscores 0 zijn. Dat is een VPython object van de klasse label waarvan de text kan aangepast worden. De update methode zal de lost en saved attributen van de owner van deze view opvragen, een nieuwe string knutselen, en de text van het VPython object aanpassen. VPython zal automatisch het label “hertekenen” op het scherm.

class Game_View(View):
    def  __init__(self, owner):
        View.__init__(self, owner)
        global scene
        scene = canvas()
        scene.userzoom = False
        scene.background = color.gray(0.6)
        scene.width = owner.size 
        scene.height = owner.size     
        box(pos=vector(0,0,0),length=inner, height=inner, width=2,  color=vector(0.6,0.8,0.9))
        score_size = owner.size/20
        score_pos = vector(0, -border+score_size+thk, 0)  
        self.label = label(pos=score_pos, height=score_size, 
                           text= 'Lost: ' + str(0)+ ' '*8 + 'Saved: ' + str(0))
    def update(self):
        self.label.text = 'Lost: ' + str(self.owner.lost) + ' '*8 + 'Saved: ' + str(self.owner.saved)

Ten slotte bestuderen we de views die passen bij de spelelementen. Het kanon wordt visueel voorgesteld door Canon_View. Onderstaande constructor vraagt de grootte, de hoek, het caliber en de positie van de owner op en voegt op basis hiervan twee attributen base en gun aan het pasgemaakte visualisatie-object toe. De basis van het kanon wordt voorgesteld door een rode sphere uit VPython. De loop wordt voorgesteld door een rode VPython cylinder waarvan de diameter overeenkomt met het kaliber van het kanon, de lengte een beetje groter is dan de maat van het kanon en de as in de juiste hoek staat.

class Canon_View(View):
    def  __init__(self, owner):
        View.__init__(self, owner)
        size = owner.size
        angle = owner.angle
        caliber = owner.caliber
        center = vector(owner.x,owner.y,0)
        self.base = sphere(color=color.red, pos=center, radius = size/2)
        self.gun = cylinder(color=color.red, pos=center, 
                            axis=vector(size/2*1.4*sin(angle),size/2*1.4*cos(angle),0), radius=caliber/2)
    def update(self):
        size = self.owner.size
        angle = self.owner.angle
        self.gun.axis=vector(size/2*1.4*sin(angle),size/2*1.4*cos(angle),0)

Indien het kanon zijn view vraagt zich te updaten (door bovenstaande methode update op te roepen), dan zal de Canon_View de as van de VPython cylinder aanpassen. De grootte en hoek wordt opgehaald uit de owner van de visuele representatie.

De visuele representatie van wolken wordt gerealiseerd door Cloud_View. Onderstaande constructor vraagt de grootte en de positie van de owner op en voegt op basis hiervan één attribuut body toe aan het pasgemaakte visualisatie-object. De body van een wolk is een ellipsoïde in VPython. De kleur van de ellipsoïde (een 3-tal met RGB-waarden) hangt af van de string ’white’ of ’black’ die aan de constructor van het visuele element wordt meegegeven. Telkens wanneer een wolk vraagt van haar visuele representatie te updaten (door onderstaande update op te roepen), vertellen we dat gewoon aan VPython door de pos vector van de ellipsoïde aan te passen op basis van de x,y-coördinaten van de owner. Het eigenlijke tekenwerk wordt door VPython gedaan.

class Cloud_View(View):
    def  __init__(self, owner, color_opt):
        View.__init__(self, owner)
        size = owner.size
        center = vector(owner.x,owner.y,0)
        self.body = ellipsoid(pos=center, length=size*1.2, height=size, width=size*0.1)
        if color_opt == 'white':
            self.body.color = color.gray(0.95)
        else:
            self.body.color = color.gray(0.5) 
    def update(self):
        self.body.pos = vector(self.owner.x,self.owner.y,0)

Ten slotte bespreken we de visuele representatie van ballen. De visuele representatie van een bal wordt gerealiseerd door Ball_View. Onderstaande constructor vraagt de grootte en de positie van de owner op en voegt op basis hiervan één attribuut body toe aan het pasgemaakte visualisatie-object. De body van een wolk is een sfeer in VPython.

class Ball_View(View):
    def  __init__(self, owner):
        View.__init__(self, owner)
        size = owner.size
        center = vector(owner.x,owner.y,0)
        self.body = sphere(color=color.green, pos=center, radius=size/2.0)
    def update(self):
        self.body.pos = vector(self.owner.x,self.owner.y,0)
    def picked(self):
        return scene.mouse.pick == self.body
    def erase (self):
        self.body.visible = False
        del self.body

Het updaten van een bal bestaat uit het aanpassen van de positievector van de VPython visualisatie naar de corresponderende posities uit het spelelement (via owner). Bij een Ball_View zijn twee extra methoden nodig. Om aan de logische kant te weten of de bal “onder de muis” is terecht gekomen wordt een methode picked opgeroepen. In VPython kan je aan een scene vragen wat er onder de muis ligt via het attribuut scene.mouse.pick. Als in het spel een bal door de muis wordt gered is het de bedoeling dat de bal echt uit scene verdwijnt en wordt een methode erase opgeroepen. In VPython kan je een element onzichtbaar maken door een attribuut visible of False te zetten. De implementaties van de methodes picked en erase zijn dus heel VPython specifiek maar aan de logische kant van het spel sijpelt dat niet door.

Samenvatting

In dit topic hebben we bestudeerd hoe we zélf klassen kunnen maken. Dit houdt in dat we gewoon Python blijven programmeren als voorheen, maar dat we onze functies en variabelen nu samen huisvesten in een klasse met een goed gekozen naam. Deze functies vormen dan de methoden van de objecten van de klasse en de variabelen vormen de attributen van de objecten. Het is natuurlijk belangrijk te begrijpen dat ieder object zijn eigen versie heeft van die attributen.

Nadat we het bouwen van klassen hebben bestudeerd, hebben we gezien dat we klassen kunnen organiseren in hiërarchieën. Dat doen we door een klasse te laten afhangen van een superklasse. Belangrijk is te begrijpen dat een klasse alle methoden en attributen overerft van haar superklasse. Maar een klasse kan van dit recht afzien door een andere implementatie te geven aan die methode en dus de geërfde methode te overschrijven.

Twee begrippen zijn belangrijk bij het begrijpen van objectgericht programmeren. Encapsulatie betekent dat objecten eigenlijk al hun attributen inkapselen. Gebruikers lezen bij voorkeur de attributen niet rechtstreeks uit maar doen alle communicatie met de objecten via de methoden. Op die manier kunnen de ontwerpers van een klasse later beslissen om een geheel andere implementatie te geven aan de klasse, zolang de werking van de methoden gelijk blijft. Polymorfisme betekent dat we verschillende klassen van methoden met gelijke naam en gelijk aantal parameters kunnen voorzien. Hierdoor kan gebruikerscode deze methoden “blindelings” oproepen zonder precies te weten met welk soort object men precies te doen heeft. De inkapseling zorgt ervoor dat automatisch de juiste methode wordt opgeroepen afhankelijk van de klasse waartoe het object behoort.

Nadien hebben we een kanonspel gebouwd dat alle begrippen samenvoegt. Het spel bestaat uit twee hiërarchieën die de logica en de visuele representatie van de spelelementen bewerkstelligen. Het beste voorbeeld van het gebruik van polymorfisme is de oproep van de methode update. Deze wordt bijvoorbeeld door het spel op ieder spelelement blindelings opgeroepen. Afhankelijk van het spelelement wordt er echter geheel andere code uitgevoerd.