Zo kun je programmeren in Python - Deel 7

Door: koen-vervloesem | 02 januari 2021 09:34

script, laptop, programmeren, code
How To

In de vorige les leerde je hoe je met functies en modules wat meer structuur brengt in je Python-programma’s. Maar structuur gaat niet alleen over wat je doet, maar ook over welke data je verwerkt. In deze les leren we daarom hoe je data structureert door je eigen datatypes te maken: klassen.

Wil je meer leren over programmeren? Bekijk dan onze Cursus: programmeren in Phyton (boek & online cursus).

Tot nu toe bestaan onze Python-programma’s uit heel wat regels code die de standaard datatypes van Python verwerken. Maar als je programma wat complexer wordt, verwerk je misschien data met een speciale structuur en bijbehorende functionaliteit. Dan wordt het tijd om een klasse te definiëren, waarmee je een nieuw datatype creëert. Daarna kun je objecten van deze klasse aanmaken en die verwerken zoals je met objecten van de standaardklassen van Python werkt.

Een klasse definiëren

Stel dat we een programma schrijven waarin we berekeningen op punten in een tweedimensionaal vlak willen uitvoeren. We maken dan de volgende klasse:

import math

class Point:

def __init__(self, x=0, y=0):
self.x = x
self.y = y

def __repr__(self):
return 'Point({}, {})'.format(self.x, self.y)

def displacement(self, other_point):
return Point(other_point.x - self.x, other_point.y - self.y)

def distance(self, other_point):
relative_position = self.displacement(other_point)
return math.sqrt(relative_position.x**2 + relative_position.y**2)

In het begin importeren we de module math omdat we de functie sqrt nodig hebben om de vierkantswortel uit te rekenen. Daarna definiëren we een klasse Point met class Point:. Alles wat zich in dat blok bevindt, maakt onderdeel uit van de klasse.

Objecten aanmaken

Negeer even wat er allemaal in de definitie van de klasse staat, sla bovenstaande code op in het bestand point.py en voer het uit in Thonny. In de terminal kun je nu objecten van het type Point aanmaken en er berekeningen mee uitvoeren:

>>> p = Point()
>>> p
Point(0, 0)
>>> p1 = Point(5, 7)

>>> p2 = Point(2, 1)
>>> p1.distance(p2)
6.708203932499369

>>> p2.x = 0

>>> p2

Point(0, 1)

>>> p1.distance(p2)

7.810249675906654

Methodes en objectvariabelen

Dan is het nu tijd om in de definitie van de klasse te duiken. We zien in de klasse vier functies, maar in een klasse noemen we ze methodes. Een methode is dus een functie die aan een klasse is gekoppeld. Een opvallend verschil tussen een functie buiten een klasse en een methode is dat de eerste parameter van een methode altijd self is: dat is een variabele die verwijst naar het object zelf waarop je de methode aanroept.

Een object kan ook variabelen hebben (objectvariabelen of instance variables in het Engels). In onze klasse zijn dat self.x en self.y. De methode __init__ heeft een speciale betekenis: hiermee initialiseer je het object. Meestal geef je in deze methode de objectvariabelen een waarde. Omdat een punt x- en y-coördinaten heeft, bestaat onze initialisatie uit self.x = x en self.y = y: we initialiseren beide objectvariabelen met de waarden die je als argument aan __init__ doorgeeft. Als je dus een object p1 aanmaakt met p1 = Point(5, 7), krijgt de objectvariabele self.x van p1 de waarde 5 en self.y de waarde 7.

Overigens gebruiken we in de definitie van __init__ iets wat je voor alle functies kunt doen: standaardwaarden instellen. Met x=0 en y=0 in de lijst met parameters geven we aan dat x en y de waarde 0 krijgen als je bij het initialiseren van een Point geen argumenten doorgeeft. Daarom heeft p = Point() de coördinaten (0, 0), wat je kunt controleren door p.x en p.y op te vragen, of zoals we in ons voorbeeld deden door gewoon p in de terminal in te voeren.

Dit laatste werkt omdat we de speciale methode __repr__ hebben gedefinieerd. Als je die functie voor een klasse definieert, moet je daarin een string teruggeven die het object voorstelt alsof je het in Python-code aanmaakt. Als we deze functie niet hadden gedefinieerd, kregen we in de terminal iets te zien als <__main__.Point object at 0x7f27e24ad940>, wat je alleen leert dat het om een Point-object gaat, en niets meer.

