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:
self ist die Instanz des von dir erstellten Descriptors (Real Python).
object ist die Instanz des Objekts, an das dein Descriptor angehängt ist (Real Python).
type ist der Typ der Klasse, an die der Descriptor gebunden ist (Real Python).
value ist 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.
classCar: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)Bereitgestellt von
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.
classCar: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)Bereitgestellt von
----------------------------------------ValueErrorTraceback(most recent call last)<ipython-input-22-1c3d23be72f7>in<module>---->1 car2 =Car("BMW","X7",-40)2print(car2)<ipython-input-20-1e154783588d>in__init__(self, make, model, fuel_cap)5 self.fuel_cap = fuel_cap
6if self.fuel_cap <0:---->7 raise ValueError("Fuel Capacity can never be less than zero")89 def __str__(self):ValueError: Fuel Capacity can never be less than zero
Bereitgestellt von
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.
classCar: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)Bereitgestellt von
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.
classCar:def__init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
ifisinstance(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)Bereitgestellt von
----------------------------------------ValueErrorTraceback(most recent call last)<ipython-input-34-75b08cba454f>in<module>---->1 car2 =Car(-40,"X7",40)2print(car2)<ipython-input-31-175690bf3b98>in__init__(self, make, model, fuel_cap)7print(self.make)8else:---->9 raise ValueError("Make of the car can never be an integer")1011if self.fuel_cap <0:ValueError: Make of the car can never be an integer
Bereitgestellt von
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:
classCar:def__init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
ifisinstance(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)Bereitgestellt von
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.
classDescriptor:def__init__(self):
self.__fuel_cap =0def__get__(self, instance, owner):return self.__fuel_cap
def__set__(self, instance, value):ifisinstance(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
Bereitgestellt von
classCar:
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)Bereitgestellt von
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_cap auf 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 (hier car2), und den Owner, also die Klasse, zu der die Instanz gehört (die Klasse Car). In dieser Methode gibst du einfach den Wert des Attributs fuel_cap zurü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ür fuel_cap eine Ganzzahl ist. Wenn nicht, wird eine TypeError-Exception geworfen. Anschließend prüfst du, ob der Wert kleiner als null ist; falls ja, folgt eine ValueError. Nach den Prüfungen setzt du fuel_cap auf 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.
-40----------------------------------------ValueErrorTraceback(most recent call last)<ipython-input-115-1c3d23be72f7>in<module>---->1 car2 =Car("BMW","X7",-40)2print(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
78 def __str__(self):<ipython-input-106-0b252695aeed>in__set__(self, instance, value)1112if value <0:--->13 raise ValueError("Fuel Capacity can never be less than zero")1415 self.__fuel_cap = value
ValueError: Fuel Capacity can never be less than zero
Bereitgestellt von
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.
-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)1112if value <0:--->13 raise ValueError("Fuel Capacity can never be less than zero")1415 self.__fuel_cap = value
ValueError: Fuel Capacity can never be less than zero
Bereitgestellt von
----------------------------------------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)8print(value)9else:--->10 raise TypeError("Fuel Capacity can only be an integer")1112if value <0:TypeError: Fuel Capacity can only be an integer
Bereitgestellt von
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 valuesBereitgestellt von
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.
Lerne mehr über Python-Lambda-Funktionen, wozu sie gut sind und wann man sie benutzt. Enthält praktische Beispiele und bewährte Methoden für eine effektive Umsetzung.
Mach dich mit Python-Datenstrukturen vertraut: Lerne mehr über Datentypen und primitive sowie nicht-primitive Datenstrukturen wie Strings, Listen, Stapel usw.
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 mitBindungsverhalten, dessen Attributzugriff durch Methoden des Descriptor-Protokolls überschrieben wurde. Diese Methoden sind__get__(),__set__()und__delete__()(Quelle).Bindungsverhaltenbedeutet 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 Datenelementgebunden, 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,setunddelete.Schauen wir uns die Methoden
get,setunddeletegenauer 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
carmit drei Attributen:make,modelundfuel_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 duprint()oderstr()auf eine Instanz anwendest.Sieht gut aus, oder?
Jetzt ändern wir die Tankkapazität auf minus 40.
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__()eineif-Bedingung hinzu, die prüft, ob die angegebene Tankkapazität gültig ist. Ist sie ungültig, werfen wir eine ValueError-Exception.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 capacityeine Gleitkommazahl oder ein String ist? Oder wenn auchmakeundmodelGanzzahlen sind? In all diesen Fällen würde unser Programm aktuell keinen Fehler melden.Um auch diesen Fall abzufangen, könnten wir eine weitere
if-Bedingung ergänzen oder die Typprüfung mitisinstancelösen.Diesmal verwenden wir die eingebaute Funktion
isinstance.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:
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.
Wenn dir die Klasse Descriptor noch kryptisch vorkommt, zerlegen wir sie in kleine Teile:
__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.__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.__delete__()wird aufgerufen, wenn das Attribut von einem Objekt gelöscht wird. Wie__set__()gibt sie nichts zurück.Die Klasse
Carbleibt im Wesentlichen unverändert. Neu ist nur, dass du die Instanzfuel_capder KlasseDescriptor()als Klassenattribut hinzufügst. Wichtig: Eine Descriptor-Instanz muss als Klassenattribut, nicht als Instanzattribut, definiert werden.Sobald du im
__init__()den lokalen Parameterfuel_capder 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.
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.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:
Wenn du anschließend
car2ausgibst, siehst du, dass die Werte voncar3übernommen wurden.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.