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.
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 |
---|---|
|
Oproep van de constructor |
|
Om een string representatie te krijgen |
|
|
|
|
|
|
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.
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. Dieupdate
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 VPythoncilinder
worden gekombineerd.
De visuele elementen kennen eveneens een methode
update
die gebeurlijk door deupdate
van het corresponderende spelelement wordt opgeroepen nadat de logische status van het spelelement aangepast wordt. Het is de bedoeling dat deupdate
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
.
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.