In de methode distance berekenen we de afstand tussen het punt zelf en een ander punt met behulp van de stelling van Pythagoras. Daarvoor berekenen we eerst de relatieve positie van het andere punt ten opzichte van het punt zelf. Dat doen we door een andere methode van het punt aan te roepen: displacement. Omdat ook deze methode tot het object zelf behoort, roep je dit aan als: self.displacement. Merk op dat we in de methode displacement een Point-object teruggeven. Dat mag gewoon!

De klasse veralgemenen

Door met klassen te werken, wordt je code heel wat gestructureerder en leesbaarder, maar na een tijdje zul je merken dat sommige klassen speciale gevallen zijn van andere klassen. Zo bestaan er tweedimensionale punten, driedimensionale en n-dimensionale. Kunnen we geen klasse maken die het algemene n-dimensionale geval afhandelt en dan specifieke klassen voor twee- en driedimensionale punten zonder alle methodes meerdere keren te moeten definiëren? Ja dat kan in Python, met iets wat we overerving van klassen noemen.

Laten we eerst onze klasse Point veralgemenen tot het n-dimensionale geval:

import math

class Point:

def __init__(self, *coordinates):
self.coordinates = list(coordinates)

def __repr__(self):
return 'Point(' + ', '.join([str(co) for co in self.coordinates]) + ')'

def displacement(self, other_point):
return Point(*[a-b for a, b in zip(other_point.coordinates, self.coordinates)])
def distance(self, other_point):

relative_position = self.displacement(other_point)
return math.sqrt(sum([i**2 for i in relative_position.coordinates]))

Zoals je ziet, lijkt die heel erg op het tweedimensionale geval. Een subtiel verschil is dat we de coördinaten aan __init__ niet meer als x en y kunnen opgeven, maar een willekeurig aantal argumenten doorgeven. Dat geven we aan met de asterisk (*) voor de parameter coordinates. Dat willekeurig aantal argumenten zetten we dan om naar een lijst en kennen we dan toe aan de objectvariabele coordinates.

In de methode __repr__ gebruiken we list comprehension (zie deel 4 van de cursus) om alle afzonderlijke coördinaten uit de lijst coordinates te halen en die naar een string om te zetten. Dat is nodig omdat we al die strings daarna met de functie join aan elkaar plakken, met een komma ertussen.

Zip

Ook in de twee andere methodes maken we gebruik van list comprehension om door alle coördinaten te gaan. De lastigste methode om te begrijpen is displacement. We willen daar van de twee punten telkens de overeenkomende coördinaten van elkaar aftrekken en van het resultaat een Point-object maken. Met de functie zip maken we een lijst van tupels van de overeenkomende coördinaten. Met een list comprehension maken we dan een lijst met het verschil van de twee elementen van het tupel voor alle tupels in die lijst. En uiteindelijk zetten we die lijst met * om naar een willekeurig aantal argumenten dat we aan Point doorgeven.

Als dit allemaal wat abstract lijkt, probeer dit dan eens uit met twee korte lijsten coördinaten, zoals [1, 4, 2] en [3, 4, 1]:

>>> c1 = [1, 4, 2]
>>> c2 = [3, 4, 1]

>>> koppels = zip(c1, c2)
>>> list(koppels)

[(1, 3), (4, 4), (2, 1)]
>>> [a-b for a, b in koppels]
[-2, 0, 1]

Overerving

We hebben nu een heel algemene klasse Point waarmee we punten in alle mogelijke dimensies kunnen voorstellen en er berekeningen op kunnen uitvoeren. Maar in de meeste toepassingen hebben we slechts twee of drie dimensies nodig en dan willen we weer naar de coördinaten met x en y kunnen verwijzen zoals in de eerste klasse in deze cursus. Hoe maken we nu klassen Point2D en Point3D zonder de algemene code van Point te herhalen? Dat doen we door deze klassen te laten overerven van Point. Point noemen we dan de superklasse en de klassen die ervan overerven zijn de subklassen. We zouden dat als volgt kunnen doen:

class Point2D(Point):

def __init__(self, x=0, y=0):
self.x = x

self.y = y
Point.__init__(self, x, y)

class Point3D(Point):

def __init__(self, x=0, y=0, z=0):
self.x = x
self.y = y
self.z = z
Point.__init__(self, x, y, z)

We zien hier hoe we de klasse Point2D definiëren, met tussen haakjes de superklasse, Point. Daarna definiëren we de methode __init__ zoals we dat in het begin van deze les deden, met als enige verschil dat we op het einde van de methode met Point.__init__(self, x, y) de methode __init__ van de superklasse aanroepen met als argumenten de x- en y-coördinaten. Zo zorgen we dat de superklasse zijn lijst met coördinaten correct initialiseert.

