Ga naar hoofdinhoud

Python-descriptoren: tutorial

Leer wat Python-descriptoren zijn, wanneer je ze moet gebruiken en waarom je ze zou gebruiken.
· 13 min lezen

Verken met AI

Python-descriptoren, of kortweg descriptoren, geven je een krachtige techniek om herbruikbare code te schrijven die je tussen klassen kunt delen. Ze lijken misschien op overerving, maar dat zijn ze technisch gezien niet. Het is een generieke manier om toegang tot attributen te onderscheppen. Descriptoren zijn het mechanisme achter properties, static methods, class methods, super-methods, enzovoort.

Descriptoren zijn toegevoegd in Python 2.2 en worden sindsdien gezien als iets magisch dat traditionele klassen een nieuwe stijl heeft gegeven. Het zijn klassen waarmee je beheerde eigenschappen in een andere klasse kunt maken. Concreet implementeren ze de interface voor de methoden __get__(), __set__() en __delete__(), wat ze om veel redenen interessant maakt. Denk bijvoorbeeld aan class- en property-decorators die je eerder in Python bent tegengekomen.

Eenvoudig gezegd: een klasse die voor een object de methode __get__(), __set()__ of __delete()__ implementeert, heet een "descriptor". Vrij naar de officiële Python-documentatie: een descriptor is een objectattribuut met binding behavior, waarbij de attribuuttoegang is overschreven door methoden uit het descriptorprotocol. Die methoden zijn __get__(), __set__() en __delete__() (Bron).

Binding behavior in de context van descriptoren betekent dat je vastlegt hoe een waarde kan worden gezet, opgevraagd (get) of verwijderd voor een bepaalde variabele, object of dataset. Deze hele interactie wordt gebonden aan dat stukje data, omdat het alleen geldt voor de data waarop je het hebt ingesteld, en dus alleen aan dat specifieke deel van de data is gekoppeld.

Descriptoren kunnen verder worden onderverdeeld in data- en niet-data-descriptoren. Als de descriptor die je schrijft alleen de methode __get__() heeft, is het een niet-data-descriptor; een implementatie die ook __set__() en __delete__() bevat, heet een data-descriptor. Niet-data-descriptoren zijn alleen leesbaar, terwijl data-descriptoren zowel lees- als schrijfbaar zijn.

Belangrijk om te onthouden: descriptoren worden toegewezen aan een klasse, niet aan een instantie van een klasse. Het aanpassen van de klasse overschrijft of verwijdert de descriptor zelf, in plaats van diens code te triggeren (IBM Developer).

Tot slot: de descriptorklasse is niet beperkt tot alleen deze drie methoden; hij kan ook andere attributen bevatten naast de methoden get, set en delete.

Laten we de methoden get, set en delete in meer detail bekijken, geïnspireerd door deze pagina van IBM Developer:

  • self is de instantie van de descriptor die je maakt (Real Python).
  • object is de instantie van het object waaraan je descriptor is gekoppeld (Real Python).
  • type is het type van het object waaraan de descriptor is gekoppeld (Real Python).
  • value is de waarde die wordt toegewezen aan het attribuut van de descriptor. get(self, object, type) set(self, object, value) delete(self, object)
  • __get__() benadert het attribuut of wanneer je informatie wilt opvragen. Het retourneert de waarde van het attribuut of werpt de uitzondering AttributeError als een gevraagd attribuut niet aanwezig is.

  • __set__() wordt aangeroepen bij een toewijzing aan een attribuut die de waarde van een attribuut zet. Retourneert niets, maar kan de uitzondering AttributeError werpen.

  • __delete__() stuurt een verwijderoperatie aan, d.w.z. wanneer je het attribuut van een object wilt verwijderen. Retourneert niets.

Laten we nu het doel van descriptoren begrijpen met behulp van enkele voorbeelden!

Doel van descriptoren

Laten we een klasse car definiëren met drie attributen: make, model en fuel_cap. Je gebruikt de methode __init__() om de attributen van de klasse te initialiseren. Vervolgens gebruik je de magische functie __str__(), die simpelweg de uitvoer retourneert van de drie attributen die je aan de klasse doorgeeft bij het maken van het object.

Let op: de methode __str__() retourneert de stringrepresentatie van het object. Deze wordt aangeroepen wanneer print() of de functie str() wordt gebruikt op een object van de klasse.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
BMW model X7 with a fuel capacity of 40 ltr.

Zoals je in de bovenstaande uitvoer ziet, ziet alles er prima uit!

Laten we nu de brandstofcapaciteit van de auto wijzigen naar min 40.

car2 = Car("BMW","X7",-40)
print(car2)
BMW model X7 with a fuel capacity of -40 ltr.

Wacht even, daar klopt iets niet, toch? De brandstofcapaciteit van een auto kan nooit negatief zijn. Toch accepteert Python dit zonder foutmelding. Dat komt omdat Python een dynamische programmeertaal is die geen expliciete typecontrole afdwingt.

