FACULTEIT INDUSTRIELE INGENIEURSWETENSCHAPPEN! CAMPUS DE NAYER! NETWERKEN en OBJECTORIËNTATIE Deel 2: Objectoriëntatie in Python Joost Vennekens
Inhoudsopgave 1 Objecten en klassen 5 1.1 Een beetje geschiedenis...................... 5 1.2 Objecten................................ 7 1.3 Klassen................................. 11 1.4 Constructoren............................ 15 1.5 Magische methodes......................... 18 1.6 Wijzigen van attributen....................... 20 1.7 Voorbeelden.............................. 21 1.7.1 De klasse Drank...................... 21 1.7.2 De klasse Rechthoek................... 22 1.7.3 De klasse Cirkel...................... 22 1.8 Een blik achter de schermen................... 23 2 Samenwerkende objecten 25 2.1 Een object als argument...................... 25 2.2 Een object als resultaat...................... 26 2.3 Associaties tussen klassen.................... 27 2.4 Lijsten en objecten.......................... 29 2.4.1 Objecten met lijsten..................... 30 2.4.2 Lijsten van objecten..................... 31 2.4.3 Objecten met lijsten van objecten............ 31 2.5 Voorbeelden.............................. 34 2.5.1 Punten en veelhoeken................... 34 2.5.2 Cocktails en dranken.................... 36 3 Overerving 39 3.1 Het overschrijven van methodes................. 41 3.2 Overervingshiërarchieën...................... 44 3.3 Methodes uit een superklasse oproepen............ 46 3.4 Voorbeelden.............................. 48 3.4.1 Vierkanten.......................... 48 3
4 Varia 49 4.1 Python als server-side scripting taal.............. 49 4.2 Methodes met default argumenten................ 50 4.3 Vergelijken van objecten...................... 51 4.4 Nog meer vergelijkingen...................... 55 4.5 Uitzonderingen............................ 56 4.5.1 Uitzonderingen opwerpen................. 58 4.5.2 Uitzonderingen afhandelen................ 60 4.5.3 Herwerking van het voorbeeld.............. 62 4.6 Statische methodes......................... 63 4.7 Nog een blik achter de schermen................. 67 4.7.1 De volledige klasse Cirkel................ 68 4.8 Private variabelen.......................... 68 4.9 Eigenschappen (Properties).................... 74 A Practicum opgaves 77 A.1 Setup.................................. 77 A.2 Vogelpik................................ 78 A.3 De eerste objectjes.......................... 80 A.4 Punten, ruimtes en ballen..................... 81 A.5 Tijd voor actie............................ 83 A.6 Muren................................. 85 A.7 Krachten................................ 87 4
Objecten en klassen 1 1.1 Een beetje geschiedenis Als 1 programmeur op 1 dag 100 lijnen Python code kan schrijven, hoeveel lijnen code schrijft hij dan op 10 dagen? Of, hoeveel code schrijft een team van 10 programmeurs dan op 1 dag? Het is vooral voor IT managers verleidelijk om op deze vragen het antwoord 1000 te geven. De praktijk wijst echter uit dat echte antwoord veel minder is. De verklaring daarvoor ligt in het feit dat lijnen code niet onafhankelijk zijn van elkaar. Als de ene lijn een toekenning x = x * 2 doet, en een paar lijnen erna volgt een test op de waarde van x: i f x > 5: print "wat veel!" dan is de eerste lijn code duidelijk relevant voor de tweede lijn. Met andere woorden, om te kunnen begrijpen wat de tweede lijn juist doet, moeten we ons bewust zijn van het bestaan van de eerste lijn. Een programma van 200 regels is daarom niet gewoon maar dubbel zo complex als een programma van 100 regels, maar veel méér: elke lijn van de 200 regels kan immers potentieel relevant zijn voor elke andere lijn. Er zijn dus 200 2 mogelijke interacties tussen lijnen code, die allemaal relevant kunnen zijn voor de programmeur, ten opzichte van 100 2 mogelijke interacties in het kortere programma. De complexiteit van een programma hoe moeilijk het is om dit programma te begrijpen stijgt dus veel sneller dan het aantal lijnen code. Programmeertalen hebben sinds de begindagen van de computer een grote ontwikkeling doorgemaakt. Deze evoluties kunnen best begrepen worden als een voortdurende zoektocht naar manieren om dit fenomeen tegen te gaan. Globaal gesproken is het de bedoeling om grote programma s zoveel mogelijk uiteen te trekken in kleinere stukjes. Als we ons programma van 200 lijnen code kunnen opdelen in twee 5
stukken van elk 100 lijnen én we kunnen dit zodanig doen dat we er zeker van zijn dat geen enkele lijn code uit het ene deel relevant is voor het andere deel, dan blijven en slechts 2 100 2 mogelijke interacties over, wat een pak minder is dan 200 2. Maar hoe kunnen we dat nu verwezenlijken? In het begin zocht men vooral zijn heil in het opsplitsen van de grote taak die een programma moest vervullen in kleinere deeltaken. Dit heeft geleid tot het invoeren van functies. De hoop hierbij was dat men een programma zou kunnen begrijpen door al zijn functies te begrijpen, en dat men elke functie afzonderlijk zou kunnen begrijpen. Men dacht, met andere woorden, dat enkel maar lijnen code binnen dezelfde functie relevant zouden zijn voor elkaar, zodat het zou volstaan om een programma op te splitsen in functies die klein genoeg zijn. Als we onze 200 lijnen code opsplitsen in 10 functies van 20 lijnen, dan zijn er maar 10 20 2 mogelijke interacties, wat een grootte-orde minder is dan de oorspronkelijke 200 2 : eureka! Helaas bleek al snel dat dit in de praktijk toch niet zo goed werkte. De reden hiervoor ligt zelfs nogal voor de hand: als we ons programma gaan opsplitsen in deeltaken, dan moeten we deze deeltaken natuurlijk na elkaar uitvoeren. Maar dat betekent dat de invoer van taak 2 natuurlijk de uitvoer van taak 1 zal zijn! En de invoer van taak 20 is de uitvoer van taak 19, wiens invoer de uitvoer van taak 18 was, wiens invoer de uitvoer was van taak 17, wiens invoer.... We zien al snel dat een taak helemaal niet onafhankelijk is van de taken die ervoor kwamen, maar dat ze hier juist heel erg van afhangt. Dit betekent ook dat als we in taak 1 een aanpassing doen, deze aanpassing mogelijk een effect kan hebben op taak 2, en dus ook op taak 3, en dus ook op.... Als we een regel veranderen in taak 1, dan zouden we dus eigenlijk alle andere regels code uit taken 2 t/m 20 terug moeten gaan bekijken, om te zien of we ze ook niet moeten aanpassen. Dat is duidelijk niet wat we willen. In het begin van de jaren 90 is men dan op zoek gegaan naar een alternatief. Merk op dat men hier dus niet gewoon maar op zoek was naar een nieuwe programmeertaal, maar naar een nieuw wereldbeeld. Er was nood aan een andere manier van kijken: een programma moest niet langer gezien worden als een verzameling van taken die konden worden opgesplitst in deeltaken, maar als... iets anders? De grote doorbraak die er dan gekomen is, is dat men beseft heeft dat een oud concept, bedacht door academici in de jaren 60, eigenlijk perfect het antwoord op deze vraag kon geven. Het concept was dat van objectgericht programmeren, en het antwoord is simpelweg: beschouw een programma niet langer als een verzameling van (deel-)taken, maar als een verzameling van samenwerkende objecten. Hoewel dit antwoord dus al bestaat sinds de jaren 60, heeft het tot de jaren 90 geduurd voor men eindelijk de bijhorende vraag bedacht heeft, namelijk: hoe kunnen we onze programma s zodanig structureren dat zoveel mogelijk 6
regels code onafhankelijk zijn van elkaar? 1.2 Objecten Om echt te kunnen begrijpen hoe het objectgerichte wereldbeeld ertoe kan leiden dat programma s beter (dwz. met meer onafhankelijkheden) gestructureerd worden, is het nodig om eerst wat dieper in te gaan op de betekenis die de term object in deze context heeft. We doen dit door even versneld de geschiedenis van het programmeren door te maken, aan de hand van een eenvoudig voorbeeld. Een cocktail bestaat uit een aantal ingrediënten die in een specifieke verhouding door elkaar gemengd moeten worden. Een vodka-orange bestaat bijvoorbeeld voor een derde uit vodka, en voor de rest uit fruitsap. Dit betekent dat om een glas van 25cl te vullen met vodka-orange, er 8,3cl vodka nodig is en 16,7cl fruitsap. En als we bijvoorbeeld al 10cl vodka hebben ingeschonken, is er nog 20cl fruitsap nodig. Dergelijke berekeningen worden natuurlijk des te uitdagender, naarmate er meer vodka-oranges geconsumeerd worden. Laten we daarom een computerprogramma maken dat ons kan helpen. verhouding = 1.0 / 3.0 cocktail def vodkavoorcocktail ( cocktail ) : return verhouding * cocktail def fruitsapvoorcocktail ( cocktail ) : return (1 verhouding ) * cocktail # aandeel vodka per eenheid def vodkavoorfruitsap ( fruitsap ) : return fruitsap * ( verhouding / (1 verhouding ) ) def fruitsapvoorvodka ( vodka ) : return vodka * ( ( 1 verhouding ) / verhouding ) Deze functies gebruiken we dan natuurlijk als volgt: >>> vodkavoorcocktail(25) 8.3333333333333321 >>> fruitsapvoorvodka(10) 20.000000000000004 In traditionele terminologie hebben we hier gebruik gemaakt van een datastructuur, waarin we de gegevens die we nodig hebben kunnen bijhouden, en daarbij horen een aantal functies, die op basis van deze datastructuur de gewenste uitvoer produceren. Natuurlijk is onze datastructuur hier heel eenvoudig, aangezien hij enkel maar bestaat uit 7
de waarde 1/3. Zelfs voor zo n eenvoudige datastructuur zijn er echter tal van alternatieven te bedenken. Bijvoorbeeld, we hadden in plaats van de hoeveelheid vodka per eenheid cocktail, ook de verhouding tussen de hoeveelheid fruitsap en de hoeveelheid vodka kunnen gebruiken. verhouding = 2.0 # eenheden fruitsap per eenheid vodka def vodkavoorfruitsap2 ( fruitsap ) : return fruitsap / verhouding def fruitsapvoorvodka2 ( vodka ) : return verhouding * vodka def vodkavoorcocktail2 ( cocktail ) : return cocktail / ( verhouding + 1) def fruitsapvoorcocktail2 ( cocktail ) : vodka = vodkavoorcocktail2 ( cocktail ) return fruitsapvoorvodka2 ( vodka ) Deze functies berekenen natuurlijk net dezelfde resultaten als voorheen: >>> vodkavoorcocktail2(25) 8.3333333333333339 >>> fruitsapvoorvodka2(10) 20.0 Hier zien we een eenvoudige illustratie van een belangrijk fenomeen: als we de voorstelling van onze data veranderen, dan moeten we ook alle functies veranderen die deze data gebruiken. Wat zou er gebeuren als we dit zouden vergeten? Dan zouden we bijvoorbeeld per ongeluk onze oude definitie van de functie fruitsapvoorvodka(vodka) samen kunnen gebruiken met onze nieuwe datavoorstelling verhouding = 2.0. Het is duidelijk dat dit een fout resultaat zal opleveren, en een cocktail die veel te licht is. Historisch weetje: In 1999 verloor de NASA $327.600.000 toen de Mars Climate Orbiter missie mislukte. De oorzaak van het feit dat deze satelliet opbrandde in de atmosfeer van Mars, zonder ook maar één zinvol resultaat te produceren, was een software-fout: de ene functie dacht dat een bepaald getalletje een waarde in Newton voorstelde, terwijl de andere dacht dat dit een waarde in Pond was. Of het nu gaat om ontploffende satellieten of cocktails die niet straf genoeg zijn, de les is dezelfde: het is gevaarlijk om een datastructuur los te zien van de functies die hem moeten gebruiken. Deze les is meteen de belangrijkste motivatie voor objectgericht programmeren. 8
Laat ons, voor we verder gaan, eerst eens een meer realistische versie van ons programma bekijken. Het is natuurlijk een beetje belachelijk dat we code geschreven hebben die enkel maar werkt voor vodka-oranges. Met een kleine beetje extra moeite, zouden we code kunnen schrijven die werkt voor alle cocktails. Hiervoor zullen we natuurlijk onze datastructuur iets ingewikkelder moeten maken, en onze functies daaraan aanpassen. # ingredienten per eenheid cocktail vodkaorange = { vodka : 0.33, fruitsap : 0. 67} def ingredientpercocktail ( cocktail, hoeveelheid, ingredient ) : return hoeveelheid * cocktail [ ingredient ] def ingredientperingredient ( cocktail, hoeveelheid, gegeven, gezocht ) : return hoeveelheid * ( cocktail [ gezocht ]/ cocktail [ gegeven ] ) Als we nu bijvoorbeeld willen weten hoeveel vodka er nodig is voor 20cl fruitsap, doen we: >>> ingredientperingredient(vodkaorange, 20, fruitsap, vodka ) 9.8507462686567155 We gebruiken nu als datastructuur een woordenboek (dictionary) en hebben twee functies geschreven die gebruik maken van deze datastructuur. Hier is nu eens een idee: aangezien we toch net besloten hebben dat de functies die een datastructuur gebruiken onafscheidelijk met deze datastructuur verbonden zijn, waarom steken we die functies dan niet gewoon bij in dat woordenboek? In Python kan dit immers perfect: we kunnen met een functie alles doen wat we met bijvoorbeeld een getal of een string kunnen doen. Iets als dit kan bijvoorbeeld perfect: def dubbel ( x ) : return x * 2 print dubbel ( 3 ) functie = dubbel print functie ( 3 ) De toekenning in de voorlaatste lijn geeft de functie dubbel in wezen gewoon een tweede naam, die we zoals de laatste lijn laat zien daarna eveneens kunnen gebruiken om de functie op te roepen. We kunnen dus even goed onze functie nemen en deze bij in de gegevensstructuur van onze cocktail plaatsen. 9
def ingredientpercocktail ( cocktail, hoeveelheid, ingredient ) : return hoeveelheid * cocktail [ ingredient ] def ingredientperingredient ( cocktail, hoeveelheid, gegeven, gezocht ) : verhouding = cocktail [ gezocht ] / cocktail [ gegeven ] return hoeveelheid * verhouding vodkaorange = { vodka : 0.33, fruitsap : 0.67, ipc : ingredientpercocktail, ipi : ingredientperingredient } Eender welke bewerking we met onze vodkaorange willen doen, kunnen we nu voor elkaar krijgen door enkel maar naar dit woordenboek te kijken. Het berekenen van een hoeveelheid fruitsap per hoeveelheid vodka, kan nu bijvoorbeeld zo: vodkaorange [ ipi ] ( vodkaorange, 20, fruitsap, vodka ) Hier komen dus zowel de functie die we toepassen als de data waarop we ze toepassen uit hetzelfde woordenboek. Een groot voordeel van deze aanpak is dat gelijk welke datastructuur we nu kiezen om onze gegevens in voor te stellen, we altijd de juiste functies erbij zullen hebben. Veronderstel bijvoorbeeld dat we, in plaats van te zeggen dat een Bloody Mary voor 0.25 uit vodka bestaat en voor 0.75 uit tomatensap (zoals we hierboven deden voor onze vodka-orange), liever zeggen dat hij één deel vodka moet bevatten per 3 delen tomatensap. bloodymary = { vodka : 1. 0, tomatensap : 3. 0} Bij deze voorstelling horen nu natuurlijk andere functies dan bij onze vodka-orange, namelijk: def ingredientpercocktail2 ( cocktail, hoeveelheid, ingredient ) : som = 0 for drank in cocktail : som += cocktail [ drank ] return hoeveelheid * cocktail [ ingredient ] / som (De functie ingredientperingredient mag dezelfde blijven, aangezien de som daar in zowel teller als noemer zou staan.) Deze functie kunnen we nu ook bij in onze bloodymary steken. bloodymary [ ipi ] = ingredientperingredient bloodymary [ ipc ] = ingredientpercocktail2 Nu lopen we dus nooit het risico dat we ons zullen vergissen tussen ingredientpercocktail en ingredientpercocktail2! 10
(Opmerking voor de aandachtige lezer: Moest je bovenstaande code effectief proberen uit te voeren, zou je merken dat er nog een fout in dit programma zit. Het is instructief om eens na te denken over hoe we deze fout in het algemeen zouden kunnen vermijden.) Er is nog een tweede voordeel, dat mooi geïllustreerd wordt door volgend fragmentje, dat berekent hoeveel vodka we nodig hebben voor 25cl Vodka-orange en 25cl Bloody Mary samen. cocktails = [ vodkaorange, bloodymary ] vodkanodig = 0 for c in cocktails : vodkanodig += c [ ipc ] ( c, 25, vodka ) Hoewel dat aan dit fragmentje helemaal niet te zien is, zal er hier voor onze twee verschillende cocktails een verschillende functie worden opgeroepen. Bovendien zal dit ook telkens de juiste functie zijn! Het feit dat de twee cocktails achter de schermen een verschillende voorstelling voor hun gegevens gebruiken is nu vanuit het oogpunt van bovenstaand fragmentje niet meer relevant. We hebben hier dus een mooie onafhankelijkheid tussen verschillende delen van onze code kunnen realiseren. Deze manier van werken is nu de essentie van het objectgeöriënteerd programmeren. Met de term object bedoelt men immers niet meer of niet minder dan een gegevensstructuur waar alle functies die nodig zijn om deze gegevensstructuur te manipuleren bij inzitten. Samengevat: een gegevensstructuur wéét iets, een functie kán iets, en een object weet niet alleen iets, maar kan daar ook iets mee. Het voornaamste voordeel van de objectgeöriënteerde manier van werken is encapsulatie. Dit betekent dat, zolang we de gegevens die in een object vervat zitten enkel maar manipuleren door middel van de functies die in het object zitten, we helemaal niet hoeven te weten hoe dit object deze gegevens juist voorstelt. Al deze details zitten immers netjes ingekapseld in het object. Hierdoor kunnen we ook op elk moment de gegevensvoorstelling veranderen bijvoorbeeld van onze vodkaorange voorstelling naar de bloodymary zonder dat we aan de rest van ons programma iets hoeven te veranderen. De essentie van objectgeöriënteerd programmeren zit hem dus in de functies die bij in het object zitten. Aangezien dit concept van een functie die bij in een object zit zodanig belangrijk is, heeft men daar dan ook maar meteen een woord voor verzonnen: dit noemt men een methode. object = gegevens + gedrag Een methode is een functie in een object 1.3 Klassen In de vorige sectie hebben we de essentie van objectgericht programmeren uit de doeken gedaan, zonder daarvoor iets meer over 11
Python te zien dan er in de cursus van het 1 e jaar reeds besproken werd. Python is echter een objectgerichte programmeertaal, wat betekent dat deze taal een aantal speciale voorzieningen zogenaamde syntactische suiker aanbiedt om programmeren op de objectgerichte manier aangenamer te maken. Een eerste ding dat al meteen opvalt als we onze cocktailbar verder willen uitbreiden, is dat we nogal veel repetitief tikwerk moeten doen. bloodymary = { vodka : 0.34, tomatensap : 0.66, ipc : ingredientpercocktail, ipi : ingredientperingredient } vodkaorange = { vodka : 0.34, fruitsap : 0.66, ipc : ingredientpercocktail, ipi : ingredientperingredient } gintonic = { gin : 0.34, tonic : 0.66, ipc : ingredientpercocktail, ipi : ingredientperingredient } Al deze cocktail-objects hebben immers dezelfde methodes. Aangezien luiheid een grote deugd is voor een programmeur, zouden we liever gewoon één keer zeggen dat alle cocktail-objectjes deze methodes moeten hebben, in plaats van dit elke keer opnieuw te moeten tikken. Hiervoor bestaat het concept van een klasse: een klasse is een verzameling van objecten die allemaal dezelfde methodes hebben. In het geval van ons voorbeeld, gaan we dus een klasse Cocktail invoeren, de methodes ingredientpercocktail en ingredientperingredient koppelen aan deze klasse, en tot slot zeggen dat bloodymary, vodkaorange en gintonic allemaal Cocktails zijn. Dit gaat als volgt: class Cocktail : def ingredientpercocktail ( cocktail, hoeveelheid, ingredient ) : pass # <- Hier komt nog een berekening def ingredientperingredient ( cocktail, hoeveelheid, gegeven, gezocht ) : pass # <- Hier komt nog een berekening gintonic = Cocktail ( ) bloodymary = Cocktail ( ) vodkaorange = Cocktail ( ) 12
Met het sleutelwoord class definiëren we dus een klasse met een bepaalde naam, waarbij we dan alle methodes van deze klasse opsommen. Nadien kunnen we de naam van deze klasse gebruiken als een functie die een nieuw objectje aanmaakt, dat tot deze bepaalde klasse behoort. We zeggen dan ook wel dat dit object een instantiatie van deze klasse is. Het netto-effect is dus dat aan elk object dat door middel van de uitdrukking Cocktail() wordt aangemaakt, de twee methodes worden toegevoegd die in de declaratie van deze klasse zijn opgenomen. In de voorgaan sectie hebben we een woordenboek gebruikt om een object voor te stellen. In Python zijn echte objecten (dwz. objecten die zijn aangemaakt op basis van een class) een klein beetje verschillend van woordenboeken. Daar waar we in een woordenboek volgende notatie gebruiken om aan een nieuw sleutel-waarde paar toe te voegen: woordenboek [ sleutel ] = waarde doen we dat met een object als volgt: object. sleutel = waarde Ook hier is een beetje terminologie voor: we noemen sleutel in dit geval een attribuut van het object. Nadat we dus bovenstaande Cocktails hebben aangemaakt, kunnen we er als volgt ingrediënten aan toevoegen: gintonic. gin = 0.34 gintonic. tonic = 0.66 Nu heeft het object gintonic dus twee methodes (namelijk de methodes ingredientpercocktail en ingredientperingredient van zijn klasse) en twee attributen (gin en tonic). De notatie om methodes en attributen van een object aan te spreken is trouwens identiek dezelfde, alleen zijn er natuurlijk ook haakjes en argumenten nodig om een methode op te roepen. gintonic. ingredientpercocktail ( argumenten ) Zonder de haakjes en argumenten, zouden we de methode niet oproepen, maar krijgen we gewoon deze methode zelf terug, zoals te zien is in de Python interpreter: >>> gintonic.ingredientpercocktail <bound method Cocktail.ingredientPerCocktail of < main.cocktail instance at 0x50da08>> Laten we nu deze methode ook eens implementeren. In de vorige sectie hadden we deze functie: def ingredientpercocktail ( cocktail, hoeveelheid, ingredient ) : return hoeveelheid * cocktail [ ingredient ] De gegevens in een object heten attributen 13
Nu we van de cocktail een object gemaakt hebben in plaats van een woordenboek, moeten we deze functie natuurlijk aanpassen. Het idee is dat we eigenlijk dit zouden willen doen: def ingredientpercocktail ( cocktail, hoeveelheid, ingredient ) : return hoeveelheid * cocktail. ingredient Maar dit zal helaas niet werken! Deze code zal immers op zoek gaan naar een (niet-bestaand) attribuut ingredient van het object gintonic. Terwijl we eigenlijk willen dat als we deze functie oproepen met als laatste argument bv. de string gin, dat dan het attribuut cocktail.gin gezocht zou worden. Gelukkig kent Python speciaal voor dit probleem een functie getattr(object,naam), waarvan het tweede argument een string moet zijn. Met andere woorden, als we getattr(gintonic, gin ) doen, dan zal het attribuut gintonic.gin worden opgehaald. Hiermee wordt de definitie van onze klasse dan: class Cocktail : def ingredientpercocktail ( z e l f, hoeveelheid, ingredient ) : return hoeveelheid * getattr ( z e l f, ingredient ) Het eerste argument van een methode is zelf def ingredientperingredient ( z e l f, hoeveelheid, gegeven, gezocht ) : verhouding = getattr ( z e l f, gezocht ) /getattr ( z e l f, gegeven ) return hoeveelheid * verhouding We hebben nu ook nog een tweede, kleine wijziging gedaan tov. onze vorige code. We hebben het eerste argument van onze methodes hernoemd naar zelf. In Python is het eerste argument van een methode altijd het object waarbij de methode hoort. De conventie is om dit argument altijd deze naam te geven (of self in Engelstalige code). We zouden verwachten dat we deze methode nu als volgt kunnen oproepen: gintonic. ingredientpercocktail ( gintonic, 20, gin ) Dit is echter niet helemaal juist. In werkelijkheid is het namelijk nog net iets eenvoudiger. Het eerste argument van deze methode-oproep zal immers toch altijd hetzelfde zijn als het object waarin de methode zelf zit. Meer nog, het feit dat de methode gegevens manipuleert die in haar eigen object zitten is net de essentie van objectgericht programmeren! Daarom zal Python dit eerste argument impliciet achter de schermen zelf doorgeven, zonder dat wij dit zelf hoeven te doen gintonic. ingredientpercocktail ( 20, gin ) 14
wordt egeven impliciet Hoewel er dus in de definitie van deze methode drie argumenten waren, moeten we er bij de oproep van deze methode slechts twee zelf expliciet doorgeven. Het ontbrekende argument is het object waarop de methode wordt opgeroepen (gintonic, in dit geval), dat als impliciet eerste argument wordt doorgegeven. 1.4 Constructoren We hebben tot dusver de ingrediënten van onze cocktails gewoon voorgesteld door een string ( gin, vodka,... ). Als we in ons programma meer moeten weten over een drank dan enkel maar zijn naam, dan zal deze voorstelling ontoereikend zijn en hebben we nood aan een gegevensstructuur waarin we al de relevante informatie over een drank kunnen bijhouden. Hiervoor kunnen we natuurlijk ook weer objecten gaan gebruiken. We hebben dan een klasse nodig, die we hier Drank gaan noemen. Het is vaak nuttig om, vooraleer we effectief Python code gaan schrijven, even kort samen te vatten wat wij juist van plan zijn, door de attributen en methodes van de klasse op te lijsten. We doen dit in de vorm van een info-kaartje, dat er zo uitziet: Klasse Drank Attr. naam : string alcoholpercentage : R prijs : R Meth. Op dit ogenblik, zijn we dus niet van plan om methodes te voorzien in onze Drank-objecten. Dit betekent dat we eigenlijk evengoed een woordenboek zouden kunnen gebruiken om deze gegevens in voor te stellen, als een object. Er zijn twee goede redenen om toch voor een object te kiezen. Ten eerste is het verwarrend om in hetzelfde programma sommige gegevens voor te stellen door een object en sommige door een woordenboek; in een objectgericht programma kiezen we dus best zoveel mogelijk voor objecten. Ten tweede zou het altijd nog kunnen dat we later alsnog methodes blijken nodig te hebben. Als we van in het begin voor objecten gekozen hebben, is dit een veel eenvoudigere operatie, dan wanneer we een woordenboek zouden moeten gaan omvormen tot een object. Wegens het gebrek aan methodes, krijgen we dus een klasse definitie die leeg is. class Drank: pass Deze lege klasse dient vooral om ons nu alvast een plaats te geven waar we later als we het programma verder gaan uitbreiden eventuele 15
methodes van de klasse Drank kunnen gaan toevoegen. Het feit dat de definitie van deze klasse leeg is, belet ons natuurlijk niet om objecten hiervan aan te maken. gin = Drank ( ) gin.naam = gin gin. alcoholpercentage = 0.15 gin. p r i j s = 12 vodka = Drank ( ) vodka.naam = vodka vodka. alcoholprecentage = 0.40 vodka. p r i j s = 20 We zien hier opnieuw een hoop tikwerk opduiken, met bovendien het risico op moeilijk te vinden fouten. Zo staat er in het voorbeeld hierboven een tikfout, die op dit moment nog ongemerkt voorbij zal gaan, maar ongetwijfeld later voor problemen gaat zal zorgen als we het alcoholpercentage van onze dranken willen raadplegen. Beter is het dus om een methode te definiëren die de verschillende attributen van een Drank invult. class Drank: def vulattributenin ( z e l f, naam, perc, p r i j s ) : z e l f.naam = naam z e l f. alcoholpercentage = perc z e l f. p r i j s = p r i j s gin = Drank ( ) gin. vulattributenin ( gin, 0.15, 12) vodka = Drank ( ) vodka. vulattributenin ( vodka, 0.40, 20) In dit voorbeeld zien we dat we, telkens als we een Drank aanmaken, we als eerste werk de initializatie-functie vulattributenin hierop gaan oproepen. Dit zal bovendien in heel ons programma waarschijnlijk altijd zo zijn. Python laat ons toe om ons programma nog wat compacter te maken door deze twee stappen het aanmaken van een object en het initializeren ervan in één instructie uit te voeren. Het enige dat we hiervoor moeten doen, is onze initializatie-methode een speciale naam geven: init. De naam van deze functie bestaat dus uit het woordje init (als afkorting van initializatie), voorafgegaan en gevolgd door telkens twee underscores _. De reden voor de underscores is dat Python deze notatie gebruikt voor dingen die op één of andere manier speciaal zijn, in de zin dat Python zelf er achter de rug van de programmeur dingen mee zal 16
doen. Deze notatie oogt een beetje vreemd, maar dat is eigenlijk precies de bedoeling: de underscores dienen als een waarschuwing voor mensen die de code zouden lezen zonder de speciale functie te kennen. Als ze de underscores zien, dan weten ze dat deze functie iets speciaals doet, en dat ze best eens de Python documentatie erop zouden naslaan om te weten te komen wat dit speciale juist is. Deze speciale functies worden ook wel magische functies genoemd, omdat ze een effect kunnen hebben op het gedrag van een programma in delen die er op het eerste zicht helemaal niets mee te maken hebben. class Drank: def i n i t ( z e l f, naam, perc, p r i j s ) : z e l f.naam = naam z e l f. alcoholpercentage = perc z e l f. p r i j s = p r i j s gin = Drank ( gin, 0.15, 12) vodka = Drank ( vodka, 0.40, 20) Als we in onze definitie van onze klasse een methode voorzien met de naam init, dan zal Python dus voor ons een functie definiëren met volgende eigenschappen: de naam van de functie is dezelfde als de naam van de klasse; het aantal argumenten van de functie is hetzelfde als het aantal argumenten van de init methode. Wat deze functie zal doen is: 1. Eerst maakt de functie een nieuw object aan van de klasse, en koppelt hieraan alle methodes die bij de klasse horen; 2. Daarna roept de functie de initializatie-methode init van deze klasse op op het object dat ze net heeft aangemaakt, met als argumenten de argumenten die ze zelf gekregen heeft; 3. Tot slot geeft deze functie het nieuw aangemaakte en geïnitializeerde object terug als haar resultaat. Deze functie wordt de constructor van de klasse genoemd. (Opmerking terzijde: Sommige objectgerichte programmeertalen bieden de mogelijkheid aan om per klasse meerdere constructoren te voorzien, die bijvoorbeeld objecten initializeren op basis van verschillende parameters. In Python is dit niet mogelijk, aangezien het gedrag van de constructor volledig bepaald wordt door hetgeen er in de init -methode staat, en er maar één methode met deze naam in 17
elke klasse kan zijn. Wel is het mogelijk om sommige argumenten van deze methode een default waarde mee te geven, waarmee een deel van de functionaliteit waarvoor het hebben van meerdere constructors in andere programmeertalen gebruikt wordt, toch gerealizeerd kan worden.) 1.5 Magische methodes Naast init zijn er in Python nog een hele hoop andere speciale functies en methodes. Een greep uit het gamma. Python biedt een aantal functies aan die objecten van één datatype omzetten naar een ander datatype. Bijvoorbeeld: >>> int(5.4) 5 >>> float(3) 3.0 >>> str(4) 4 >>> int( 7 ) 7 >>> float( 7 ) 7.0 Al deze functies werken door achter de schermen een corresponderende magische methode op te roepen, die dezelfde naam heeft maar dan aangevuld met de nodige underscores. Door in onze eigen klassen deze methodes te implementeren, kunnen we dus bepalen hoe onze eigen objecten zullen worden omgezet naar andere datatypes: class Drank: def i n i t ( z e l f, naam, perc, p r i j s ) : z e l f.naam = naam z e l f. alcoholpercentage = perc z e l f. p r i j s = p r i j s def str ( z e l f ) : return z e l f.naam + ( + str ( z e l f. alcoholpercentage ) + %) def f l o a t ( z e l f ) : return z e l f. alcoholpercentage Laten we dit eens uitproberen: >>> d = Drank( pils, 0.4, 2) 18
>>> str(d) pils (0.4%) >>> float(d) 0.40000000000000002 >>> int(d) Traceback (most recent call last): File "<stdin>", line 1, in? AttributeError: Drank instance has no attribute int In bovenstaande klasse is het waarschijnlijk niet nodig om een methode float te hebben. Er zijn immers weinig situaties te bedenken waarin we op het idee zouden komen om een drank als een kommagetal te gebruiken. De methode str lijkt daarentegen wel zinvol. Telkens als we een drank zouden willen afprinten, moeten we deze immers transformeren naar een string. Sterker nog: als we een drank meegeven aan een print opdracht, dan zal Python achter de schermen deze conversie uitvoeren. Normaalgezien krijgen we iets als dit te zien, als we een Drank-object proberen af te printen: >>> print Drank( pils, 0.4, 2) < main.drank instance at 0x50e9b8> Nu we echter een str methode gedefiniëerd hebben in de klasse Drank, ziet het resultaat er anders uit: >>> print Drank( pils, 0.4, 2) pils (0.4%) Van de verschillende conversie-methodes die hierboven werden aangehaald, is str dan ook veruit de meest gebruikte. Tot slot nog een laatste beetje magie. In de interactieve Python shell, kan je het commando help gebruiken om meer informatie in te winnen over ingebouwde objecten, klassen, functies of methodes. Bijvoorbeeld: >>> help(str)... Return a nice string representation of the object. If the argument is a string, the return value is the same object.... Bij de definitie van een nieuwe klasse, kan je als eerste instructie een string opgeven, en deze zal dan afgebeeld worden als gebruikers om hulp vragen over deze klasse. Deze string wordt per conventie tussen driedubbele aanhalingstekens geplaatst, ook als hij op één lijn past, en wordt de docstring van de klasse genoemd. 19
class WatDoetDit : """ Een klasse die het gebruik van een docstring illustreert. """ pass >>> help(watdoetdit) Help on class WatDoetDit in module main : class WatDoetDit Een klasse die het gebruik van een docstring illustreert. 1.6 Wijzigen van attributen De attributen van een object komen altijd tot stand tijdens zijn initializatie. Het is natuurlijk mogelijk om achteraf de waarde van deze attributen nog te gaan wijzigen. Als we bijvoorbeeld plots 2,5 e korting krijgen op gin, dan kan dit als volgt verwerkt worden: class Drank: def i n i t ( z e l f, naam, perc, p r i j s ) : z e l f.naam = naam z e l f. alcoholpercentage = perc z e l f. p r i j s = p r i j s... # Nog wat andere methodes gin = Drank ( gin, 0.35, 10) gin. p r i j s = gin. p r i j s 2.5 Hierbij wordt de waarde van attribuut prijs van het object gin dus aangepast van buiten deze klasse. Een alternatief is dat we de klasse Drank een extra methode geven, waarmee we deze aanpassing binnen de klasse doen. class Drank: def i n i t ( z e l f, naam, perc, p r i j s ) : z e l f.naam = naam z e l f. alcoholpercentage = perc z e l f. p r i j s = p r i j s... # Nog wat andere methodes 20
def krijgkorting ( z e l f, bedrag ) : z e l f. p r i j s = z e l f. p r i j s bedrag gin = Drank ( gin, 0.35, 10) gin. krijgkorting ( 2. 5 ) Aangezien het de bedoeling van objectgericht programmeren is dat klassen zoveel mogelijk hun eigen gegevens inkapselen, is de tweede optie vaak de beste. 1.7 Voorbeelden Tot slot van dit hoofdstuk, nog een aantal voorbeelden van volledige klassen. 1.7.1 De klasse Drank Het info-kaartje van onze klasse Drank is intussen dit geworden: Klasse Drank Attr. naam : string alcoholpercentage : R prijs : R Meth. init (zelf, naam, perc, prijs) str (zelf) En de bijhorende Python code is dan: class Drank: """ Objecten van deze klasse stellen een drank voor met: """ - een naam, - een alcoholpercentage, - een prijs (in euro per liter). def i n i t ( z e l f, naam, perc, p r i j s ) : z e l f.naam = naam z e l f. alcoholpercentage = perc z e l f. p r i j s = p r i j s def str ( z e l f ) : return z e l f.naam + ( + str ( z e l f. alcoholpercentage ) + %) 21
1.7.2 De klasse Rechthoek Volgende klasse laat ons toe om rechthoeken voor te stellen, af te printen, en hun oppervlakte en omtrek te berekenen. Klasse Rechthoek Attr. hoogte : R breedte : R Meth. oppervlakte(zelf) : R omtrek(zelf) : R class Rechthoek : """ Objecten van deze klasse stellen een meetkundige rechthoek voor met een hoogte en breedte. """ def i n i t ( z e l f, b, h) : z e l f. breedte = b z e l f. hoogte = h def str ( z e l f ) : return "Rechthoek van " + str ( z e l f. breedte ) + "x" + str ( z e l f. hoogte ) def oppervlakte ( z e l f ) : return z e l f. breedte * z e l f. hoogte def omtrek ( z e l f ) : return 2 * ( z e l f. breedte + z e l f. hoogte ) Deze klasse gebruiken we dan bijvoorbeeld zo: >>> r = Rechthoek(2,3) >>> print r Rechthoek van 2x3 >>> r.oppervlakte() 6 >>> r.omtrek() 10 1.7.3 De klasse Cirkel Andere meetkundige vormen kunnen natuurlijk op een gelijkaardige manier worden voorgesteld. Voor een cirkel hebben we het getal π nodig, dat we kunnen aanspreken als math.pi, nadat we eerst deze module math geïmporteerd hebben. 22
Klasse Cirkel Attr. straal : R Meth. oppervlakte(zelf) : R omtrek(zelf) : R class Cirkel : def i n i t ( z e l f, straal ) : z e l f. straal = straal def str ( z e l f ) : return "Cirkel met straal " + str ( z e l f. straal ) def omtrek ( z e l f ) : import math return z e l f. straal * 2 * math. pi def oppervlakte ( z e l f ) : import math return z e l f. straal * (math. pi ** 2) 1.8* Een blik achter de schermen Veel programmeertalen hebben de filosofie dat ze programmeurs tegen zichzelf of tegen hun collega s moeten beschermen. Python heeft deze filosofie niet. Dit is natuurlijk slecht nieuws voor programmeurs die deze bescherming nodig hebben, maar goed nieuws voor de anderen. We hebben eerder gezien dat er een grote gelijkenis bestaat tussen attributen van een object en sleutel-waarde paren in een woordenboek. Dit hoeft geen verwondering te wekken, want achter de schermen worden de attributen van een object gewoon in een woordenboek gestoken. Dit magische woordenboek heeft de naam dict en is zelf een attribuut van het object. >>> d = Drank( pils, 0.04, 2) >>> d. dict { naam : pils, alcoholpercentage : 0.04, prijs : 2} Elk object behoort, zoals je weet, tot een bepaalde klasse. Deze klasse wordt bijgehouden in het attribuut class. >>> d. class <class main.drank at 0x505b40> Op basis van de inleiding van dit hoofdstuk, had je misschien verwacht dat de methodes van de klasse Drank bij in het woordenboek 23
d. dict zouden zitten. Dit is echter niet het geval. De reden hiervoor is gewoon zuinigheid: aangezien alle objecten van de klasse toch dezelfde methodes delen, hoeven ze die niet allemaal afzonderlijk bij te houden. Het is voldoende als gewoon de klasse d. class de methodes bijhoudt. Dit doet ze in haar eigen dict, waar we o.a. ook de docstring van de klasse terugvinden. >>> d. class. dict { module : main, doc : Objecten van deze klasse stellen een drank voor met:\n\n - een naam,\n - een alcoholpercentage, \n - een prijs (in euro per liter). \n, str : <function str at 0x50a230>, init : <function init at 0x50a630>} 24
Samenwerkende objecten 2 In het vorige hoofdstuk hebben we enkel maar naar geïsoleerde klassen en objecten gekeken. In een echt programma zullen verschillende klassen normaalgezien moeten samenwerken. Er zijn drie manieren waarop één klasse een andere kan gebruiken: Een klasse kan een methode hebben, waarin een object van een andere klasse als argument voorkomt; Een klasse kan een methode hebben, die een object van een andere klasse teruggeeft; Een klasse kan een attribuut hebben waarin ze een object van een andere klasse bijhoudt. Er is duidelijk verschil tussen de eerste twee mogelijkheden en de derde, namelijk de duurtijd van de samenwerking. In de eerste twee gevallen is dit een tijdelijke samenwerking, waarbij de ene klasse de andere enkel maar nodig heeft tijdens één enkel methode oproep. Het laatste geval, daarentegen, beschrijft een duurzame binding tussen twee objecten, die mogelijk hun hele levensduur lang meegaat. Dit fenomeen wordt ook wel een associatie tussen de twee klassen genoemd. Associatie = contract van onbepaalde duur 2.1 Een object als argument In Secties 1.7.2 en 1.7.3 introduceerden we een klasse Cirkel en een klasse Rechthoek. Laat ons nu de klasse Cirkel uitbreiden met een methode die kan nagaan of de cirkel in een gegeven Rechthoek past. class Cirkel : #... de klasse zoals voorheen def pastin ( z e l f, rechthoek ) : 25
return ( z e l f. straal <= rechthoek. hoogte and z e l f. straal <= rechthoek. breedte ) Een voorbeeldje van het gebruik van deze methode: >>> cirkel = Cirkel(5) >>> rh = Rechthoek(6,7) >>> cirkel.pastin(rh) True Een object dat als argument wordt meegegeven, gedraagt zich zoals een woordenboek of een lijst, in die zin dat als er in de methode wijzigingen gebeuren aan een attribuut van dit object, deze wijzigingen ook buiten de methode zichtbaar zullen zijn. Laat ons dit illustreren met een methode die een rechthoek inkrimpt totdat hij in een cirkel past. class Cirkel : #... de klasse zoals voorheen def maakingesloten ( z e l f, rechthoek ) : i f rechthoek. hoogte > z e l f. straal : rechthoek. hoogte = z e l f. straal i f rechthoek. breedte > z e l f. straal : rechthoek. breedte = z e l f. straal >>> cirkel = Cirkel(5) >>> rh = Rechthoek(3,7) >>> print rh Rechthoek van 3x7 >>> cirkel.maakingesloten(rh) >>> print rh Rechthoek van 3x5 2.2 Een object als resultaat Als we in bovenstaand voorbeeld nog andere plannen hebben met onze originele rechthoek rh, dan kan het lastig zijn dat we deze nu net in place veranderd hebben. Een alternatief is om in onze methode een nieuwe Rechthoek aan te maken, en deze terug te geven als resultaat. De oorspronkelijke rechthoek kan dan onveranderd blijven. Vergelijk onderstaande code met de versie uit de vorige sectie: class Cirkel : #... de klasse zoals voorheen 26
def maakingesloten ( z e l f, rechthoek ) : i f rechthoek. breedte > z e l f. straal : nieuwebreedte = z e l f. straal else : nieuwebreedte = rechthoek. breedte i f rechthoek. hoogte > z e l f. straal : nieuwehoogte = z e l f. straal else : nieuwehoogte = rechthoek. hoogte return Rechthoek ( nieuwebreedte, nieuwehoogte ) Het gebruik van deze methode moet dan natuurlijk ook anders: >>> cirkel = Cirkel(5) >>> rh = rechthoek(3,7) >>> rh2 = cirkel.maakingesloten(rh) >>> print rh Rechthoek van 3x7 >>> print rh2 Rechthoek van 3x5 Welk van beide stijlen te verkiezen valt, is vaak een kwestie van persoonlijke smaak. In de Python gemeenschap, is men vaak nogal gewonnen voor een functionele stijl van programmeren, waarin functies en methodes zich gedragen zoals wiskundige functies. Dit betekent dat, net zoals een wiskundige functie f, een methode wel een resultaat y = f(x) zal berekenen, maar geen veranderingen zal aanbrengen aan x. Aanhangers van deze programmeerstijl zouden dus waarschijnlijk onze tweede variant van de methode maakingesloten verkiezen. De achterliggende motivatie is dezelfde als altijd: ze geloven dat er op deze manier gemakkelijker onafhankelijkheden tussen verschillende stukken code gerealiseerd kunnen worden, wat uiteindelijk aanleiding zou moeten geven tot programma s die gemakkelijker te ontwikkelen en te onderhouden zijn. 2.3 Associaties tussen klassen Een associatie tussen twee klassen betekent dat elk object van de ene klasse een attribuut heeft waarin een object van de andere klasse wordt bijgehouden. Om dit te illustreren verlaten we even onze rechthoeken en cirkels voor een belangrijkere toepassing, namelijk het bijhouden van onze drankvoorraad. We zagen in het vorige hoofdstuk (Sectie 1.7.1) al een klasse Drank, waarmee we kunnen bijhouden welke dranken we in voorraad hebben. Dit breiden we nu zodanig uit, dat we ook kunnen bijhouden hoeveel er van een bepaalde drank in voorraad is. Hiervoor introduceren we volgende klasse: 27
Klasse Fles Attr. drank : Drank inhoud (in cl) : N Meth. haaluit(zelf, hoeveelheid) voegtoe(zelf, hoeveelheid) waarde(zelf) : R Elk object van de klasse Fles heeft dus een attribuut waarin het een object van de klasse Drank gaat bijhouden. Er is dus, maw., een associatie tussen Fles en Drank. Vaak is het inzichtelijker om in plaats van bovenstaand info-kaartje een zogenaamd klassendiagramma te tekenen. Hierop worden associates aanduid met een pijl tussen de twee klassen in kwestie. Deze pijl vertrekt bij de klasse die het attribuut heeft waarmee deze associatie wordt voorgesteld, en dit attribuut wordt dan niet meer opgenomen in diens lijst met attributen. Eventueel kan de naam van het attribuut wel nog vermeld worden als label bij de pijl. Om het geheel overzichtelijk te houden, wordt er vaak ook voor gekozen om de methodes of zelfs de attributen van een klasse niet te vermelden. Fles inhoud : N drank Drank naam alcoholpercentage prijs : string : R : R class Fles : def i n i t ( z e l f, drank, inhoud ) : z e l f. drank = drank z e l f. inhoud = inhoud def haaluit ( z e l f, hoeveelheid ) : z e l f. inhoud = z e l f. inhoud hoeveelheid def voegtoe ( z e l f, hoeveelheid ) : z e l f. inhoud = z e l f. inhoud + hoeveelheid def waarde ( z e l f ) : prijspercl = z e l f. drank. p r i j s / 100.0 return z e l f. inhoud * prijspercl De interessantste methode is hier de laatste, waarin een Fles object zijn eigen inhoud moet combineren met de prijs van zijn drank om zijn eigen waarde te bepalen. De deling door 100 is nodig omdat de klasse Drank zijn prijs bijhoudt in e/l, terwijl de inhoud van een Fles 28
in cl wordt bijgehouden. Op zich kan dit riskant zijn (denk aan de ontplofte Mars Observator): als we later zouden besluiten om de prijs van een Drank ook in e/cl te zetten, moeten we eraan denken om de deling door 100 weg te halen, of anders krijgen we natuurlijk foute resultaten. Het is in dit geval waarschijnlijk veiliger om voor een andere strategie te kiezen, waarbij het doen van berekeningen met het attribuut drank.prijs zoveel mogelijk in de klasse Drank gebeurt. class Fles :... def waarde ( z e l f ) : return z e l f. drank. prijspercl ( ) * z e l f. inhoud class Drank:... def prijspercl ( z e l f ) : return z e l f. p r i j s / 100 2.4 Lijsten en objecten Ook in onze eigen objecten kunnen we vaak op nuttige wijze gebruik maken van de functionaliteit van Pythons ingebouwde lijsten. In de cursus van het eerste jaar, heb je gezien hoe je met deze lijsten moet omgaan. Een kort voorbeeldje ter herinnering: >>> lijst = [1,2,3] >>> print lijst [1, 2, 3] >>> lijst.append(4) >>> print lijst [1, 2, 3, 4] >>> for element in lijst:... print element... 1 2 3 4 >>> print lijst[1] 2 >>> for i in range(len(lijst)):... print i, "->", lijst[i] 29
... 0 -> 1 1 -> 2 2 -> 3 3 -> 4 Herinner je bij de uitvoer van het laatste commando ook dat de index van een lijst altijd begint te tellen vanaf 0; het element dat bij index 1 hoort, is dus niet het eerste, maar wel het tweede element van de lijst. 2.4.1 Objecten met lijsten Een lijst kan net zoals bijvoorbeeld een getal, een string of een object gebruikt worden als een attribuut van een object. Als een object zo n attribuut heeft, zal het vaak methodes aanbieden waarmee elementen kunnen worden toegevoegd aan en/of weggehaald uit de lijst. Als de constructor van zo n object al geen lijst meekrijgt als argument, dan zal hier typisch een nieuwe, lege lijst worden aangemaakt. Het volgende voorbeeld toont een klasse waarmee een rij van getallen kan worden bijgehouden om hiervan het gemiddelde te berekenen. class GetallenRij : def i n i t ( z e l f ) : z e l f. r i j = [ ] def voegtoe ( z e l f, getal ) : z e l f. r i j. append ( getal ) def str ( z e l f ) : return str ( z e l f. r i j ) def som( z e l f ) : som = 0 for getal in z e l f. r i j : som += getal return som def gemiddelde ( z e l f ) : som = z e l f.som ( ) return f l o a t (som) / len ( z e l f. r i j ) # ^^^^^ anders krijgen we een gehele deling Deze klasse kunnen we dan bijvoorbeeld als volgt gebruiken. >>> rij = GetallenRij() >>> rij.voegtoe(3) >>> rij.voegtoe(6) 30
>>> rij.voegtoe(7) >>> print rij [3, 6, 7] >>> rij.gemiddelde() 5.333333333333333 2.4.2 Lijsten van objecten Naast waardes van een primitief type, zoals getallen of strings, kunnen ook objecten in een lijst gestoken worden. Zo zal onderstaande code een lijst van twee objecten maken, en daar dan nadien nog een geheel getal en een derde object aan toevoegen. gin = Drank (... ) p i l s = Drank (... ) wijn = Drank (... ) f1 = Fles ( gin,50) f2 = Fles ( pils,25) f3 = Fles ( wijn,75) l i j s t = [ f1, f2 ] l i j s t. append ( 4 ) l i j s t. append ( f3 ) De lijst die door deze code geproduceerd wordt, ziet er als volgt uit: >>> print lijst [< main.fles instance at 0x50d580>, < main.fles instance at 0x50d5a8>, 4, < main.fles instance at 0x50d5d0>] Een grafische voorstelling hiervan is te zien in Figuur 2.1. Het belangrijkste punt hiervan, is dat lijst[0] dus eigenlijk gewoon een andere naam is voor hetzelfde object dat ook al de naam f1 heeft. Het effect hiervan zien we bijvoorbeeld in volgende interactie: >>> print f1.inhoud 50 >>> lijst[0].inhoud = 25 >>> print f1.inhoud 25 2.4.3 Objecten met lijsten van objecten Als we objecten in een lijst kunnen steken, is het natuurlijk ook mogelijk om zo n lijst van objecten te gebruiken als een attribuut van een andere object. Op deze manier kunnen we, met andere woorden, een associatie tot stand brengen van een object van één klasse met een verzameling van objecten van een andere klasse. Hiervan kunnen we 31
0 1 2 3 lijst 4 Fles drank gin inhoud 50 Fles drank pils inhoud 25 Fles drank wijn inhoud 75 f1 f2 f3 Figuur 2.1: Een grafische voorstelling van het resultaat van de code lijst = [f1,f2,4,f3]. bijvoorbeeld gebruik maken om gegevens over een DrankVoorraad bij te houden, door middel van een lijst van flessen waaruit deze voorraad bestaat. Klasse DrankVoorraad Attr. flessen : lijst<fles> Meth. waarde(zelf) : R Met de notatie lijst<fles> bedoelen we een lijst met daarin een aantal Fles objecten. In een klassendiagramma, kunnen we zo n lijst aanduiden met een sterretje, zoals te zien in Figuur 2.2. Veel van het gedrag dat een object van een klasse zoals DrankVoorraad zal aanbieden, wordt typisch gerealizeerd door middel van een iteratie over de Fles-objecten die in zijn lijst zitten. Bijvoorbeeld de methode om de volledige waarde van een drankkast te berekenen, gegeven de waarde van de individuele flessen, valt op deze manier te implementeren. class DrankVoorraad : def i n i t ( z e l f ) : z e l f. flessen = [ ] def voegtoe ( z e l f, f l e s ) : z e l f. flessen. append ( f l e s ) def waarde ( z e l f ) : 32
DrankVoorraad waarde(zelf) : R flessen Fles inhoud : N waarde(zelf) : R voegtoe(zelf, hoeveelheid) haaluit(zelf, hoeveelheid) drank Drank naam alcoholpercentage prijs : string : R : R Figuur 2.2: Een klassendiagramma waarbij een drankvoorraad uit een verzameling flessen bestaat. resultaat = 0 for f in z e l f. flessen : resultaat += f. waarde ( ) return resultaat Laten we even stilstaan bij het resultaat dat deze methode berekent. Als de drankvoorraad n flessen {f 1,..., f n } bevat, waarbij V i de inhoud is van fles i en p i de prijs van de drank d i die in fles f i zit, dan berekent deze methode volgende som w: w = V i p i. 1 i n De methode waarde uit de klasse DrankVoorraad berekent heel deze som, gebruikmakend van de gelijknamige methode uit de klasse Fles, die één term ervan berekent. Sommige getalletjes leggen dus een hele weg af, voordat ze uiteindelijk in deze som belanden: Drank Fles DrankVoorraad p 1 d 1 f 1 V 1 p 1 i V i p i d n p n f n V n p n 33
GetallenRij rij som(zelf) gemiddelde(zelf) : lijst<z> : Z : R Veelhoek omtrek(zelf) : R voegtoe(zelf,punt) hoekpunten Punt x y afstandnaar(zelf,punt) : R : R : R Figuur 2.3: Klassendiagramma van het voorbeeld in Sectie 2.5. 2.5 Voorbeelden 2.5.1 Punten en veelhoeken In dit voorbeeld gebruiken we drie klassen: De klasse Punt stelt een punt in het Euclidisch vlak voor. De klasse Veelhoek stelt veelhoek voor als een lijst van hoekpunten. Bij het berekenen van de omtrek van een Veelhoek, maken we gebruik van de klasse GetallenRij, die we eerder in dit hoofdstuk (Sectie 2.4.1) gebruikt hebben. Het bijhorende klassendiagramma is te zien in Figuur 2.3. Er is dus een associatie tussen een Veelhoek en een lijst van Punten. De klasse GetallenRij wordt enkel maar binnenin één bepaalde methode gebruikt, dus hiermee is er geen associatie. Voor de volledigheid herhalen we eerst de definitie van de klasse GetallenRij nog eens. class GetallenRij : def i n i t ( z e l f ) : z e l f. r i j = [ ] def voegtoe ( z e l f, getal ) : z e l f. r i j. append ( getal ) def str ( z e l f ) : 34
return str ( z e l f. r i j ) def som( z e l f ) : som = 0 for getal in z e l f. r i j : som += getal return som def gemiddelde ( z e l f ) : som = z e l f.som ( ) return f l o a t (som) / len ( z e l f. r i j ) # ^^^^^ anders krijgen we een gehele deling De klasse Punt bevat niet veel meer dan een Euclidische afstandsberekening. class Punt : def i n i t ( z e l f, x, y ) : z e l f. x = x z e l f. y = y def afstandnaar ( z e l f, ander ) : import math # om vierkantswortels te nemen return math. sqrt ( ( z e l f. x ander. x ) ** 2 + ( z e l f. y ander. y ) ** 2) def str ( z e l f ) : return "Punt(" + str ( z e l f. x ) + ", " + str ( z e l f. y ) + ")" De klasse Veelhoek is nu als volgt. class Veelhoek : def i n i t ( z e l f, punten ) : z e l f. hoekpunten = punten def voegtoe ( z e l f, punt ) : z e l f. hoekpunten. append ( punt ) def lijstvanzijdes ( z e l f ) : r i j = GetallenRij ( ) for i in range ( len ( z e l f. hoekpunten ) ) : van = z e l f. hoekpunten [ i ] i f i +1 < len ( z e l f. hoekpunten ) : naar = z e l f. hoekpunten [ i +1] else : 35
1 b 1 c 2 a 2 d Figuur 2.4: Een veelhoek in het Euclidisch vlak. naar = z e l f. hoekpunten [ 0 ] r i j. voegtoe ( van. afstandnaar ( naar ) ) return r i j def omtrek ( z e l f ) : return z e l f. lijstvanzijdes ( ).som ( ) Hiermee kunnen we nu als volgt de omtrekt berekenen van de veelhoek in Figuur 2.4. a = Punt (2,2) b = Punt (2,3) c = Punt (3,3) d = Punt (4,2) veelhoek = Veelhoek ( [ a, b, c, d ] ) print veelhoek. omtrek ( ) 2.5.2 Cocktails en dranken We herschrijven nu de klasse Cocktail om gebruik te maken van onze klasse Drank, op de manier getoond in onderstaand klassendiagramma. 36
Cocktail naam : String ingrediënten Ingredient percentage * Drank drank naam : R alcoholpercentage prijs : string : R : R De klasse Drank is nog steeds dezelfde als in Sectie 1.7.1. De klasse Ingredient en Cocktail zijn nu als volgt: class Ingredient : """ Een klasse om de ingredienten van een Cocktail voor te stellen Elk ingredient bestaat uit: - een Drank - het percentage van de cocktail dat uit deze drank bestaat """ def i n i t ( z e l f, drank, percentage ) : z e l f. drank = drank z e l f. percentage = percentage class Cocktail : """ Een cocktail met een: - naam - een lijst van Ingredient objecten, die de samenstelling aangeven """ def i n i t ( z e l f, naam, ingredienten ) : z e l f.naam = naam z e l f. ingredienten = ingredienten def ingredientpercocktail ( z e l f, drank, hoeveelheid ) : for ingr in z e l f. ingredienten : i f ingr. drank == drank : return hoeveelheid * ingr. percentage 37
def ingredientperingredient ( z e l f, hoeveelheid, gegeven, gezocht ) : for ingr in z e l f. ingredienten : i f ingr. drank == gegeven : gegeveningr = ingr i f ingr. drank == gezocht : gezochtingr = ingr verhouding = gezochtingr. percentage / gegeveningr. percentage return hoeveelheid * verhouding def alcoholpercentage ( z e l f ) : r i j = GetallenRij ( ) for ingr in z e l f. ingredienten : r i j. voegtoe ( ingr. percentage * ingr. drank. alcoholpercentage ) return r i j.som ( ) def isstrafferdan ( z e l f, andere ) : return z e l f. alcoholpercentage ( ) > andere. alcoholpercentage ( ) def str ( z e l f ) : return z e l f.naam Gebruik maken van deze klassen kan dan bijvoorbeeld als volgt: gin = Drank ( "gin", 45, 12) tonic = Drank ( "tonic", 0, 7) gintonic = Cocktail ( "Gin-tonic", [ Ingredient ( gin, 0.34), Ingredient ( tonic,0.66) ] ) print "Voor 10cl gin heb je " + str ( gintonic. ingredientperingredient (10, gin, tonic ) ) + "cl tonic nodig" tomatensap = Drank ( "tomatensap", 0, 2) vodka = Drank ( "vodka", 0. 4, 10) bloodymary = Cocktail ( "Bloody Mary", [ Ingredient ( tomatensap, 0.75), Ingredient ( vodka,0.25) ] ) i f bloodymary. isstrafferdan ( gintonic ) : s t r a f s t e = bloodymary else : s t r a f s t e = gintonic print "Doe mij maar een " + str ( s t r a f s t e ) 38
Overerving 3 Een belangrijk streefdoel van objectgericht programmeren is herbruikbaarheid van programma-code. In het vorig hoofdstuk hebben we bijvoorbeeld een klasse Veelhoek gedefinieerd. Moesten we nu tien jaar later een programma aan het schrijven zijn waarin we opeens nood blijken te hebben aan een klasse waarmee we een veelhoek kunnen voorstellen, dan zouden we graag hebben dat we gewoon deze klasse uit het vorige hoofdstuk terug kunnen opzoeken en deze zonder verdere moeite herbruiken in ons nieuwe programma. In de praktijk blijkt dit echter vaak toch niet zo eenvoudig te zijn. Een veel voorkomend fenomeen is dat er in het nieuwe programma net iets meer functionaliteit vereist is dan in het oude. Zo zou het bijvoorbeeld kunnen zijn dat we in het nieuwe programma opeens ook de oppervlakte van een veelhoek moeten kunnen berekenen, terwijl dit in het oude nog niet zo was. We zouden dan natuurlijk de oude klasse kunnen gaan aanpassen, maar dan lopen we het risico dat we uiteindelijk een moeilijk te beheren warboel van allemaal verschillende versies van dezelfde klasse zullen overhouden. Om dit te voorkomen is er nood een mechanisme waarmee we uitbreidingen van een bestaande klasse kunnen maken, zonder deze klasse zelf echter te moeten veranderen. Dit gebeurt dmv. het mechanisme van overerving. We introduceren eerst wat terminologie. De bestaande klasse waarvan ze vertrekken wordt de superklasse genoemd. Uit deze superklasse gaan we dan een nieuwe klasse afleiden, die de subklasse genoemd wordt. Objecten van deze subklasse zullen alles kunnen wat objecten van de superklasse ook kunnen, maar daarnaast ook nog iets méér. Op deze manier zouden we dus zelfs kunnen zeggen dat elk object dat tot de subklasse behoort eigenlijk óók tot de superklasse behoort. Met andere woorden, we kunnen de subklasse zien als een deelverzameling van de superklasse. Hierin ligt ook meteen de oorsprong van deze twee termen. Dit wordt nog eens grafisch geïllustreerd in Figuur 3.1. De notatie voor overerving in Python is heel eenvoudig: het volstaat om bij de declaratie van de subklasse de naam van de superklasse tussen Overerving dient om bestaande klassen uit te breiden Subklasse = superklasse + extra s 39
superklasse subklasse Figuur 3.1: Een superklasse (zoals Cocktail) in rode streepjeslijn ( ) met een subklasse (zoals CocktailMetGarnituur) in blauwe puntjeslijn ( ). haakjes te zetten. Onderstaand code-fragmentje definieert bijvoorbeeld een subklasse CocktailMetGarnituur van de klasse Cocktail uit Sectie 2.5.2. class CocktailMetGarnituur ( Cocktail ) : pass Het effect hiervan is dat we nu objectjes van de klasse CocktailMetGarnituur kunnen aanmaken, die zich volledig zullen gedragen zoals onze Cocktail objectjes. We kunnen dus, bijvoorbeeld, het volgende doen: tomatensap = Drank ( "tomatensap", 0, 2) vodka = Drank ( "vodka", 0. 4, 10) bloodymary = CocktailMetGarnituur ( "Bloody Mary", [ Ingredient ( tomatensap, 0.75), Ingredient ( vodka,0.25) ] ) print bloodymary. alcoholpercentage ( ) Het object bloodymary dat we hier hebben aangemaakt is een object van de klasse CocktailMetGarnituur. In termen van Figuur 3.1, is het dus één van de objectjes in de blauwe verzameling. Dit komt enkel en alleen door het feit dat we de functie met deze naam gebruikt hebben bij het aanmaken van dit object. Met andere woorden, de klasse waartoe een object behoort, ligt al vast van bij het aanmaken van dit object. Tijdens de levensloop van het object, zal dit ook niet meer veranderen. Zoals we in bovenstaand fragmentje kunnen zien, erft elk objectje van een subklasse dus al de methodes van zijn superklasse. Dit verklaart waarom we op ons object bloodymary toch de methode 40
alcoholpercentage kunnen oproepen, ook al komt deze niet voor in onze definitie van de klasse CocktailMetGarnituur. Overerving wordt natuurlijk pas interessant van zodra we aan de subklasse wat bijkomende functionaliteit gaan toevoegen, die in de superklasse nog niet aanwezig was. class CocktailMetGarnituur ( Cocktail ) : def voeggarnituurtoe ( z e l f, gar ) : z e l f. garnituur = gar Nu hebben objecten van de klasse CocktailMetGarnituur dus zowel alle methodes uit de oorspronkelijke klasse Cocktail, als deze nieuwe methode voeggarnituurtoe. bloodymary = CocktailMetGarnituur ( "Bloody Mary", [ Ingredient ( tomatensap, 0.75), Ingredient ( vodka,0.25) ] ) bloodymary. voeggarnituurtoe ( "selder" ) 3.1 Het overschrijven van methodes Dankzij deze extra methode in de klasse CocktailMetGarnituur kunnen we nu al een takje selder toevoegen aan onze bloody mary. Er gebeurt natuurlijk nog niets met deze bijkomende informatie. We zouden bijvoorbeeld onze garnituur ook kunnen willen opnemen in de string-voorstelling van de cocktail. Hiervoor moeten we dan, zoals steeds, een str methode schrijven. Merk eerst op dat onze CocktailMetGarnituur objecten natuurlijk al zo n methode hebben, namelijk, diegene die ze overerven van hun superklasse Cocktail. Om nog even het globale plaatje samen te vatten: class Cocktail :... def str ( z e l f ) : return z e l f.naam class CocktailMetGarnituur ( Cocktail ) : def voeggarnituurtoe ( z e l f, gar ) : z e l f. garnituur = gar En dit laat ons toe om het volgende te doen: bloodymary = CocktailMetGarnituur ( "Bloody Mary", [ Ingredient ( tomatensap, 0.75), Ingredient ( vodka,0.25) ] ) 41
bloodymary. voeggarnituurtoe ( "selder" ) En als we dan de string-voorstelling van dit object opvragen, wordt dankzij de overerving het resultaat berekend door de methode str uit de klasse Cocktail. >>> print bloodymary Bloody Mary Nu willen we ervoor zorgen dat objecten van de klasse CocktailMetGarnituur een andere string-voorstelling krijgen dan objecten van de klasse Cocktail. Dit doen we door deze klasse zijn eigen str methode te geven. class Cocktail :... def str ( z e l f ) : return z e l f.naam class CocktailMetGarnituur ( Cocktail ) : def voeggarnituurtoe ( z e l f, gar ) : z e l f. garnituur = gar De meest specifieke methode wordt opgeroepen def str ( z e l f ) : return z e l f.naam + " met " + z e l f. garnituur Als we nu proberen om op onze Bloody Mary de methode str op te roepen (door ofwel bloodymary. str (), ofwel str(bloodymary), ofwel print bloodymary te doen), dan zijn er dus eigenlijk twee verschillende methodes die in aanmerking komen. Er is de methode met deze naam die wordt overgeërfd uit de klasse Cocktail en er is de methode met deze naam die in de klasse CocktailMetGarnituur zelf gedefiniëerd wordt. Wat Python in zo n geval zal doen en het is trouwens eenvoudig in te zien dat dit ook de enige zinvolle optie is is de meest specifieke methode kiezen die van toepassing is, in dit geval dus de methode uit CocktailMetGarnituur. >>> print bloodymary Bloody Mary met selder In dit geval zeggen we dat de methode uit de subklasse de gelijknamige methode uit de superklasse overschrijft; in het Engels spreken we van overriding. Ook constructoren kunnen op deze manier natuurlijk overschreven worden. In bovenstaand voorbeeld hadden we het object bloodymary als volgt aangemaakt: 42
bloodymary = CocktailMetGarnituur ( "Bloody Mary", [ Ingredient ( tomatensap, 0.75), Ingredient ( vodka,0.25) ] ) Wat deze code doet, is natuurlijk de overgeërfde methode init uit de superklasse Cocktail oproepen: class Cocktail :... def i n i t ( z e l f, naam, ingredienten ) : z e l f.naam = naam z e l f. ingredienten = ingredienten Moesten we nu graag de klasse CocktailMetGarnituur zijn eigen, specifieke constructor geven, zodat de garnituur al meteen als argument hiermee kan worden meegegeven, dan kan dit zo: class CocktailMetGarnituur ( Cocktail ) :... def i n i t ( z e l f, naam, ingredienten, garnituur ) : z e l f.naam = naam z e l f. ingredienten = ingredienten z e l f. garnituur = garnituur Ook kunnen natuurlijk niet-magische methodes overschreven worden. Veronderstel bijvoorbeeld dat de aanwezigheid van de garnituur ervoor zorgt dat het alcoholpercentage van de cocktail een procentje lager ligt dan normaal. class Cocktail :... def alcoholpercentage ( z e l f ) : r i j = GetallenRij ( ) for ingr in z e l f. ingredienten : r i j. voegtoe ( ingr. percentage * ingr. drank. alcoholpercentage ) return r i j.som ( ) def isstrafferdan ( z e l f, andere ) : return z e l f. alcoholpercentage ( ) > andere. alcoholpercentage ( ) class CocktailMetGarnituur ( Cocktail ) : 43
... def alcoholpercentage ( z e l f ) : r i j = GetallenRij ( ) for ingr in z e l f. ingredienten : r i j. voegtoe ( ingr. percentage * ingr. drank. alcoholpercentage ) return r i j.som ( ) 0.01 Om het globale effect hiervan te zien, is het de moeite waard om even stil te staan bij wat er juist gebeurt in volgend code-fragmentje: bloodymary = CocktailMetGarnituur(...) whiskycola = Cocktail(...) bloodymary.isstrafferdan(whiskycola) De methode isstrafferdan is niet overschreven geweest in de subklasse CocktailMetGarnituur, dus hier wordt gewoon de versie uit de klasse Cocktail opgeroepen. In deze methode gebeuren nu twee andere methode-oproepen: zelf.alcoholpercentage() wordt opgeroepen, waarbij zelf verwijst naar het object bloodymary. Aangezien dit een object is van de klasse CocktailMetGarnituur en deze klasse de methode alcoholpercentage overschrijft, wordt hiermee dus de methode opgeroepen die 0.01 aftrekt van het gemiddelde alcoholpercentage van de ingrediënten. ander.alcoholpercentage() wordt opgeroepen, waarbij ander verwijst naar het object whiskycola. Aangezien dit een object is van de klasse Cocktail, speelt de subklasse CocktailMetGarnituur hierbij geen enkele rol, en wordt dus de methode opgeroepen die gewoon het gemiddelde alcoholpercentage van de ingrediënten teruggeeft. Samengevat zien we dus dat een subklasse alle functionaliteit overerft van zijn superklasse. Door bijkomende methodes toe te voegen, kan deze subklasse de functionaliteit van de superklasse dan gaan uitbreiden, en door reeds bestaande methodes te overschrijven, kan de subklasse de implementatie van sommige functionaliteiten naar wens gaan aanpassen. 3.2 Overervingshiërarchieën Het is natuurlijk ook mogelijk om subklassen te maken van een klasse die zelf al een subklasse is van een andere klasse. Op deze manier 44
Figuur 3.2: Classificatie/overervingshiërarchie van veelhoeken. kunnen hele klassenhiërarchieën tot stand komen. Als voorbeeld kunnen we de classificatie van veelhoeken nemen, die in Figuur 3.2 getoond wordt. Onderstaande code toont hoe we deze hiërarchie in Python klassen kunnen gieten. Omdat we hier enkel geïnteresseerd zijn in de structuur van deze hiërarchie, hebben we al deze klassen voorlopig leeg gelaten. In een echt programma, zouden hier natuurlijk verschillende methodes in aanwezig zijn. class Veelhoek : pass class Driehoek ( Veelhoek ) : pass class Vierhoek ( Veelhoek ) : pass class RechthoekigeDriehoek ( Driehoek ) : pass class GelijkzijdigeDriehoek ( Driehoek ) : pass class Trapezium ( Vierhoek ) : pass class Parallellogram ( Trapezium ) : 45
pass class Ruit ( Parallellogram ) : pass class Rechthoek ( Parallellogram ) : pass De klasse object is de voorouder van alle klassen class Vierkant ( Rechthoek, Ruit ) : pass De klasse Vierkant toont iets wat we nog niet eerder gezien hebben, namelijk een klasse die meer dan één superklasse heeft. Dit is een fenomeen dat meervoudige overerving genoemd wordt. Hoewel Python meervoudige overerving wel degelijk toelaat, kan het gebruik hiervan ervoor zorgen dat programma s moeilijker te begrijpen vallen, doordat het minder evident wordt om te zien welke methodes juist vanuit welke klasse worden overgeërfd. Om deze reden zijn er verschillende experts die het gebruik van meervoudige overerving afraden. Deze cursus volgt dit standpunt, en we zullen verder dan ook geen meervoudige overerving meer gebruiken. In een overervingshiërarchie kunnen we een onderscheid maken tussen een klasse die een directe subklasse is van een andere (zoals bv. de klasse Vierkant is van Rechthoek) en een klasse die een indirecte subklasse is (zoals bv. Vierkant van Vierhoek). Alle klassen in bovenstaand voorbeeld zijn dus een directe of indirecte subklasse van Veelhoek. We noemen deze klasse daarom ook wel de wortel van deze overervingshiërarchie. In het algemeen zullen we de klassen in een programma dus kunnen ordenen in een aantal overervingshiërarchieën, waarvan er sommigen mogelijk veel klassen en niveaus bevatten, en anderen misschien maar één. In het extreme geval, zouden we zelfs één enkele wortel-klasse in ons programma kunnen hebben, waarvan alle andere klassen (direct of indirect) overerven. Python bevat reeds een klasse object (met kleine eerste letter), die hiervoor specifiek bedoeld is. Dit is een relatief nieuwe toevoeging aan de taal, en om geen oude programma s stuk te maken, is het gebruik ervan (nog) niet verplicht. Het is echter wel aan te raden om dit toch te doen, maw. om te zorgen dat elke klasse die je definiëert (direct of indirect) overerft van object. In Python terminologie noemt men dit een klasse in de nieuwe stijl (new-style class). We zullen in deze cursus vanaf nu alleen nog maar klassen in de nieuwe stijl maken. 3.3 Methodes uit een superklasse oproepen Een vaak voorkomend fenomeen als we methodes gaan overschrijven, is dat we eigenlijk maar een heel kleine aanpassing willen doen aan het 46
gedrag van de methode uit de superklasse. Dit zagen we bijvoorbeeld bij de methode alcoholpercentage, waarbij we in de subklasse eerst heel de berekening van het gemiddelde alcoholpercentage uit de superklasse herhaald hebben, om er dan daarna nog even een procent van af te trekken. We hadden dit toen gewoon gedaan door een paar regels code uit de superklasse te kopiëren, maar zoals we intussen weten is het dupliceren van code iets wat een goede programmeur altijd zoveel mogelijk tracht te vermijden. Python biedt daarom de mogelijkheid aan om vanuit een methode in een subklasse de overschreven gelijknamige methode uit de superklasse toch nog op te roepen. Hiervoor dient de functie super. Deze functie neemt twee argumenten: super(klasse, zelf). Het resultaat van deze functie is eigenlijk gewoon opnieuw het object zelf, maar dan met de eigenschap dat als je er methodes op oproept, niet de meest specifieke methodes uit Klasse zullen genomen worden, zoals normaal zou gebeuren, maar wel de overschreven methode uit de superklasse van Klasse. Laat ons dit illustreren met een voorbeeld. class Cocktail ( object ) : super dient om een overschreven methode toch nog op te roepen... def alcoholpercentage ( z e l f ) : r i j = GetallenRij ( ) for ingr in z e l f. ingredienten : r i j. voegtoe ( ingr. percentage * ingr. drank. alcoholpercentage ) return r i j.som ( ) class CocktailMetGarnituur ( Cocktail ) :... def alcoholpercentage ( z e l f ) : return super ( CocktailMetGarnituur, z e l f ). alcoholpercentage ( ) 0.01 Merk op dat het natuurlijk niet zou werken, moesten we de laatste lijn van dit voorbeeld vervangen door gewoon: return z e l f. alcoholpercentage ( ) 0.01 Dit zou immers zorgen voor een nooit eindigend programma, waarin de methode alcoholpercentage uit CocktailMetGarnituur zichzelf altijd zou blijven oproepen. Het gebruik van de functie super dient dus precies om ervoor te zorgen dat deze methode niet zichzelf oproept, maar wel de gelijknamige methode uit de klasse Cocktail. 47
Een waarschuwingswoordje: het gebruik van de functie super werkt enkel maar bij klassen in de nieuwe stijl, maw. die een (directe of indirecte) subklasse zijn van object. 3.4 Voorbeelden 3.4.1 Vierkanten We definiëren een klasse Vierkant als subklasse van de klasse Veelhoek uit Sectie 2.5.1. Deze klasse overschrijft de constructor van de klasse Veelhoek om ervoor te zorgen dat we een Vierkant kunnen aanmaken op basis van één hoekpunt en een lengte, in plaats van door het opsommen van al de vier hoekpunten. Daarnaast voegt deze constructor ook een extra attribuut toe (de klasse Veelhoek heeft alleen maar een attribuut punten), namelijk de lengte van een zijde. De andere methodes van de klasse Veelhoek, in het bijzonder de methode omtrek, worden gewoon overgeërfd. Een bijkomende methode oppervlakte wordt toegevoegd. class Vierkant ( Veelhoek ) : def i n i t ( z e l f, linksonder, lengte ) : z e l f. lengte = lengte linksboven = Punt ( linksonder. x, linksonder. y + lengte ) rechtsboven = Punt ( linksboven. x + lengte, linksboven. y ) rechtsonder = Punt ( rechtsboven. x, linksonder. y ) hoekpunten = [ linksonder, linksboven, rechtsboven, rechtsonder ] super ( Vierkant, z e l f ). i n i t ( hoekpunten ) def oppervlakte ( z e l f ) : return z e l f. lengte ** 2 v = Vierkant(Punt(1,1), 2) print v.omtrek() print v.oppervlakte() 48
Varia 4 In dit hoofdstuk komen tot slot nog een paar kleinere onderwerpen aan bod. 4.1 Python als server-side scripting taal Python kan gebruikt worden als taal om webpagina s mee te genereren, en om gegevens van webformulieren mee te verwerken. Dit kan via het CGI protocol. De essentie hiervan is eenvoudig: als je jouw Python programma op de juiste plaats zet (en de webserver op de juiste manier geconfigureerd is), dan zal dit worden opgeroepen als de gebruiker naar een bepaalde URL surft. Via gewone print instructies, kan het Python programma dan HTML code genereren, die zal worden teruggestuurd naar de gebruiker. Het is wel aan te bevelen om als eerst de lijn Content-Type: text/html af te printen, die de webbrowser van de gebruiker vertelt dat er een HTML pagina aankomt. Daarna moet er dan nog een lege lijn volgen, vooraleer de HTML pagina zelf mag komen. #!/usr/bin/python print "Content-type: text/html" print "" print "<html> <head> <title> Hoi </title> </head>" print "<body> <p>dit is een pagina.</p> </body>" print "</html>" Dit wordt natuurlijk pas interessant als we ook invoer van de gebruiker kunnen verwerken. Herinner je dat de gegevens die een gebruiker invult in een webformulier worden doorgestuurd in de vorm van paren van een naam en een waarde, waarbij de naam afkomstig is van een attribuut van het HTML element waarin de gebruiker zijn gegevens heeft ingevuld. 49
Python biedt een methode getvalue(naam) waarbij de waarde die bij een naam hoort kan worden opgevraagd. Deze methode behoort bij een object dat je aanmaakt met de functie cgi.fieldstorage() uit de module cgi. Het volgend voorbeeld laat zien hoe dit in zijn werk gaat. Dit is een HTML-formulier met daarop een tekstveldje met als de naam naam. <html> <body> <form method="get" action="programma.py"> <input type="text" name="naam" /> <input type="submit /> </form> </body> <html> Als de gebruiker hier bijvoorbeeld de naam Joost invult in het tekstvenster en dan op de submit-knop klikt, zal zijn webbrowser surfen naar de URL:.../programma.py?naam=Joost Hierbij is programma.py de naam van volgend Python programma, dat de naam die de gebruiker heeft ingevuld zal opvragen, en gebruiken om een gepersonaliseerde groet te produceren. #!/usr/bin/python import cgi fs = cgi. FieldStorage ( ) naam = fs. getvalue ( "naam" ) print "Content-type: text/html" print "" print "<html> <head> <title> Hoi </title> </head>" print "<body> <p>hallo, " print naam print ".</p> </body>" print "</html>" 4.2 Methodes met default argumenten Het is in Python mogelijk om argumenten van een methode of functie een default waarde te geven. Dit laat dan toe om deze methode op te roepen met minder argumenten dan normaal, waarbij de default waarde dan automatisch wordt ingevuld voor de ontbrekende argumenten. 50
Beschouw bijvoorbeeld volgende functie die de afstand tussen twee punten (x 1, y 1 ) en (x 2, y 2 ) berekent en waarbij de laatste twee argumenten een default waarde 0 hebben. def afstand ( x1, y1, x2=0, y2=0) : import math return math. sqrt ( ( x1 x2 ) **2 + ( y1 y2 ) **2) We kunnen deze methode natuurlijk op de normale manier oproepen, door vier argumenten mee te geven: >>> afstand(2,2,1,1) 1.4142135623730951 Dankzij de default argumenten, is het echter ook mogelijk om het laatste argument weg te laten. Hiervoor zal dan de overeenkomstige default waarde gebruikt worden, zodat volgende code de afstand tussen punten (2, 2) en (1, 0) zal berekenen. >>> afstand(2,2,1) 2.2360679774997898 Aangezien het derde argument van de methode ook een default waarde heeft, mag dit eveneens worden weggelaten. Volgende code berekent dus de afstand tussen punten (2, 2) en (0, 0). >>> afstand(2,2) 2.8284271247461903 Zoals aan deze voorbeelden te zien is, gebeurt het gebruik van default argumenten altijd van achter naar voor: dit wilt zeggen dat als je één argument weglaat, dit altijd het laatste argument is; als je twee argumenten weglaat, zijn dit de laatste twee argumenten; enzovoort. Het is ook enkel toegestaan om bij de definitie van de functie een default waarde aan een argument te geven als ook alle verdere argumenten een default waarde hebben. Dit mag dus bijvoorbeeld niet, omdat y2 geen default waarde heeft: def afstand ( x1, y1, x2 =0, y2 ) :... 4.3 Vergelijken van objecten In een objectgeöriënteerde programmeertaal heeft elke object zijn eigen, unieke identiteit. We kunnen ons dit voorstellen zoals bijvoorbeeld ons eigen rijksregisternummer: een getal dat van bij onze geboorte aan ons wordt toegekend, en dat ook tot onze dood van ons zal blijven. We kunnen dit eenvoudig zien in de Python interpreter: Elk object is uniek 51
>>> vodka = Drank("vodka", 0.4, 10) >>> vodka < main.drank instance at 0x50d788> Hier is 0x50d788 het unieke nummer 1 dat bij dit object hoort. Als we twee verschillende objecten aanmaken, hebben deze altijd verschillende nummers: >>> vodka2 = Drank("vodka", 0.4, 10) >>> vodka2 < main.drank instance at 0x50d760> Ondanks het feit dat alle attributen van de objecten vodka en vodka2 dus hetzelfde zijn, zijn deze twee objecten zélf toch verschillend. In het algemeen heeft elk object een unieke identiteit en daarnaast ook een aantal verschillende namen. In bovenstaande voorbeeldjes heeft het object met nummer 0x50d788 de naam vodka, en het object met nummer 0x50d760 de naam vodka2. We kunnen een object zoveel verschillende namen geven als we willen, en we kunnen ook de betekenis van een naam (dwz. het object waarnaar een naam verwijst) naar believen veranderen. De identiteit van een object blijft echter altijd dezelfde. In onderstaand code-fragmentje geven we bijvoorbeeld het object 0x50d760 een tweede naam: >>> vodka3 = vodka2 >>> vodka3 < main.drank instance at 0x50d760> >>> vodka2 < main.drank instance at 0x50d760> En in dit fragmentje wisselen we de betekenis van de namen vodka en vodka2 om: >>> vodka < main.drank instance at 0x50d788> >>> vodka2 < main.drank instance at 0x50d760> >>> tmp = vodka >>> vodka = vodka2 >>> vodka2 = tmp >>> vodka < main.drank instance at 0x50d760> >>> vodka2 < main.drank instance at 0x50d788> 1 Dit nummer is trouwens niet zomaar een nummer, maar is eigenlijk het geheugenadres waarop het object in kwestie zit opgeslagen in het RAM geheugen van de computer. 52
Nu we dit allemaal weten, is er nu een voor de hand liggende vraag: wat gebeurt er eigenlijk als we Python vragen of, bijvoorbeeld, vodka2 == vodka3? Er zijn op zijn minst twee verschillende dingen die zouden kunnen gebeuren: Python zou kunnen kijken of deze twee namen verwijzen naar hetzelfde object; Of Python zou kunnen kijken of deze twee namen verwijzen naar twee (potentieel verschillende) objecten die gelijk zijn aan elkaar, bijvoorbeeld omdat ze dezelfde waarde hebben voor al hun attributen. Het antwoord op deze vraag is een beetje complex. Een eerste deel van het antwoord is het feit dat Python niet één, maar twee verschillende manieren aanbiedt om dingen met elkaar te vergelijken: naast het dubbele is-gelijk-aan-teken ==, kent Python ook het sleutelwoord is. Laten we om te beginnen eens kijken wat deze twee vergelijkingsoperatoren doen met twee verschillende strings, die allebei uit dezelfde opeenvolging van letters bestaan. >>> "hal" + "lo" hallo >>> "hallo" hallo >>> "hal" + "lo" == "hallo" True >>> "hal" + "lo" is "hallo" False Op basis hiervan, zouden we geneigd zijn om te denken dat Python het volgende doet: Met x is y kijkt Python of x en y twee namen zijn die naar hetzelfde object verwijzen; Met x == y kijkt Python of x en y namen zijn voor twee (potentieel verschillende) objecten die gelijk zijn aan elkaar. Voor onze eigen Drank objecten, blijkt dit echter niet waar te zijn: >>> Drank("cola",0,3) is Drank("cola",0,3) False >>> Drank("cola",0,3) == Drank("cola",0,3) False Wat is er hier nu aan de hand? Als we zelf een klasse definiëren, dan zal Python ons ook zelf laten kiezen wanneer we twee verschillende objecten van deze klasse als gelijk wensen te beschouwen. We kunnen 53
dit doen door in onze klasse de magische methode eq (zelf, ander) te definiëren, waarbij de naam eq een afkorting is van het Engelse equals. Zolang we echter zelf geen zo n methode voorzien, zal Python gewoon uitgaan van deze implementatie: def eq ( z e l f, ander ) : return z e l f is ander Met andere woorden, Python zal in zo n geval == gewoon beschouwen als een synoniem voor is. Hieronder zijn twee pathologische voorbeelden van klassen die wel een eq methode hebben. class KlasseA : def eq ( z e l f, ander ) : return True class KlasseB : def eq ( z e l f, ander ) : return False Het effect hiervan is natuurlijk als volgt: >>> KlasseA() == KlasseA() True >>> KlasseB() == KlasseB() False Een meer zinvolle eq zou bijvoorbeeld de volgende toevoeging aan onze klasse Drank kunnen zijn: class Drank:... def eq ( z e l f, ander ) : return ( z e l f.naam == ander.naam and z e l f. alcoholpercentage == ander. alcoholpercentage and z e l f. p r i j s == ander. p r i j s ) Hiermee bekomen we nu immers dit resultaat: >>> Drank("cola",0,3) is Drank("cola",0,3) False >>> Drank("cola",0,3) == Drank("cola",0,3) True 54
Als we zouden willen hebben dat twee Drank-objecten als gelijk beschouwd worden vanaf dat, bijvoorbeeld, alleen al maar hun naam dezelfde is, dan volstaat het natuurlijk om de eq methode in die zin aan te passen. 4.4* Nog meer vergelijkingen Naast de == operator, kent Python nog een aantal andere vergelijkingsoperatoren. Ook van deze operatoren kan het gedrag aangepast worden door de juiste magische methode te implementeren. Hieronder een overzicht: Operator Magische methode == eq!= ne < lt <= le > gt >= ge Al deze methodes nemen twee argumenten: het zelf object en het andere object waarmee dit vergeleken moet worden. Het is ook mogelijk om al de verschillende vergelijkingsoperatoren in één klap te definiëren, zonder bovenstaande 6 verschillende methodes te moeten implementeren. Hiervoor dient de methode cmp (van compare). Deze methode geeft een getal terug dat aangeeft hoe zelf en het andere object zich tot elkaar verhouden: als de twee objecten gelijk zijn, moet de methode 0 teruggeven; als zelf strikt kleiner is dan het andere object, moet een negatief getal < 0 worden teruggegeven; en als zelf strikt groter is, dan moet een positief getal > 0 worden teruggegeven. Moesten we bijvoorbeeld onze dranken willen ordenen volgens alcoholpercentage, dan kan dit als volgt: class Drank:... def cmp ( z e l f, ander ) : return z e l f. alcoholpercentage ander. alcoholpercentage >>> cola = Drank("cola", 0, 2) >>> fanta = Drank("fanta", 0, 2) >>> cola is fanta False 55
>>> cola == fanta True >>> cola >= fanta True >>> cola <= fanta True Als we zowel een cmp methode implementeren als één of meer van de meer specifieke methodes uit bovenstaande tabel, dan krijgen de meer specifieke methodes voorrang. 4.5 Uitzonderingen Het volgend programma gebruikt de klasse GetallenRij, die we intussen al een paar keer zijn tegengekomen, om het gemiddelde te berekenen van een aantal getallen die de gebruiker ingeeft. class GetallenRij ( object ) : def i n i t ( z e l f ) : z e l f. r i j = [ ] def voegtoe ( z e l f, getal ) : z e l f. r i j. append ( getal ) def str ( z e l f ) : return str ( z e l f. r i j ) def som( z e l f ) : som = 0 for getal in z e l f. r i j : som += getal return som def gemiddelde ( z e l f ) : som = z e l f.som ( ) return f l o a t (som) / len ( z e l f. r i j ) # ^^^^^ anders krijgen we een gehele deling r i j = GetallenRij ( ) while True : invoer = raw_input ( "Geef een getal in of druk op enter om het gemiddelde te zien. " ) i f ( invoer == "" ) : break else : 56
r i j. voegtoe ( int ( invoer ) ) print r i j. gemiddelde ( ) Er kan zelfs met dit eenvoudige programma al vanalles mis gaan, als de gebruiker niet de gewenste invoer geeft. Eén van de dingen die bijvoorbeeld kan gebeuren, is dat de gebruiker op de entertoets drukt zonder getallen te hebben ingegeven. In dat geval, zal ons programma op een deling door nul stoten en op weinig elegante wijze eindigen. Laten we nu eens nadenken over hoe we dit probleem het beste oplossen. Er zijn hierbij twee vragen die we ons moeten stellen: Wat is de beste plaats om de fout te detecteren? Wat is de beste plaats om de fout op te lossen? Het mag duidelijk zijn dat het antwoord op beide vragen niet noodzakelijk hetzelfde is. Inderdaad, als we hierover nadenken, komen we tot volgende conclusies: Het detecteren van de fout gebeurt best zo laag mogelijk, dwz. vlak voor we effectief de deling door nul zouden gaan doen in de methode gemiddelde van de klasse GetallenRij. Door de foutendetectie op deze plaats te doen, zorgen we er immers voor dat elk gebruik van deze klasse van deze foutendetectie kan profiteren. Als we op twintig verschillende plaatsen in ons programma deze methode oproepen, dan zal dankzij één enkele if-test er bij alle twintig oproepen gecontroleerd worden of er toevallig geen deling door nul zou gebeuren. Het oplossen van de fout gebeurt echter liefst op een hoger niveau. Om te weten hoe we deze fout het beste oplossen, moeten we immers weten waar onze GetallenRij vandaan komt. Als we deze zoals in bovenstaand programma hebben aangemaakt op basis van invoer die de gebruiker heeft ingegeven aan de prompt, dan is de beste aanpak waarschijnlijk om middels een print commando de gebruiker te melden dat hij minstens één getal moet ingeven. Het is daarnaast ook best mogelijk dat we ooit een ander programma schrijven dat dezelfde methode zal gebruiken om, bijvoorbeeld, het gemiddelde te berekenen van een rij getallen die we inlezen van een bestand op de harde schijf. In dit laatste geval, zou het natuurlijk niet zinvol zijn om de gebruiker te zeggen dat hij te weinig getallen heeft ingegeven, aangezien de gebruiker van niets weet. Aangezien de klasse GetallenRij zelf geen flauw idee heeft waar de getallen wiens gemiddelde ze moet berekenen eigenlijk vandaan komen, kan deze klasse dus de fout niet oplossen en moet dit op een hoger niveau gebeuren. 57
Samengevat merken we dus dat we de fout zullen moeten oplossen op een andere plaats dan waar we ze detecteren. Hiervoor is er nood aan een mechanisme waarmee we de informatie dat er een fout is opgetreden kunnen doorgeven van de ene naar de andere plaats. Dit gebeurt in Python dmv. uitzonderingen. Op de plaats waar de fout gedetecteerd wordt, wordt de uitzondering opgeworpen door middel van het sleutelwoord raise. Op de plaats waar de fout afgehandeld moet worden, wordt de uitzondering afgehandeld door middel van een try-except blok. 4.5.1 Uitzonderingen opwerpen Laten we eerst eens kijken naar een voorbeeld van het opwerpen van een uitzondering. 1 class GetallenRij ( object ) : 2 3 def gemiddelde ( z e l f ) : 4 lengte = len ( z e l f. r i j ) 5 i f lengte == 0: 6 raise ValueError ( "Rij is leeg!" ) 7 som = z e l f.som ( ) 8 return f l o a t (som) / len ( z e l f. r i j ) 9 10... Het opwerpen van de uitzondering gebeurt in lijn 6 van dit voorbeeld. Zoals hier te zien is, gebeurt dit door middel van het sleutelwoord raise gevolgd door een nieuw aangemaakt object, in dit geval van de klasse ValueError. Deze klasse is één van de vele uitzonderingsklassen die ingebouwd zitten in Python. Een overzicht van een aantal van de meest gebruikten is te zien in Figuur 4.1. Al deze klassen hebben een constructor die als argument een string neemt die de opgetreden fout beschrijft. Het effect van onze uitzondering kunnen we als volgt zien: >>> rij = GetallenRij() >>> rij.gemiddelde() Traceback (most recent call last): File "<stdin>", line 1, in? File "GetallenRij.py", line 6, in gemiddelde raise ValueError("Rij is leeg!") ValueError: Rij is leeg! De laatste lijn toont de uitzondering die is opgetreden, en de lijnen daarvoor geven een traceback van de uitvoeringsstack die aangeeft waar 58
BaseException +-- Exception +-- StopIteration +-- StandardError +-- BufferError +-- ArithmeticError +-- FloatingPointError +-- OverflowError +-- ZeroDivisionError +-- EOFError +-- LookupError +-- IndexError +-- KeyError +-- RuntimeError +-- NotImplementedError +-- TypeError +--... +--... Figuur 4.1: Een aantal ingebouwde uitzonderingsklassen in een overervingshiërarchie. deze uitzondering is opgetreden. Dit vertelt ons dat er vanop standaard-invoer (<stdin>) een commando binnenkwam, waarvan de eerste lijn de methode gemiddelde heeft opgeroepen, die zich bevond in het bestand GetallenRij. Bij het uitvoeren van de instructie van de methode gemiddelde die op lijn 6 van dit bestand staat, is er dan een uitzondering opgeworpen. Om eens een wat meer complexe traceback te zien te krijgen, voegen we wat functies toe aan het programma: def gemiddelde ( r i j ) : gr = GetallenRij ( ) for x in r i j : gr. voegtoe ( x ) return gem( gr ) def gem( g e t a l l e n r i j ) : return g e t a l l e n r i j. gemiddelde ( ) >>> gemiddelde([]) Traceback (most recent call last): File "<stdin>", line 1, in? File "functies.py", line 5, in gemiddelde return gem(gr) File "functies.py", line 7, in gem 59
return getallenrij.gemiddelde() File "GetallenRij.py", line 6, in gemiddelde raise ValueError("Rij is leeg!") ValueError: Rij is leeg! Hier zien we dus dat de uitzondering optrad in de methode gemiddelde, die was opgeroepen vanuit de functie gem, die was opgeroepen vanuit de functie gemiddelde, die was opgeroepen vanop <stdin>. Bovenstaande uitzondering hebben we zelf geraised, maar ook ingebouwde Python functies doen dit wel eens. Bijvoorbeeld: >>> rij = [1] >>> rij[2] Traceback (most recent call last): File "<stdin>", line 1, in? IndexError: list index out of range >>> x = 5 >>> x[2] Traceback (most recent call last): File "<stdin>", line 1, in? TypeError: unsubscriptable object >>> 5 / 0 Traceback (most recent call last): File "<stdin>", line 1, in? ZeroDivisionError: integer division or modulo by zero 4.5.2 Uitzonderingen afhandelen In de voorbeelden die we tot dusver gezien hebben, werden de uitzondering die we gooiden altijd gewoon aan de gebruiker getoond. Als dat ons enige doel was, hadden we natuurlijk even goed met print kunnen werken. De kracht van het uitzonderingsmechanisme schuilt in het feit dat deze uitzonderingen ook weer kunnen afgehandeld worden, zodat de gebruiker ze nooit te zien zal krijgen. Met andere woorden, als we een fout of abnormale situatie ontdekken, dan gooien we een uitzondering, en als we er in slagen om de fout weer te herstellen of de situatie weer te normaliseren, dan handelen we deze uitzondering af, zonder dat de gebruiker ooit hoeft te weten dat er iets aan de hand was. Maar hoe werkt dit nu juist? Van zodra een uitzondering gegooid wordt in een methode of functie, wordt de uitvoering hiervan afgebroken en gaan we weer naar boven in de uitvoeringsstack. Als we terugdenken aan het voorbeeld van gemiddelde([]) uit de vorige sectie, dan zal het volgende gebeuren: In de methode gemiddelde van de klasse GetallenRij wordt de instructie raise ValueError(... ) uitgevoerd. 60
Op dit moment wordt de uitvoering van deze methode beëindigd, en keren we terug naar de plaats van waaruit deze methode werd opgeroepen, namelijk, de functie gem. Ook de uitvoering van de functie gem wordt beëindigd, en we keren terug naar de plaats van waaruit deze werd opgeroepen, namelijk, de functie gemiddelde. Ook de uitvoering van deze functie wordt beëindigd, en we keren terug naar de plaats van waaruit deze werd opgeroepen, namelijk, de instructie gemiddelde([]), die de gebruiker had ingegeven. De traceback die in de vorige sectie reeds getoond werd, is natuurlijk het resultaat van dit proces. Elk van deze tussenniveaus gaat nu de kans krijgen om de uitzondering die opgeworpen werd weer op te vangen. We kunnen ons dit voorstellen als een soort van gezagshiërarchie, zoals bijvoorbeeld in het leger. Als een generaal een opdracht moet vervullen, zal hij een aantal taken geven aan zijn kolonels. Een kolonel geeft op zijn beurt taken aan zijn kapiteins. Een kapitein geeft taken aan zijn sergeanten, en deze sergeanten geven taken aan de soldaten. Als nu een soldaat een probleem tegenkomt bij het uitvoeren van zijn taak, zal hij dit signaleren aan zijn sergeant. Deze sergeant kan dan misschien het probleem zelf oplossen (= de uitzondering afhandelen). Als hij dit echter zelf niet kan, dan zal hij het probleem tot bij zijn kapitein moeten brengen. Als de kapitein het ook niet kan oplossen, dan gaat het naar de kolonel. Als de kolonel het al evenmin opgelost krijgt, gaat het naar de generaal, en als die het ook niet kan oplossen, dan zal de taak gewoon niet uitgevoerd kunnen worden. In Python wordt zoiets geïmplementeerd dmv. een try-except blok. Dit ziet er als volgt uit: 1... 2 try : 3... 4... 5... 6 except ValueError : 7... 8 except ArithmeticError : 9... 10... In het try-blok staan een aantal gewone Python instructies. Als tijdens het uitvoeren van één van deze instructies een uitzondering wordt opgeworpen, dan wordt deze mogelijk afgehandeld door één van de except-blokken die bij het try-blok horen. Hiervoor wordt er gezocht naar een except-blok dat een superklasse vermeld van de klasse van 61
de uitzondering die gegooid geweest is, of natuurlijk deze klasse zelf. In bovenstaand code-fragmentje, zullen dus alle uitzonderingen van (een subklasse van) de klasse ValueError en ArithmeticError worden afgehandeld. Andere uitzonderingen worden op deze plaats niet afgehandeld, en zullen dus verder naar boven op de uitvoeringsstack escaleren. Als een geschikt except-blok gevonden wordt, dan wordt de code die hierin staat uitgevoerd. Het is natuurlijk de bedoeling dat deze code dan ook maatregelen neemt om de fout die is opgetreden te herstellen. Nadat deze code werd uitgevoerd, wordt de uitzondering dan ook als afgehandeld beschouwd. De uitvoering van het programma zal dan gewoon verder gaan op het einde de try-except constructie. Als dus, bijvoorbeeld, in lijn 4 een uitzondering word opgeworpen, dan zal lijn 5 sowieso niet meer worden uitgevoerd. Als de uitzondering die opgeworpen werd een ValueError (of subklasse daarvan) was, dan wordt eerst lijn 7 uitgevoerd, en gaan we daarna verder vanaf lijn 10. Als het een ArithmeticError (of subklasse daarvan) was, wordt lijn 9 uitgevoerd, en ook dan gaan we daarna verder vanaf lijn 10. Als een ander soort uitzondering gegooid werd, dan wordt niets van bovenstaande code meer uitgevoerd, maar escaleert de uitzondering verder naar de plaats van waarop deze code werd opgeroepen. 4.5.3 Herwerking van het voorbeeld We kunnen onze kennis van uitzonderingen nu gebruiken om het voorbeeld waarmee we dit hoofdstuk begonnen te verbeteren. class GetallenRij ( object ) : def i n i t ( z e l f ) : z e l f. r i j = [ ] def voegtoe ( z e l f, getal ) : z e l f. r i j. append ( getal ) def str ( z e l f ) : return str ( z e l f. r i j ) def som( z e l f ) : som = 0 for getal in z e l f. r i j : som += getal return som def gemiddelde ( z e l f ) : lengte = len ( z e l f. r i j ) 62
i f lengte == 0: raise ValueError ( "Rij is leeg!" ) som = z e l f.som ( ) return f l o a t (som) / len ( z e l f. r i j ) opnieuwofstop = "o" # Om de lus minstens 1 keer te doen while opnieuwofstop == "o" : r i j = GetallenRij ( ) while True : invoer = raw_input ( "Geef een getal in of druk op enter om het gemiddelde te zien. " ) i f ( invoer == "" ) : break else : r i j. voegtoe ( int ( invoer ) ) try : print r i j. gemiddelde ( ) except ValueError : print "Gelieve minstens 1 getal in te geven." opnieuwofstop = raw_input ( "Wilt u (o)pnieuw beginnen of (s)toppen? [o/s]" ) print "Vaarwel!" Geef een getal in of druk op enter om het gemiddelde te zien. 4 Geef een getal in of druk op enter om het gemiddelde te zien. 6 Geef een getal in of druk op enter om het gemiddelde te zien. 5.0 Wilt u (o)pnieuw beginnen of (s)toppen? [o/s]o Geef een getal in of druk op enter om het gemiddelde te zien. Gelieve minstens 1 getal in te geven. Wilt u (o)pnieuw beginnen of (s)toppen? [o/s]s Vaarwel! 4.6 Statische methodes Tot dusver hebben we gezien dat een klasse een verzameling van objecten is, waarbij deze objecten gegevens bijhouden in hun attributen en methodes aanbieden om de gegevens in deze attributen te manipuleren. Het is echter ook mogelijk om zowel methodes als attributen te koppelen aan een klasse op zich. Dit is nuttig voor zaken die nodig zijn voor de goede werking van een klasse, maar niet gekoppeld zijn aan een individueel object hiervan. 63
Laten we nog eens een meetkundig voorbeeld nemen. Een cirkel heeft een middelpunt en een straal. De meest voor de hand liggende manier om een cirkel te construeren is daarom ook de volgende, waarbij we gebruik maken van de klasse Punt uit Sectie 2.5.1 om het middelpunt voor te stellen. class Cirkel ( object ) : def i n i t ( z e l f, middelpunt, straal ) : z e l f. middelpunt = middelpunt z e l f. straal = straal def str ( z e l f ) : return "Cirkel van " + str ( z e l f. straal ) + " rond " + str ( z e l f. middelpunt ( ) ) c = Cirkel(Punt(1,1),2) In meetkunde, kunnen we een cirkel nog op veel andere manieren definiëren dan via zijn middelpunt en straal. Zo kunnen we bijvoorbeeld vertrekken van een vierkant en daarvan ofwel de ingeschreven of de omgeschreven cirkel construeren, zoals getoond in Figuur 4.2. We zouden in de klasse Vierkant methodes kunnen opnemen die deze cirkels construeren: class Vierkant ( Veelhoek ) :... def middelpunt ( z e l f ) : middenx = z e l f. hoekpunten [ 0 ]. x + z e l f. lengte/2 middeny = z e l f. hoekpunten [ 0 ]. y + z e l f. lengte/2 return Punt ( middenx, middeny ) def ingeschreven ( z e l f ) : return Cirkel ( z e l f. middelpunt ( ), z e l f. lengte /2) def omgeschreven ( z e l f ) : import math half = z e l f. lengte/2 return Cirkel ( z e l f. middelpunt ( ), math. sqrt ( 2 ) half ) * Als andere programmeurs willen weten wat de verschillende manieren zijn om een cirkel te construeren, gaan ze echter waarschijnlijk eerder in de klasse Cirkel zelf gaan kijken, dan in de klasse Vierkant. Het kan daarom beter zijn om de twee laatste methodes daar te zetten, in 64
plaats van in de klasse Vierkant. We zouden dit eventueel als volgt kunnen doen: class Cirkel ( object ) :... def ingeschreven ( z e l f, vierkant ) : return Cirkel ( vierkant. middelpunt ( ), vierkant. lengte /2) def omgeschreven ( z e l f, vierkant ) : import math half = vierkant. lengte/2 return Cirkel ( vierkant. middelpunt ( ), math. sqrt ( 2 ) * half ) Zoals altijd, hebben deze twee methodes allebei een argument zelf, maar in dit geval gebeurt daar helemaal niets mee! Hoewel we deze methode dus altijd moeten oproepen op een cirkel, maakt het eigenlijk helemaal niet uit op dewelke: het resultaat zal altijd hetzelfde zijn. Dat is natuurlijk ook logisch, aangezien de ingeschreven/omgeschreven cirkel alleen maar afhangt van het vierkant in kwestie. vierkant = Vierkant(Punt(2,2),2) c1 = Cirkel(Punt(1,1),1) c2 = Cirkel(Punt(0,0),6) >>> print c1.omgeschreven(vierkant) Cirkel van 1.41421356237 rond Punt(3, 3) >>> print c2.omgeschreven(vierkant) Cirkel van 1.41421356237 rond Punt(3, 3) Om dit nutteloze eerste argument te vermijden, kunnen we een statische methode gebruiken. Een statische methode wordt niet opgeroepen op een object van een klasse, zoals een normale methode, maar wel op de klasse zelf. Een statische methode heeft geen impliciet eerste argument zelf. Je maakt een statische methode door gebruik te maken van de annotatie @staticmethod. class Cirkel ( object ) : Een staticmethod heeft geen impliciet eerste argumente... @staticmethod def ingeschreven ( vierkant ) : return Cirkel ( vierkant. middelpunt ( ), vierkant. lengte /2) 65
Figuur 4.2: Een vierkant met zijn ingeschreven (blauw) en omgeschreven (rood) cirkel. @staticmethod def omgeschreven ( vierkant ) : import math half = vierkant. lengte/2 return Cirkel ( vierkant. middelpunt ( ), math. sqrt ( 2 ) * half ) We kunnen nu deze methodes gebruiken zonder daarvoor nog een object van de klasse Cirkel nodig te hebben. >>> print Cirkel.omgeschreven(vierkant) Cirkel van 1.41421356237 rond Punt(3, 3) >>> print Cirkel.ingeschreven(vierkant) Cirkel van 1 rond Punt(3, 3) Een ander voorbeeld is onderstaande statische methode die een eenheidscirkel construeert en teruggeeft. class Cirkel ( object ) :... @staticmethod def eenheidscirkel ( ) : return Cirkel ( Punt (0,0), 1) Hierdoor kunnen we nu bijvoorbeeld >>> c = Cirkel.eenheidscirkel() schrijven in plaats van >>> c = Cirkel(Punt(0,0), 1) 66
wat de leesbaarheid van onze code vergroot. Naast statische methoden, zijn er ook statische attributen mogelijk. Deze worden gewoon gedefiniëerd in de declaratie van de klasse, op hetzelfde niveau als de declaratie van de methodes. class Cirkel ( object ) :... pi = 22.0/7 Deze waarde kunnen we dan aanspreken als Cirkel.pi. Tussen haakjes, de beste manier om de waarde van π te pakken te krijgen, is natuurlijk nog steeds: import math math. pi Merk wel op dat het niet mogelijk is om de klasse Cirkel bijvoorbeeld als volgt een attribuut eenheidscirkel te geven, ipv. een methode: class Cirkel :... eenheidscirkel = Cirkel ( Punt (0,0),1) De reden hiervoor is dat we de constructor van de klasse Cirkel pas mogen gebruiken nadat de definitie van deze klasse volledig ten einde is. 4.7* Nog een blik achter de schermen Zoals we ons herinneren uit Sectie 1.8, zorgt Python er achter de schermen voor dat al de attributen van een object in een woordenboek belanden dat bij dit object hoort. Daarnaast hebben we ook gezien dat elke klasse eveneens een woordenboek heeft, waarin o.a. al de methodes van deze kasse zitten. Zoals we zouden verwachten, is het speciale aan een statisch attribuut niet meer of niet minder dan dat dit attribuut zich in het woordenboek bevindt dat bij de klasse hoort, ipv. bij een individueel object van deze klasse. >>> c = Cirkel(Punt(0,0), 1) >>> print c. dict { middelpunt : < main.punt instance at 0x50dc60>, straal : 1} >>> print c. class <class main.cirkel > 67
>>> print c. class. dict { dict : <attribute dict of Cirkel objects>, module : ma 4.7.1 De volledige klasse Cirkel Hieronder nog eens de volledige klasse Cirkel met haar drie statische methodes en haar statische attribuut. class Cirkel ( object ) : def i n i t ( z e l f, middelpunt, straal ) : z e l f. middelpunt = middelpunt z e l f. straal = straal def str ( z e l f ) : return "Cirkel van " + str ( z e l f. straal ) + " rond " + str ( z e l f. middelpunt ) @staticmethod def ingeschreven ( vierkant ) : return Cirkel ( vierkant. middelpunt ( ), vierkant. lengte /2) @staticmethod def omgeschreven ( vierkant ) : import math half = vierkant. lengte/2 return Cirkel ( vierkant. middelpunt ( ), math. sqrt ( 2 ) * half ) @staticmethod def eenheidscirkel ( ) : return Cirkel ( Punt (0,0),1) pi = 22/7 def omtrek ( z e l f ) : return 2 * Cirkel. pi * z e l f. straal 4.8 Private variabelen Zoals we intussen weten, is encapsulatie een belangrijke motivatie voor objectgericht programmeren. Door gegevens binnen een klasse zoveel mogelijk af te schermen, behouden we langs de ene kant zoveel 68
mogelijk vrijheid om later aanpassingen binnenin deze klasse te doen, en maken we anderzijds ook het gebruik van deze klasse in de rest van ons programma zo eenvoudig mogelijk. Om dit te bereiken, is gewoon het gebruik van een objectgerichte programmeertaal op zich natuurlijk niet voldoende. We moeten hiervoor ook de discipline aan de dag leggen om nooit van buiten een klasse rechtstreeks in te grijpen in diens interne werking. Hier is nog eens een voorbeeld om dit te illustreren. We maken een klasse Datum en een klasse Reis die deze Datums gebruikt om zijn begin- en einddatum voor te stellen. class Datum( object ) : def i n i t ( z e l f, dag, maand, jaar ) : z e l f. dag = dag z e l f.maand = maand z e l f. jaar = jaar """ Magische methode om twee datums te vergelijken - Resultaat = 0: ze zijn gelijk - Resultaat < 0: de eerste is kleiner - Resultaat > 0: de tweede is kleiner """ def cmp ( z e l f, ander ) : jaarverschil = z e l f. jaar ander. jaar maandverschil = z e l f.maand ander.maand dagverschil = z e l f. dag ander. dag verschillen = [ jaarverschil, maandverschil, dagverschil ] # We geven nu het eerste verschil!= 0 terug for verschil in verschillen : i f verschil!= 0: return verschil # Tenzij er zo geen is, want dan zijn ze gelijk return 0 @staticmethod def schrikkeljaar ( jaar ) : return jaar % 4 == 0 and ( jaar % 100! = 0 or jaar % 400 == 0) @staticmethod def aantaldagen ( maand, jaar ) : met31 = [1,3,5,7,8,10,12] met30 = [4,6,9,11] i f maand in met31 : 69
return 31 i f maand in met30 : return 30 # Als we hier komen, is het februari i f Datum. schrikkeljaar ( jaar ) : return 29 else : return 28 def volgendedag ( z e l f ) : volgende = Datum( z e l f. dag, z e l f.maand, z e l f. jaar ) laatstedag = Datum. aantaldagen ( z e l f. maand, z e l f. jaar ) laatstemaand = 12 i f z e l f. dag < laatstedag : volgende. dag += 1 e l i f z e l f.maand < laatstemaand : volgende.maand += 1 volgende. dag = 1 else : # Gelukkig nieuwjaar! volgende. jaar += 1 volgende. dag = 1 volgende.maand = 1 return volgende class Reis ( object ) : def i n i t ( z e l f, van, tot, naar ) : z e l f. vertrek = van z e l f. terugkeer = tot z e l f. bestemming = naar def overlaptmet ( z e l f, ander ) : return not ( z e l f. terugkeer < ander. vertrek or ander. terugkeer < z e l f. vertrek ) def verleng ( z e l f, aantaldagen = 1) : for i in range ( aantaldagen ) : z e l f. terugkeer = z e l f. terugkeer. volgendedag ( ) >>> d1 = Datum(20, 8, 2013) >>> d2 = Datum(31, 8, 2013) >>> reis1 = Reis(d1, d2, "Istanbul") >>> d3 = Datum(1, 9, 2013) >>> d4 = Datum(5, 9, 2013) >>> reis2 = Reis(d3, d4, "Parijs") 70
>>> reis1.overlaptmet(reis2) False >>> reis1.verleng() >>> reis1.overlaptmet(reis2) True In bovenstaande code is de manier waarop we een datum voorstellen netjes ingekapseld in de klasse Datum. Dit betekent dat we deze voorstelling kunnen veranderen zonder code buiten deze klasse te moeten aanpassen. In het bijzonder de klasse Reis kan gewoon blijven zoals ze is. Laten we dit illustreren door een andere voorstelling van een datum te kiezen, waarbij er geen gebruik gemaakt wordt van dagen, maanden en jaren, maar waarbij we (net zoals het Unix besturingssysteem dat doet) een datum zullen voorstellen door middel van het aantal seconden dat verstreken is sinds 00:00:00 op 1 januari 1970. class Datum( object ) : secperdag = 60 * 60 * 24 dagenperjaar = 365 def i n i t ( z e l f, dag=1, maand=1, jaar =1970) : # Het aantal dagen sinds 1970 # tot aan het begin van dit jaar dagentotbeginjaar = 0 for j in range(1970, jaar ) : dagentotbeginjaar += dagenperjaar i f Datum. schrikkeljaar ( jaar ) : dagentotbeginjaar += 1 # Het aantal dagen in dit jaar # tot het begin van deze maand dagentotbeginmaand = 0 for m in range ( maand, 1) : dagentotbeginmaand += Datum. aantaldagen (m, jaar ) # Het aantal dagen in deze maand # tot aan deze dag dagentotdag = dag 1 dagentotaal = dagentotbeginjaar + dagentotbeginmaand + dagentotdag z e l f. verstreken = Datum. secperdag * dagentotaal """ Magische methode om twee datums te vergelijken - Resultaat = 0: ze zijn gelijk - Resultaat < 0: de eerste is kleiner 71
- Resultaat > 0: de tweede is kleiner """ def cmp ( z e l f, ander ) : return z e l f. verstreken ander. verstreken @staticmethod def schrikkeljaar ( jaar ) : return jaar % 4 == 0 and ( jaar % 100! = 0 or jaar % 400 == 0) @staticmethod def aantaldagen ( maand, jaar ) : met31 = [1,3,5,7,8,10,12] met30 = [4,6,9,11] i f maand in met31 : return 31 i f maand in met30 : return 30 # Als we hier komen, is het februari i f Datum. schrikkeljaar ( jaar ) : return 29 return 28 def volgendedag ( z e l f ) : volgende = Datum ( ) # Maakt niet uit welke datum volgende. verstreken = z e l f. verstreken + 1 return volgende Je kan gemakkelijk nakijken dat, ook met deze nieuwe versie van de klasse Datum, de klasse Reis nog steeds correct zal werken. Dit is dus precies wat we met encapsulatie hopen te bereiken. Dit zou echter niet gelukt zijn, moesten we in onze klasse Reis bijvoorbeeld volgende methode gehad hebben: class Reis ( object ) :... def str ( z e l f ) : r1 = "Reis naar " + z e l f. bestemming r2 = "(van " + str ( z e l f. vertrek. dag ) + "," + str ( z e l f. vertrek.maand) r2 = " tot " + str ( z e l f. terugkeer. dag ) + "," + str ( z e l f. terugkeer.maand) + ")" return r1 + r2 + r3 Deze methode zou met onze oorspronkelijke versie van de klasse Datum goed gewerkt hebben, maar met onze nieuwe versie niet meer. De reden 72
hiervoor is dat ze de encapsulatie doorbreekt door rechtstreeks de attributen van een datum aan te spreken. Als we bij het maken de klasse Datum al het vermoeden hadden dat we misschien ooit de voorstelling van een datum zouden willen wijzigen, dan hadden we misschien beter geprobeerd om dit te voorkomen. In Python wordt een underscore _ gebruikt om aan te geven dat een attribuut enkel bedoeld is voor de interne werking van een klasse, en dat het niet de bedoeling is dat er vanuit andere klassen rechtstreeks met dit attribuut gewerkt wordt. We hadden onze eerste versie van de klasse Datum dus misschien beter zo geschreven: class Datum( object ) : Leidende underscore = privaat def i n i t ( z e l f, dag, maand, jaar ) : z e l f. _dag = dag z e l f._maand = maand z e l f. _jaar = jaar... Merk op: _dag, _maand en _jaar zijn nu gewoon de namen van de drie attributen van een Datum. Er is dus helemaal niets speciaals aan de underscore; dit is gewoon een letter die uitmaakt van de naam van het attribuut. De betekenis die eraan gegeven wordt (namelijk dat dit attributen zijn voor intern gebruik), is alleen maar een kwestie van afspraak tussen Python programmeurs. Als de waarde van deze attributen van buiten de klasse toch opgevraagd of gewijzigd moet kunnen worden, kunnen we hiervoor natuurlijk methodes voorzien. Een standaard conventie is om, voor een attribuut _ding, de naam getding te gebruiken voor een methode die de waarde van _ding teruggeeft, en de naam setding voor een methode die de waarde verandert. We spreken ook wel van getters en setters. class Datum( object ) : def i n i t ( z e l f, dag, maand, jaar ) : z e l f. _dag = dag z e l f._maand = maand z e l f. _jaar = jaar def getdag ( z e l f ) : return z e l f. _dag def setdag ( z e l f, dag ) : z e l f. _dag = dag 73
... Als we nu later de dag/maand/jaar-voorstelling zouden willen vervangen door een andere, dan kunnen we door het aanpassen van de getters en setters voor deze attributen ervoor zorgen dat andere klassen toch onveranderd correct blijven werken. 4.9* Eigenschappen (Properties) Het voorbeeld met getters en setters uit de vorige sectie is een beetje on-pythoniaans. De reden hiervoor is dat Python eigenlijk een betere manier aanbiedt om hetzelfde effect te bekomen, namelijk het concept van eigenschappen (of properties in het Engels). Zo n eigenschap bestaat uit een attribuut met een bijhorende getter en/of setter. Bij de getter wordt de annotatie @property geplaatst, en bij de eventuele setter de annotatie @getter.setter, waarbij de naam van de getter moet worden ingevuld. Zowel getter als setter moeten bovendien dezelfde naam hebben. Onderstaande code definieert drie eigenschappen, namelijk dag, maand en jaar. class Datum( object ) : def i n i t ( z e l f, dag, maand, jaar ) : z e l f. _dag = dag z e l f._maand = maand z e l f. _jaar = jaar @property def dag ( z e l f ) : return z e l f. _dag @dag. setter def dag ( z e l f, dag ) : z e l f. _dag = dag @property def maand( z e l f ) : return z e l f._maand @maand. setter def maand( z e l f, maand) : z e l f._maand = maand @property def jaar ( z e l f ) : 74
return z e l f. _jaar @jaar. setter def jaar ( z e l f, jaar ) : z e l f. _jaar = jaar Het speciale hieraan is dat we zo n eigenschap nu kunnen gebruiken alsof het een gewoon attribuut betrof. We kunnen dus met andere woorden gewoon dit doen: >>> d = Datum(5,4,2001) >>> d.dag 5 >>> d.jaar = 1999 >>> d.jaar 1999 Merk op dat we hier dus niet de naam van de echte attributen van het Datum object gebruiken (die zijn immers _dag, _maand en _jaar, en dat zouden zelfs ook totaal andere namen kunnen zijn), maar wel de namen van de getters/setters die we geschreven hebben. Telkens als we hieraan een toekenning doen, zal Python de setter oproepen, en telkens als we hiervan de waarde opvragen, roept Python de getter op. In bovenstaand code fragmentje doet de tweede lijn dus eigenlijk een methode-oproep d.dag(), terwijl de 4 e lijn een methode-oproep d.jaar(1999) doet. Het leuke is dat al deze methode-oproepen onzichtbaar zijn, zodat we eigenlijk niet hoeven te weten of dag nu een echt attribuut dan wel een eigenschap is. Dit is een detail waarover enkel de persoon die de klasse Datum implementeert zich zorgen moet maken, maar niet de programmeurs die deze klasse gewoon maar gebruiken. 75
76
Practicum opgaves A A.1 Setup We overlopen kort alle verschillende stappen die nodig zijn om een Python programma te installeren op de webserver chaplin.local.thomasmore.be in lokaal A217. 1. Download het bestand http://chaplin.local.thomasmore.be/ jve/test.py en plaats het in de map public_html. Dit bestand heeft de volgende inhoud: #!/usr/local/bin/python import jve svg = """ <svg xmlns="http://www.w3.org/2000/svg"> <circle cx="20" cy="20" r="10" fill="blue" /> </svg> """ print jve.http(svg) De eerste lijn dient om de webserver te vertellen dat dit een programma is dat moet worden uitgevoerd in Python. Vergeet ook het xmlns-attribuut in het <svg>-element niet, aangezien dit nodig is om Firefox te vertellen dat je een SVG-tekening wilt maken. Verder gebruiken we ook nog de functie http uit de module jve die we bovenaan importeren. Het effect hiervan wordt verderop duidelijk. 77
2. Vertel de webserver dat hij dit programma mag uitvoeren. Dit doe je bijvoorbeeld door in een terminal volgende commando s uit te voeren (het eerste is niet nodig als je terminal zich al in deze map bevindt): % cd public_html % chmod a+rx test.py 3. Je kan nu je programma als volgt uitvoeren: %./test.py Het resultaat zou er zo moeten uitzien: %./test.py Content-type: text/xml <svg xmlns="http://www.w3.org/2000/svg"> <circle cx="20" cy="20" r="10" fill="blue" /> </svg> Je ziet hier meteen wat het effect is van de functie http uit de module jve: deze heeft gewoon bovenaan een extra lijn toegevoegd, die Firefox vertelt wat voor soort inhoud er volgt. 4. Als je nu in Firefox naar volgende URL surft: http://chaplin.local.thomasmore.be/~axxx/test.py zou je een blauw bolletje moeten zien. A.2 Vogelpik In deze practicum-zitting oefenen we op het SVG-formaat voor het voorstellen van afbeeldingen. We herhalen ook een aantal begrippen van programmeren in Python, zoals de if en for/while instructies. Tot slot maken we ons eerste webformulier, en bekijken we hoe we de gegevens die een gebruiker hier invult kunnen doorgeven aan een Python programma, dat dit dan op zijn beurt gebruikt om een SVG-afbeelding voor de gebruiker te produceren. Een vogelpik-roos bestaat uit een aantal concentrische cirkels in twee verschillende kleuren, bijvoorbeeld rood en blauw. 78
Oef. 2.1 Maak een HTML pagina oef1-1.html met daarop een SVG-prentje dat bovenstaande roos voorstelt. Oef. 2.2 Kopieer het bestand test.py uit de vorige opgave en vervang de instructie svg = """... """ door je SVG-code voor de vogelpik-roos. Controleer dat je deze roos nu te zijn krijgt als je surft naar: http://chaplin.local.thomasmore.be/~axxx/vogelpik.py Oef. 2.3 Definieer nu in vogelpik.py een functie vogelpik(aantal), die het gewenste aantal concentrische cirkels als argument neemt en de SVG-voorstelling van een vogelpik-roos met dit aantal cirkels teruggeeft. De cirkels moeten getekend worden met als middelpunt de coördinaten (200, 200); elke band van de roos moet 20pt breed zijn. Als je nu de toekenning svg = """... """ vervangt door svg = vogelpik(4), zou je in de webbrowser nog steeds hetzelfde moeten zien als daarnet. Als je een andere waarde dan 4 neemt, zou je natuurlijk grotere of kleinere rozen moeten zien verschijnen. Tip: Als je in de webbrowser niet het gewenste resultaat ziet, probeer dan je programma uit te voeren in een terminal (met het commando./vogelpik.py). Zo zie je vaak sneller wat er mis is. Oef. 2.4 Zorg ervoor dat je Python-programma een query-string van de vorm aantal=n aanvaardt, en de waarde n doorgeeft als argument voor de vogelpik functie. Het resultaat moet met andere woorden zijn dat als je surft naar volgende URL, je een roos met 7 cirkels te zien zal krijgen: http://chaplin/~axxx/vogelpik.py?aantal=7 Je kan hiervoor vertrekken van het voorbeeld in Sectie 4.1. Mogelijk heb je ook nood aan de functie int(string) die een string zoals "7" omzet in een getalwaarde. Oef. 2.5 Laat je programma testen of het aantal dat de gebruiker doorgeeft inderdaad een geldig aantal 1 is. Als de gebruiker je programma probeert op te roepen met een ongeldig getal 0, produceer dan een roos met 5 cirkels. 79
Oef. 2.6 Maak een webpagina vogelpik.html met daarop een formulier. In dit formulier, moet zich een tekstveldje bevinden, waarin een gebruiker het aantal concentrische cirkels dat hij wenst, kan invullen. Het formulier moet daarnaast natuurlijk ook een submit-knop hebben. Als de gebruiker hierop klikt, dan wordt het getal dat hij heeft ingevuld doorgestuurd naar je Python-programma, zodat de gebruiker een afbeelding krijgt met daarop de juiste vogelpik-roos. Oef. 2.7 Voor gebruikers die niet houden van rood en blauw: voorzie in het formulier ook twee selectie-menuutjes, waarmee de gebruiker kan kiezen tussen de opties red, blue, green, black. Zorg ervoor dat de twee kleuren die de gebruiker selecteert, worden doorgegeven aan je Python-programma, en pas dit zodanig aan dat het de roos in de juiste kleuren toont. A.3 De eerste objectjes In dit practicum maken we kennis met objectgericht programmeren in Python. We doen dit door onze oefening uit de vorige practicum-zitting op een objectgerichte manier te herschrijven. Maak een kopie vogelpik-oo.py van je oplossing van de vorige practicum-zitting. We gaan deze nu herschrijven op een meer objectgerichte manier. Hiervoor maken we een klasse Roos, waarmee we de functionaliteit van het produceren van de afbeelding gaan encapsuleren. In een eerste fase, gaan we het instellen van de parameters (grootte en eventueel de te gebruiken kleuren) van de roos en het effectief produceren van de SVG-uitvoer uit elkaar trekken. Oef. 3.1 Maak een klasse Roos met daarin een constructor met als argument het aantal vereiste concentrische cirkel. Merk op: deze constructor moet niets doen, buiten het getal dat het als argument krijgt onthouden in een attribuut van het object. Test dat je nu effectief zo n Roos object kan aanmaken. Geef de klasse Roos een str (zelf) methode, die een string van de vorm roos met n cirkels produceert. Test dat je wel degelijk de juiste uitvoer bekomt als je een nieuwe roos maakt en deze dan print. Oef. 3.2 Voorzie een methode svg(zelf), waarmee de Roos zichzelf kan tekenen, dwz. de benodigde SVG-code kan genereren. Laat deze methode enkel maar de <circle /> commando s produceren. De omringende <svg>...</svg> tags kan je beter aanbrengen in de functie van waaruit je deze methode oproept. Controleer dat je objectgericht Python programma nu opnieuw alles kan wat je vorige programma kon. 80
Oef. 3.3 Breid de methode svg(zelf) uit met twee bijkomende argumenten die de (x, y) positie van het middelpunt van de cirkels specifiëren meegeven. Pas nu je programma aan zodat de roos die de gebruiker vraagt niet één, maar twee keer getekend wordt: een keer met middelpunt (200, 200) zoals voorheen en een keer met middelpunt (100, 100). Hiervoor moet je één Roos object aanmaken, waarop je twee keer de methode svg oproept. In plaats van aan de methode svg twee bijkomende argumenten mee te geven die de x en y positie van het middelpunt voorstellen, is een nettere oplossing dat we slechts één bijkomend argument meegeven dat het punt in het vlak voorstelt waarop we het middelpunt willen hebben. Om dit te verwezenlijken, moeten we een tweede klasse maken, namelijk de klasse Punt. Oef. 3.4 Maak een klasse Punt met daarin een constructor waarmee een punt kan worden aangemaakt, op basis van twee komma-getallen. Test deze uit door volgende twee punten aan te maken: p 1 = (2.3, 3.5) p 2 = ( 5, 2.2) Oef. 3.5 Geef de klasse Punt een methode str (zelf), die een punt omzet naar volgende string-voorstelling: Punt(x, y) Gebruik deze methode om de twee punten p 1 en p 2 af te printen. Oef. 3.6 Pas de methode Roos.svg zodanig aan dat je ze nu kan oproepen met een Punt object als argument. Gebruik deze om het gedrag van Oef. 3.3 mee te reproduceren. Ben je klaar met deze oefeningen? Begin dan gerust al aan de volgende opgave! A.4 Punten, ruimtes en ballen In dit practicum gaan we de concepten uit de vorige zitting nog wat verder inoefenen. We doen dit in de eerste plaats door de klasse Punt verder uit te breiden. Daarna komt ook wat nieuw materiaal, namelijk, we gaan ook zien hoe we objecten kunnen verzamelen in lijsten. Dit gebeurt in een nieuwe klasse Ruimte. Om te beginnen gaan we de klasse Punt uit de vorige zitting uitbreiden met nog wat extra functionaliteit. 81
Oef. 4.1 Het Euclidisch vlak kan als volgt worden opgedeeld in vier kwadranten, waarbij punt p 1 in het eerste kwadrant ligt en p 2 in het derde: K 2 K 1 K 2 p 1 K 1 p 2 K 3 K 4 K 3 K 4 Geef de klasse Punt een methode kwadrant(), waarmee kan worden opgevraagd in welk kwadrant het ligt. Punten op de grens tussen twee kwadranten, worden toegekend aan het laagste kwadrant van de twee (bv., een punt op de negatieve y-as zit in K 3 en niet in K 4, aangezien 3 < 4). Oef. 4.2 Geef de klasse Punt een methode afstand(zelf,ander) waarmee je de afstand van een punt tot een ander punt kan bereken. Herinner u hiervoor volgende formule: d((x 1, y 1 ), (x 2, y 2 )) = (x 1 x 2 ) 2 + (y 1 y 2 ) 2 De exponentiatie operator in Python is ** en een vierkantswortel wordt genomen met math.sqrt( ), nadat er eerst een import math gebeurd is. Controleer dat de afstand tussen de twee bovenstaande punten p 1 en p 2 inderdaad is wat hij zou moeten zijn. We gaan niet alleen maar kijken naar individuele punten op zich, maar we gaan ook verschillende punten en later ook andere geometrische objecten groeperen in een groter geheel. Hiervoor introduceren we het concept van een ruimte. Ook dit gaan we voorstellen dmv. een klasse. Oef. 4.3 Maak een klasse Ruimte met een constructor die een lege ruimte aanmaakt. Geef deze klasse een methode voegtoe waarmee een punt aan een ruimte kan worden toegevoegd. Deze punten moeten dan natuurlijk worden bijgehouden in één of andere gegevensstructuur, zoals bijvoorbeeld een lijst. Gebruik deze methode om de punten p 1 en p 2 uit Oef. 3.4 toe te voegen aan een ruimte r. Oef. 4.4 Geef de klasse Ruimte een methode str (zelf), die een string-voorstelling van een ruimte construeert, door de string-voorstellingen van alle objecten in deze ruimte te combineren. Controleer dat de string voorstelling van r inderdaad correct is. 82
Meetkundig gezien is een punt een voorwerp zonder afmetingen. Dit betekent natuurlijk ook dat we onze punten op zich eigenlijk niet kunnen weergeven in SVG afbeeldingen. Daarom maken we nu een klasse Bal, waarmee we een cirkel kunnen voorstellen die zich op een bepaald punt bevindt. Oef. 4.5 Een Bal wordt aangemaakt op basis van een Punt, een kleur (als String) en een straal (als kommagetal). Al deze argumenten zijn optioneel met default waardes van, respectievelijk, de oorsprong, red en 5. Maak ballen b 1 en b 2 aan, op basis van de twee punten p 1 en p 2 uit opgave Oef. 3.4. Oef. 4.6 Voorzie een methode svg die de SVG voorstelling van een bal genereert. Geef ook de klasse Punt een methode svg, die werkt door een Bal te construeren met het punt in kwestie als middelpunt, en hierop de gelijknamige methode op te roepen. Het netto-effect hiervan is, met andere woorden, dat elk punt zal worden voorgesteld als een rode cirkel met straal 5. Oef. 4.7 Geef tot slot ook de klasse Ruimte een methode svg, die de SVG voorstellingen van al de objecten die de ruimte bevat bundelt. Controleer dat de SVG voorstelling van de ruimte r met daarin punten p 1 en p 2 inderdaad is zoals je zou verwachten. Oef. 4.8 Herschrijf de klasse Roos zodat ze in haar constructor een Ruimte aanmaakt met daarin al de Ballen die nodig zijn om deze roos te tekenen. Verander ook de implementatie van de svg methode van Roos, zodat hierin de gelijknamige methode van Ruimte herbruikt wordt. Aangezien deze ballen elkaar natuurlijk overlappen, zal je zorg moeten besteden aan de volgorde waarin ze getekend worden! A.5 Tijd voor actie We gaan nu ons bestaand programma uitbreiden met wat meer actie, door ook bewegende ballen te bekijken. We willen deze functionaliteit toevoegen, maar tegelijk willen we ook dat het mogelijk blijft om onze bestaande klasse Bal te gebruiken voor ballen die niet moeten kunnen bewegen. Hier is, met andere woorden, overerving op zijn plaats. Oef. 5.1 Maak een subklasse BewegendeBal van de klasse Bal. Een BewegendeBal wordt aangemaakt met dezelfde argumenten als een gewonebal, aangevuld met een bijkomend argument snelheid. Maak hiervan het tweede argument, zodat het mogelijk is om een BewegendeBal aan te maken met enkel een startpositie en een initiële snelheid. 83
Oef. 5.2 De snelheid van de bal zullen we voorstellen door een vector, die de verandering van positie per tijdseenheid weergeeft. Aangezien we vectoren wiskundig gezien kunnen identificeren met punten in het vlak, gaan we hievoor geen nieuwe klasse maken, maar gewoon onze bestaande klasse Punt herbruiken. Voorzie in de klasse BewegendeBal een methode tik, waarmee de bal zijn positie kan aanpassen aan het verstrijken van 1 tijdseenheid. Dit doet hij door zijn snelheidvector op te tellen bij zijn huidige positie. De code die deze optelling verwezenlijkt, hoort natuurlijk thuis in de klasse Punt. Als je de bijhorende methode bovendien de naam add geeft, dan kan je deze oproepen door gewoon het symbool + te gebruiken. Oef. 5.3 Voorzie ook alvast een methode sub die de aftrekking van twee punten/vectoren implementeert. Oef. 5.4 Geef nu ook de klasse Ruimte een methode tik, die de gelijknamige methode oproept op alle objecten die zij bevat. Test eens uit dat dit allemaal correct werkt door een ruimte met daarin twee BewegendeBallen een paar tijdseenheden te laten evolueren. Verifiëer dat de correcte coördinaten geproduceerd worden. Oef. 5.5 Nu gaan we ook de evolutie van een Ruimte visualizeren. Geef de methode svg van deze klasse een bijkomend argument n, met default waarde 1. De betekenis hiervan is dat deze methode nu n opeenvolgende <svg>...</svg> elementen zal produceren, waarbij de i de afbeelding de toestand van de ruimte na het verstrijken van i 1 tijdseenheden weergeeft. Controleer dat de resultaten er goed uitzien. Oef. 5.6 Het zou natuurlijk leuker zijn, moesten we de opeenvolgende afbeeldingen kunnen samennemen in een fimpje, in plaats van ze gewoon allemaal onder elkaar te zetten. De module jve bevat een functie movie die je hiervoor kan gebruiken. De volgende code toont bijvoorbeeld een filmpje van een blauwe bal die naar rechts rolt: #!/usr/local/bin/python import jve svg = """ <svg id="frame1" xmlns="http://www.w3.org/2000/svg"> <circle cx="20" cy="20" r="10" fill="blue" /> </svg> <svg id="frame2" xmlns="http://www.w3.org/2000/svg"> <circle cx="30" cy="20" r="10" fill="blue" /> </svg> <svg id="frame3" xmlns="http://www.w3.org/2000/svg"> <circle cx="40" cy="20" r="10" fill="blue" /> </svg> 84
Oef. 5.7 """ print jve.movie(svg) Pas jouw programma aan zodat op dezelfde manier een filmpje getoond wordt: Vervang de instructie jve.http(...) door jve.movie(...). Als je je programma nog eens uitvoert in een terminal, zal je zien dat deze functie jve.movie nog wat meer code rond jouw SVG frames geproduceerd heeft. Deze dient om ervoor te zorgen dat je deze als opeenvolgende frames van een filmpje kan afspelen. Daarvoor moet je eerst deze frames nog als volgt nummeren. Geef elk <svg> element een attribuut id="framei", waarbij i een tellertje is dat loopt van 1 tot n. De hoeveelheid tijd die verloopt tussen twee opeenvolgende frames is trouwens 100ms. Controleer of dit goed werkt. Wat gebeurt er eigenlijk als je evoluerende ruimte naast BewegendeBallen ook nog een gewone Bal bevat? Er zijn twee manieren om dit probleem op te lossen. Zie je welke dit zijn? De beste oplossing is die waarbij de klasse Bal onveranderd kan blijven. Implementeer deze oplossing. A.6 Muren Oef. 6.1 Zorg ervoor dat aan een Ruimte een aantal muren kunnen worden toegevoegd. Een muur is een lijnstuk tussen twee punten. Er moeten twee soorten van muren mogelijk zijn: horizontale (parallel met de X-as) en verticale (parallel met de Y-as). Bij het aanmaken van een muur, moet gecontroleerd worden of de twee punten waartussen de muur loopt wel degelijk onder elkaar (voor een verticale muur) of naast elkaar (voor een horizontale) staan. Als dit niet zo is, moet er een ValueError opgestoken worden. Oef. 6.2 Laat een muur zichzelf tekenen als een SVG rechthoek van 5pt breed breed: <rect x="..." y="..." width="..." height="..." /> Zorg dat een Ruimte ook al zijn muren tekent. Nu hebben we wel muren in onze ruimte staan, maar ze doen nog niets: de ballen vliegen er gewoon doorheen. Om hier iets aan te doen, zullen we bostingen moeten gaan detecteren. Meer bepaald zullen we bij elke 85
tik moeten controleren of de weg die een bal in deze tijdeenheid van plan is om af te leggen niet toevallig een Muur gaan kruisen. We zullen deze functionaliteit implementeren in de methode BewegendeBal.tik. Een probleem hierbij is wel dat een bal in onze huidige implementatie helemaak geen weet heeft van de muren die zich in de hem omringende Ruimte bevinden. Oef. 6.3 Zorg ervoor dat bij het aanmaken van een BewegendeBal ook de omliggende Ruimte wordt meegegeven als (verplicht) argument. In de methode tik kan aan deze Ruimte dan een lijst van alle muren gevraagd worden. Oef. 6.4 Nu gaan we controleren of het traject dat een bal van plan is af te leggen, kruist met een muur. Hiervoor voorzien we een methode botsing(zelf,van,naar) in de klasse Muur. De argumenten van en naar zijn de twee punten waartussen de bal wilt bewegen. Voor de eenvoud beschouwen we een bal hier dus even als een puntmassa,zodat we zijn straal buiten beschouwing laten. We voeren bovendien nog een tweede vereenvoudiging door: je mag ervan uitgaan dat alle muren oneindig lang zijn. Zo wordt het heel eenvoudig om een botsing te detecteren. Laat je methode een booleaanse waarde teruggeven, die zegt of er al dan niet een botsing plaatsvindt. Oef. 6.5 Nu kunnen we al wel botsingen detecteren, maar er moet natuurlijk ook iets gebeuren bij een botsing. Zorg er in eerste instantie voor dat als een bal zou botsen met een muur, zijn positie gewoon blijft wat ze was. Hierdoor zal een bal dus gewoon stoppen met bewegen van zodra hij dicht genoeg bij een muur komt. Test uit of dit correct werkt. Oef. 6.6 Nu gaan we bij een botsing ook de snelheid van een bal aanpassen. Bij een botsing met een horizontale muur, blijft de horizontale component van de snelheid wat hij was, maar de verticale component verandert van teken. Met andere woorden: v x = 1 v x v y = 1 v y Geef de twee Muur klassen elk twee nieuwe attributen vfactor en hfactor waarmee je dan in de klasse BewegendeBal de correcte transformatie kan definiëren. Oef. 6.7 Voeg een nieuw soort bal toe: de KlevendeBal. Deze ballen hebben als eigenschap dat als ze tegen een muur botsen, ze niet weerkaatsen, maar gewoon de horizontale of veticale (al naarlang de oriëntatie van de muur) component van hun snelheid kwijtspelen. Bovendien hebben ze op SVG-afbeeldingen ook een andere kleur dan de gewonen ballen. Als dit allemaal goed werkt, zijn er tot slot nog een aantal uitbreidingen mogelijk: 86
Oef. 6.8 Oef. 6.9 Hou bij de detectie van een botsing wel rekening met het feit dat een muur een eindige lengte heeft. Detecteer, met andere woorden, niet langer botsingen met een rechte, maar wel met een lijnstuk. Dit kan het gemakkelijkst in twee stappen: kijk eerst of het trajact dat de bal wilt afleggen, snijdt met de oneindige rechte, en zo ja, test dan nog eens extra of dit snijpunt binnen het lijnstuk valt of daarbuiten. Het berekenen van dit snijpunt kan als volgt, in de veronderstelling dat we met een verticale muur op X-coördinaat x r te maken hebben. Bekijk de verschilvector v = p 2 p 1, waarbij de bal van p 1 naar p 2 wilt gaan. Herschaal deze vector door hem scalair te vermenigvuldigen met de factor f x = xr x1 x 2 x 1. Als we nu deze herschaalde vector optellen bij p 1, dan hebben we het snijpunt. In plaats van de positie van een bal die zou gaan botsen te laten blijven wat ze was, zouden we eigenlijk de bal moeten laten verdervliegen tot hij net de muur raakt. Laat je methode botsing bij het detecteren van een botsing het gevonden snijpunt teruggeven in plaats van True. Laat je bal nu verder vliegen tot op dit snijpunt, of eigenlijk nog beter tot op een afstand gelijk aan de straal van de bal van dit snijpunt. A.7 Krachten Een ruimte waarin alles zich aan constante snelheid beweegt, is natuurlijk nogal saai. Oef. 7.1 Maak een klasse Kracht met een methode werkinop(zelf,object). Deze methode past de snelheid van het object in kwestie aan door er, bij elke tik, een bepaalde versnelling bij op te tellen. De waarde van deze versnelling wordt meegegeven bij het aanmaken van de Kracht. Zorg ervoor dat je aan een ruimte een aantal krachten kan toevoegen. Test dit eens uit met een kracht die een horizontale versnelling heeft en een kracht die een verticale versnelling heeft eerst afzonderlijk en dan samen. Oef. 7.2 Geef al de ballen een massa. Maak een subklasse Zwaartekracht van de klasse Kracht. De werkinop methode van deze subklasse moet gebruik maken van de gelijknamige methode uit de superklassen, maar zet eerst de versnelling gelijk aan 0.981 m, waarbij m de massa is van de bal waarop de kracht zal inwerken. Oef. 7.3 Voeg een nieuw soort kracht toe, SelectieveKracht, die enkel maar van toepassing is op de KlevendeBallen, maar niet op de gewone. 87