Je kunt nu Point2D-objecten maken en er de methodes displacement en distance op uitvoeren, zonder dat we die in de klasse Point2D hebben gedefinieerd. Point2D erft immers alle methodes en objectvariabelen van zijn superklasse over:

>>> p = Point2D(1, 2)
>>> p
Point(1, 2)
>>> p.coordinates

[1, 2]

>>> p.x

1
>>> p2 = Point2D(3, 5)
>>> p.distance(p2)
3.605551275463989

Als we dus een Point2D-object aanmaken, wordt de methode __init__ van Point2D aangeroepen, omdat we die in Point2D hebben gedefinieerd. Op het einde van die methode roepen we de methode van de superklasse aan. Als we de objectvariabelen x of y gebruiken, worden die variabelen van Point2D gebruikt, omdat we ze in die subklasse hebben gedefinieerd. Maar als we naar de objectvariabele coordinates verwijzen of de methodes __repr__, displacement of distance aanroepen, worden de versies in de klasse Point gebruikt omdat we die niet in Point2D hebben gedefinieerd. Op deze manier is overerving een handig hulpmiddel om herhaling in je code te vermijden.

Samenvatting

In deze les heb je geleerd hoe je zelf datatypes definieert door klassen aan te maken met methodes en objectvariabelen. Je hebt ook wat abstract leren denken bij het programmeren: hoe veralgemeen je code, bijvoorbeeld om op een willekeurig aantal coördinaten en met een willekeurig aantal argumenten voor een functie te werken? Die abstractie hebben we ook doorgetrokken door met superklassen en subklassen te werken, zodat je vermijdt dat je code moet herhalen als je klassen definieert die op elkaar lijken. Je hebt ook eigenschappen leren definiëren. De complexiteit van je Python-programma’s wordt ondertussen al hoog genoeg om even een stapje terug te zetten. In de volgende les leer je je code te documenteren, te debuggen en te testen. Ook leer je meer over Python uit te zoeken door de helpfunctie te gebruiken.

Opdracht

Onze code is een verdienstelijke eerste poging, maar hij klopt nog niet. Als je immers p.x verandert, blijft p.coordinates zijn originele waarde behouden:

>>> p = Point2D(1, 2)
>>> p
Point(1, 2)
>>> p.coordinates
[1, 2]
>>> p.x = 3
>>> p.x
3
>>> p
Point(1, 2)
>>> p.coordinates
[1, 2]

Maak de methodes get_x en set_x voor de klasse Point2D die de eerste coördinaat van de superklasse teruggeven dan wel de eerste coördinaat instellen. Met de Python-opdracht x = property(get_x, set_x) stel je x als eigenschap in, zodat je methodes get_x en set_x worden opgeroepen als je p.x opvraagt of een waarde geeft. Daarmee kun je ons probleem oplossen. Doe hetzelfde voor y en bij Point3D ook voor z.

Uitwerking

class Point2D(Point):

def __init__(self, x=0, y=0):
Point.__init__(self, x, y)
self.x = x
self.y = y

def get_x(self):
return self.coordinates[0]

def set_x(self, x):
self.coordinates[0] = x
x = property(get_x, set_x)

def get_y(self):
return self.coordinates[1]

def set_y(self, y):
self.coordinates[1] = y
y = property(get_y, set_y)

Belangrijk is hier dat je in __init__ eerst __init__ van de superklasse oproept voordat je self.x = x zet. Die laatste roept immers set_x aan, die verwijst naar self.coordinates[0], wat pas door de initialisatie van de superklasse gedefinieerd is.

Cheatsheet

Attribuut: een eigenschap (methode of objectvariabele) van een klasse.
Klasse: een datatype.
Methode: een functie die aan een klasse gekoppeld is.

Object: een concreet voorbeeld van een klasse.
Objectvariabele: een variabele die aan een object gekoppeld is.
Overerving: een klasse die een speciaal geval van een andere klasse is.
Subklasse: een klasse die van een andere klasse overerft.
Superklasse: een klasse waarvan een andere klasse overerft.

1 Reactie(s) op: Zo kun je programmeren in Python - Deel 7

  • Om te reageren moet je ingelogd zijn. Nog geen account? Registreer je dan en praat mee!
  • 17 januari 2021 16:59 jmcvdb@me.com
    Didactisch niet zo sterk om in een opdracht een nieuwe functie te introduceren. Dan moet je als cursist eerst gaan uitzoeken wat die functie betekent en hoe die werkt. Afgezien van deze kritische noot is de reeks artikelen een aardige introductie in Python.
    Wanneer je een reactie plaatst ga je akoord
    met onze voorwaarden voor reacties.

Wanneer je een reactie plaatst ga je akoord
met onze voorwaarden voor reacties.