Om dit te voorkomen, voegen we een if-voorwaarde toe in de methode __init__() en controleren we of de ingevoerde brandstofcapaciteit geldig is. Als de ingevoerde brandstofcapaciteit ongeldig is, werp dan een ValueError-exceptie.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car("BMW","X7",-40)
print(car2)
----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-22-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
      2 print(car2)


<ipython-input-20-1e154783588d> in __init__(self, make, model, fuel_cap)
      5         self.fuel_cap = fuel_cap
      6         if self.fuel_cap < 0:
----> 7             raise ValueError("Fuel Capacity can never be less than zero")
      8
      9     def __str__(self):


ValueError: Fuel Capacity can never be less than zero

Uit de bovenstaande uitvoer kun je opmaken dat alles voorlopig goed werkt, omdat het programma een ValueError werpt als de brandstofcapaciteit onder nul ligt.

Er is echter nog een mogelijk probleem: wat als de brandstofcapaciteit die je invoert een float of een string is? Niet alleen de brandstofcapaciteit, maar ook de make en model van de auto kunnen een geheel getal zijn. In al deze gevallen zal het programma geen exceptie werpen.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car(-40,"X7",40)
print(car2)
-40 model X7 with a fuel capacity of 40 ltr.

Om ook het bovenstaande geval af te vangen, zou je kunnen denken aan nog een if-voorwaarde of het gebruik van de methode isinstance voor typecontrole.

Deze keer gebruiken we de ingebouwde methode isinstance om de fout af te handelen.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if isinstance(self.make, str):
            print(self.make)
        else:
            raise ValueError("Make of the car can never be an integer")

        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
BMW
BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car(-40,"X7",40)
print(car2)
----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-34-75b08cba454f> in <module>
----> 1 car2 = Car(-40,"X7",40)
      2 print(car2)


<ipython-input-31-175690bf3b98> in __init__(self, make, model, fuel_cap)
      7             print(self.make)
      8         else:
----> 9             raise ValueError("Make of the car can never be an integer")
     10
     11         if self.fuel_cap < 0:


ValueError: Make of the car can never be an integer

Mooi! Je hebt ook deze fout kunnen afhandelen.

Maar wat als je later expliciet de brandstofcapaciteit wilt wijzigen naar min 40? In dat geval werkt het niet, omdat de typecontrole alleen in de methode __init__() één keer wordt gedaan. Zoals je weet is __init__() een constructor en wordt deze slechts één keer aangeroepen wanneer je een object van de klasse maakt. Daarom zal de aangepaste typecontrole later falen.

Laten we dit begrijpen met een voorbeeld.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if isinstance(self.make, str):
            print(self.make)
        else:
            raise ValueError("Make of the car can never be an integer")

        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
BMW
BMW model X7 with a fuel capacity of 40 ltr.
car2.make = -40
print(car2)
-40 model X7 with a fuel capacity of 40 ltr.

En kijk: je hebt de typecontrole kunnen omzeilen.

Stel je nu voor dat je veel meer attributen hebt, zoals verbruik, prijs, accessoires, enz., die ook typecontrole vereisen, en dat je bovendien wilt dat sommige van deze attributen alleen leestoegang hebben. Dat zou toch best vervelend zijn?

Om al deze problemen aan te pakken, komen Python-descriptoren te hulp!

Zoals je hierboven hebt geleerd, heten klassen die de magische methoden __get__(), __set()__ of __delete()__ voor een object van het descriptorprotocol implementeren, descriptoren. Ze geven je ook extra controle over hoe een attribuut zich moet gedragen, bijvoorbeeld of het lees- of schrijfrechten moet hebben.

Laten we nu het bovenstaande voorbeeld uitbreiden door de Python-descriptormethoden toe te voegen.

class Descriptor:
    def __init__(self):
        self.__fuel_cap = 0
    def __get__(self, instance, owner):    
        return self.__fuel_cap
    def __set__(self, instance, value):
        if isinstance(value, int):
            print(value)
        else:
            raise TypeError("Fuel Capacity can only be an integer")

        if value < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

        self.__fuel_cap = value

    def __delete__(self, instance):
        del self.__fuel_cap
class Car:
    fuel_cap = Descriptor()
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
40
BMW model X7 with a fuel capacity of 40 ltr.

