Kurs
Python-Descriptors, oder allgemeiner Descriptors, sind eine mächtige Technik, um wiederverwendbaren Code zu schreiben, der zwischen Klassen geteilt werden kann. Auf den ersten Blick erinnern sie an Vererbung, technisch sind sie das aber nicht. Vielmehr bieten sie einen allgemeinen Mechanismus, um den Zugriff auf Attribute abzufangen. Descriptors stecken hinter Properties, statischen Methoden, Klassenmethoden, super-Methoden und mehr.
Descriptors wurden in Python 2.2 eingeführt und galten seither als „magisch“, weil sie traditionellen Klassen zu einem neuen Stil verholfen haben. Es sind Klassen, mit denen du in einer anderen Klasse verwaltete Eigenschaften umsetzt. Konkret implementieren sie die Schnittstelle für die Methoden __get__(), __set__() und __delete__() – und genau das macht sie aus vielen Gründen interessant. Ein Beispiel dafür sind Klassen- und Property-Decorator, die du in Python sicher schon gesehen hast.
Einfach gesagt: Eine Klasse, die für ein Objekt __get__(), __set()__ oder __delete()__ implementiert, nennt man „Descriptor“. Laut offizieller Python-Dokumentation ist ein Descriptor ein Objektattribut mit Bindungsverhalten, dessen Attributzugriff durch Methoden des Descriptor-Protokolls überschrieben wurde. Diese Methoden sind __get__(), __set__() und __delete__() (Quelle).
Bindungsverhalten bedeutet bei Descriptors, dass geregelt wird, wie ein Wert für eine Variable, ein Objekt oder einen Datensatz gesetzt, ausgelesen (get) oder gelöscht werden kann. Diese gesamte Interaktion ist an genau dieses Datenelement gebunden, sie gilt also nur für die Daten, auf die du den Descriptor angewendet hast.
Descriptors lassen sich in Data- und Non-Data-Descriptors einteilen. Hat dein Descriptor nur eine __get__()-Methode, ist er ein Non-Data-Descriptor; implementiert er zusätzlich __set__() und __delete__(), spricht man von einem Data-Descriptor. Non-Data-Descriptors sind nur lesbar, Data-Descriptors sind les- und schreibbar.
Wichtig: Descriptors werden an einer Klasse definiert, nicht an deren Instanz. Änderst du die Klasse, überschreibst oder löschst du den Descriptor selbst – sein Code wird dabei nicht ausgelöst (IBM Developer).
Die Descriptor-Klasse ist übrigens nicht auf diese drei Methoden beschränkt. Sie kann auch beliebige weitere Attribute enthalten – neben get, set und delete.
Schauen wir uns die Methoden get, set und delete genauer an – inspiriert von dieser Seite bei IBM Developer:
selfist die Instanz des von dir erstellten Descriptors (Real Python).objectist die Instanz des Objekts, an das dein Descriptor angehängt ist (Real Python).typeist der Typ der Klasse, an die der Descriptor gebunden ist (Real Python).valueist der Wert, der dem Attribut des Descriptors zugewiesen wird. get(self, object, type) set(self, object, value) delete(self, object)-
__get__()greift auf das Attribut zu bzw. wird verwendet, wenn du Informationen auslesen möchtest. Es gibt den Attributwert zurück oder wirft eine AttributeError-Exception, wenn das angefragte Attribut nicht existiert. -
__set__()wird bei einer Attributzuweisung aufgerufen und setzt den Wert eines Attributs. Es gibt nichts zurück, kann aber eine AttributeError-Exception auslösen. -
__delete__()steuert eine Löschoperation, also wenn du das Attribut von einem Objekt entfernen möchtest. Es gibt nichts zurück.
Jetzt schauen wir uns den Zweck von Descriptors anhand einiger Beispiele an.
Zweck von Descriptors
Definieren wir eine Klasse car mit drei Attributen: make, model und fuel_cap. Mit der Methode __init__() initialisieren wir die Attribute. Außerdem nutzen wir die Magic-Methode __str__(), die einfach die drei Attribute ausgibt, die wir beim Erstellen des Objekts an die Klasse übergeben.
Beachte: Die Methode __str__() liefert die String-Repräsentation des Objekts. Sie wird aufgerufen, wenn du print() oder str() auf eine Instanz anwendest.
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.
Sieht gut aus, oder?
Jetzt ändern wir die Tankkapazität auf minus 40.
car2 = Car("BMW","X7",-40)
print(car2)
BMW model X7 with a fuel capacity of -40 ltr.
Moment – da stimmt doch was nicht. Die Tankkapazität kann nie negativ sein. Python akzeptiert den Wert aber fehlerfrei. Der Grund: Python ist eine dynamische Sprache und führt keine explizite Typprüfung durch.
Um das zu vermeiden, fügen wir im __init__() eine if-Bedingung hinzu, die prüft, ob die angegebene Tankkapazität gültig ist. Ist sie ungültig, werfen wir eine ValueError-Exception.
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
Wie du siehst, funktioniert das – das Programm wirft eine ValueError, wenn die Tankkapazität unter null liegt.
Es gibt aber noch ein Problem: Was, wenn die fuel capacity eine Gleitkommazahl oder ein String ist? Oder wenn auch make und model Ganzzahlen sind? In all diesen Fällen würde unser Programm aktuell keinen Fehler melden.
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.
Um auch diesen Fall abzufangen, könnten wir eine weitere if-Bedingung ergänzen oder die Typprüfung mit isinstance lösen.
Diesmal verwenden wir die eingebaute Funktion isinstance.
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
Super – auch diesen Fehler hast du abgefangen.
Doch was passiert, wenn du die Tankkapazität später bewusst auf minus 40 setzt? Das greift nicht, denn die Typprüfung findet nur einmal im __init__() statt. Wie du weißt, ist __init__() der Konstruktor und wird nur beim Erzeugen des Objekts aufgerufen. Danach greift deine benutzerdefinierte Prüfung nicht mehr.
Hier ein Beispiel:
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.
Und schon ist die Typprüfung ausgehebelt.
Stell dir vor, du hast noch weitere Attribute wie Verbrauch, Preis, Ausstattung usw., die ebenfalls eine Typprüfung brauchen – und einige davon sollen nur lesbar sein. Ziemlich mühsam, das alles manuell zu verwalten.
Genau hier kommen Python-Descriptors ins Spiel!
Wie oben gelernt: Jede Klasse, die die Magic-Methoden __get__(), __set()__ oder __delete()__ für das Descriptor-Protokoll implementiert, ist ein Descriptor. Damit steuerst du zusätzlich, ob ein Attribut z. B. nur gelesen oder auch geschrieben werden darf.
Erweitern wir nun das obige Beispiel und fügen die Descriptor-Methoden hinzu.
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.
Wenn dir die Klasse Descriptor noch kryptisch vorkommt, zerlegen wir sie in kleine Teile:
- Die
__init__()-Methode der Descriptor-Klasse setzt eine lokale Variable__fuel_capauf Null. Das doppelte Unterstrich-Präfix („Dunder“) markiert die Variable als privat. Es dient hier auch dazu, die Tankkapazität des Descriptors von der gleichnamigen Eigenschaft der Car-Klasse zu unterscheiden.
- Wie du jetzt weißt, liest
__get__()Attribute aus und gibt die Tankkapazität zurück. Sie nimmt drei Argumente entgegen: das Descriptor-Objekt, die Instanz der Klasse, die den Descriptor enthält (hiercar2), und den Owner, also die Klasse, zu der die Instanz gehört (die KlasseCar). In dieser Methode gibst du einfach den Wert des Attributsfuel_capzurück, der zuvor in__set__()gesetzt wurde.
__set__()wird aufgerufen, wenn dem Attribut ein Wert zugewiesen wird, und gibt – anders als__get__()– nichts zurück. Neben dem Descriptor-Objekt selbst erhält es die Instanz (wie bei__get__()) und den zuzuweisenden Wert. Hier prüfst du, ob der Wert fürfuel_capeine Ganzzahl ist. Wenn nicht, wird eineTypeError-Exception geworfen. Anschließend prüfst du, ob der Wert kleiner als null ist; falls ja, folgt eineValueError. Nach den Prüfungen setzt dufuel_capauf den Wert.
- Die Methode
__delete__()wird aufgerufen, wenn das Attribut von einem Objekt gelöscht wird. Wie__set__()gibt sie nichts zurück.
Die Klasse Car bleibt im Wesentlichen unverändert. Neu ist nur, dass du die Instanz fuel_cap der Klasse Descriptor() als Klassenattribut hinzufügst. Wichtig: Eine Descriptor-Instanz muss als Klassenattribut, nicht als Instanzattribut, definiert werden.
Sobald du im __init__() den lokalen Parameter fuel_cap der Instanz zuweist, wird die Methode __set__() des Descriptors aufgerufen.
Ändern wir nun die Tankkapazität auf einen negativen Wert und prüfen, ob eine ValueError-Exception ausgelöst wird.
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
Erinnerst du dich hier daran, dass die Typprüfung scheiterte, wenn du den Wert später ins Negative geändert hast, weil sie nur im __init__() ausgeführt wurde? Testen wir jetzt eine String-Zuweisung.
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
Perfekt! Wie du siehst, greifen die Prüfungen auch, wenn du den Attributwert später änderst.
Ein kleiner Haken an Descriptors: Erstellst du eine neue zweite Instanz der Klasse, kann der Wert der vorherigen Instanz überschrieben werden. Der Grund: Descriptors sind an die Klasse gebunden, nicht an die Instanz.
Schauen wir uns das an:
car3 = Car("BMW","X7",48) #created a new instance 'car3' with different values
48
Wenn du anschließend car2 ausgibst, siehst du, dass die Werte von car3 übernommen wurden.
print(car2)
BMW model X7 with a fuel capacity of 48 ltr.
Fazit
Glückwunsch – du hast das Tutorial abgeschlossen.
Dieses Tutorial richtet sich an alle, die mit Python vertraut sind und das nächste Level erreichen wollen. Eine gute Übung: Überlege dir, wie du das Problem des Überschreibens zwischen Instanzen lösen könntest, das wir heute gesehen haben.
Stell deine Fragen zu diesem Tutorial gern unten in den Kommentaren.
Wenn du mehr über Python lernen möchtest, starte den vollständigen Python Programming-Lernpfad von DataCamp.
Sieh dir auch unser Python Functions Tutorial an.