Maak je geen zorgen als de klasse Descriptor wat abstract lijkt. Laten we het opsplitsen in kleine stukjes en begrijpen wat elke methode doet.

  • De methode __init__() van de klasse Descriptor zet een lokale variabele __fuel_cap op nul. De dunder of dubbele underscore aan het begin betekent dat de variabele privé is. Die dubbele underscore is hier alleen bedoeld om het brandstofcapaciteitsattribuut van de klasse Descriptor te onderscheiden van dat van de klasse Car.
  • Zoals je nu weet, gebruik je de methode __get__() om attributen op te halen en hij retourneert de variabele brandstofcapaciteit. Hij neemt drie argumenten: het descriptorobject, de instantie van de klasse die het descriptorobject bevat, dus car2, en tenslotte de owner, de klasse waartoe de instantie behoort, dus de klasse Car. In deze methode retourneer je simpelweg de waarde van het attribuut, dus fuel_cap, waarvan de waarde is gezet in de methode __set__().
  • De methode __set__() wordt aangeroepen wanneer de waarde van het attribuut wordt gezet en, anders dan __get__(), retourneert hij niets. Hij heeft twee argumenten naast het descriptorobject zelf: de instance, hetzelfde als bij __get__(), en het argument value, de waarde die je aan het attribuut toekent. In deze methode controleer je of de waarde die je aan het attribuut fuel_cap wilt toekennen een geheel getal is. Zo niet, dan werp je een TypeError. Vervolgens controleer je in dezelfde methode ook of de waarde kleiner is dan nul; als dat zo is, werp je nog een exceptie, dit keer een ValueError. Na de controles werk je het attribuut fuel_cap bij naar de waarde.
  • Tot slot de methode __delete__(), die wordt aangeroepen wanneer het attribuut van een object wordt verwijderd en, net als __set__(), niets retourneert.

De klasse Car blijft verder hetzelfde als voorheen. De enige wijziging is dat je de instantie fuel_cap van de klasse Descriptor() toevoegt. Let op: zoals eerder vermeld moet de instantie van de descriptorklasse aan een klasse worden toegevoegd als klasseattribuut en niet als instantieattribuut.

Zodra je in de methode __init__() de lokale variabele fuel_cap toewijst aan de instantie fuel_cap, wordt de methode __set__() van de klasse Descriptor aangeroepen.

Laten we nu de brandstofcapaciteit naar een negatieve waarde veranderen en kijken of het programma een ValueError-exceptie werpt.

car2 = Car("BMW","X7",-40)
print(car2)
-40



----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-115-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
      2 print(car2)


<ipython-input-107-3e1f3e97d3c7> in __init__(self, make, model, fuel_cap)
      4         self.make = make
      5         self.model = model
----> 6         self.fuel_cap = fuel_cap
      7
      8     def __str__(self):


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
     11
     12         if value < 0:
---> 13             raise ValueError("Fuel Capacity can never be less than zero")
     14
     15         self.__fuel_cap = value


ValueError: Fuel Capacity can never be less than zero

Als je je herinnert, hier mislukte de typecontrole toen je later de attribuutwaarde wijzigde naar een negatief getal, omdat de typecontrole slechts één keer gold in de methode __init__(). Laten we de waarde van fuel_cap nu bijwerken naar een string en kijken of dat een fout oplevert.

car2.fuel_cap = -1
-1



----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-120-dea9dbe96ebe> in <module>
----> 1 car2.fuel_cap = -1


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
     11
     12         if value < 0:
---> 13             raise ValueError("Fuel Capacity can never be less than zero")
     14
     15         self.__fuel_cap = value


ValueError: Fuel Capacity can never be less than zero
car2.fuel_cap = "BMW"
----------------------------------------

TypeErrorTraceback (most recent call last)

<ipython-input-121-0b316a9872c6> in <module>
----> 1 car2.fuel_cap = "BMW"


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
      8             print(value)
      9         else:
---> 10             raise TypeError("Fuel Capacity can only be an integer")
     11
     12         if value < 0:


TypeError: Fuel Capacity can only be an integer

Perfect! Zoals je ziet werkt het ook wanneer je later het attribuut brandstofcapaciteit bijwerkt.

Er is echter een klein probleem met descriptoren: wanneer je een nieuwe of tweede instantie van de klasse maakt, wordt de waarde van de vorige instantie overschreven. De reden is dat descriptoren aan de klasse zijn gekoppeld en niet aan de instantie.

Laten we dit begrijpen met het onderstaande voorbeeld.

car3 = Car("BMW","X7",48) #created a new instance 'car3' with different values
48

Wanneer je de instantie car2 afdrukt, zul je zien dat de waarden zijn overschreven door car3.

print(car2)
BMW model X7 with a fuel capacity of 48 ltr.

Conclusie

Gefeliciteerd met het afronden van de tutorial.

Deze tutorial was bedoeld voor iedereen die zich comfortabel voelt met Python en het gevorderde niveau wil beheersen. Als goede oefening kun je uitzoeken hoe je het probleem van het overschrijven van instanties, dat we vandaag bespraken, zou kunnen oplossen.

Stel gerust vragen over deze tutorial in de reacties hieronder.

Wil je meer leren over Python? Volg de volledige skill track Python Programming van DataCamp.

Bekijk ook onze Python Functions-tutorial.

Onderwerpen

Gerelateerd