Leitfaden zu Deskriptoren¶
Deskriptoren erlauben es Objekten, das Nachschlagen, Speichern und Löschen von Attributen anzupassen.
Dieser Leitfaden hat vier große Abschnitte:
Die „Einführung“ gibt einen grundlegenden Überblick und geht sachte von einfachen Beispielen aus, wobei jeweils eine Eigenschaft hinzukommt. Fang hier an, wenn Deskriptoren neu für dich sind.
Der zweite Abschnitt zeigt ein vollständiges, praxisnahes Deskriptor-Beispiel. Wenn du die Grundlagen schon kennst, fang dort an.
Der dritte Abschnitt bietet ein technischeres Tutorial, das ausführlich auf die Mechanik eingeht, wie Deskriptoren arbeiten. Die meisten brauchen diese Detailtiefe nicht.
Der letzte Abschnitt enthält reine Python-Gegenstücke zu integrierten Deskriptoren, die in C geschrieben sind. Lies ihn, wenn du neugierig bist, wie Funktionen zu gebundenen Methoden werden, oder wie verbreitete Werkzeuge wie
@classmethod,@staticmethod,@propertyund __slots__ umgesetzt sind.
Einführung¶
In dieser Einführung beginnen wir mit dem einfachsten möglichen Beispiel und ergänzen dann eine neue Fähigkeit nach der anderen.
Einfaches Beispiel: Ein Deskriptor, der eine Konstante zurückgibt¶
Die Klasse Ten ist ein Deskriptor, dessen Methode __get__() immer die Konstante 10 zurückgibt:
class Ten:
def __get__(self, obj, objtype=None):
return 10
Um den Deskriptor zu verwenden, muss er als Klassenvariable in einer anderen Klasse abgelegt werden:
class A:
x = 5 # Regular class attribute
y = Ten() # Descriptor instance
Eine interaktive Sitzung zeigt den Unterschied zwischen dem normalen Nachschlagen eines Attributs und dem Nachschlagen über einen Deskriptor:
>>> a = A() # Make an instance of class A
>>> a.x # Normal attribute lookup
5
>>> a.y # Descriptor lookup
10
Beim Nachschlagen des Attributs a.x findet der Punktoperator 'x': 5 im Klassenwörterbuch. Beim Nachschlagen von a.y findet der Punktoperator eine Deskriptor-Instanz, erkennbar an ihrer Methode __get__. Der Aufruf dieser Methode gibt 10 zurück.
Beachte, dass der Wert 10 weder im Klassen- noch im Instanzwörterbuch gespeichert ist. Der Wert 10 wird stattdessen bei Bedarf berechnet.
Dieses Beispiel zeigt, wie ein einfacher Deskriptor arbeitet, ist aber nicht besonders nützlich. Um Konstanten abzurufen, wäre das normale Nachschlagen von Attributen besser.
Im nächsten Abschnitt bauen wir etwas Nützlicheres: ein dynamisches Nachschlagen.
Dynamisches Nachschlagen¶
Interessante Deskriptoren führen üblicherweise Berechnungen aus, anstatt Konstanten zurückzugeben:
import os
class DirectorySize:
def __get__(self, obj, objtype=None):
return len(os.listdir(obj.dirname))
class Directory:
size = DirectorySize() # Descriptor instance
def __init__(self, dirname):
self.dirname = dirname # Regular instance attribute
Eine interaktive Sitzung zeigt, dass das Nachschlagen dynamisch ist – es berechnet jedes Mal andere, aktuelle Antworten:
>>> s = Directory('songs')
>>> g = Directory('games')
>>> s.size # The songs directory has twenty files
20
>>> g.size # The games directory has three files
3
>>> os.remove('games/chess') # Delete a game
>>> g.size # File count is automatically updated
2
Dieses Beispiel zeigt nicht nur, wie Deskriptoren Berechnungen ausführen können, es offenbart auch den Zweck der Parameter von __get__(). Der Parameter self ist size, eine Instanz von DirectorySize. Der Parameter obj ist entweder g oder s, eine Instanz von Directory. Es ist der Parameter obj, der die Methode __get__() das Zielverzeichnis erfahren lässt. Der Parameter objtype ist die Klasse Directory.
Verwaltete Attribute¶
Ein beliebter Einsatz von Deskriptoren ist, den Zugriff auf Instanzdaten zu verwalten. Der Deskriptor wird einem öffentlichen Attribut im Klassenwörterbuch zugewiesen, während die eigentlichen Daten als privates Attribut im Instanzwörterbuch gespeichert werden. Die Methoden __get__() und __set__() des Deskriptors werden ausgelöst, wenn auf das öffentliche Attribut zugegriffen wird.
Im folgenden Beispiel ist age das öffentliche und _age das private Attribut. Wird auf das öffentliche Attribut zugegriffen, protokolliert der Deskriptor das Nachschlagen oder die Aktualisierung:
import logging
logging.basicConfig(level=logging.INFO)
class LoggedAgeAccess:
def __get__(self, obj, objtype=None):
value = obj._age
logging.info('Accessing %r giving %r', 'age', value)
return value
def __set__(self, obj, value):
logging.info('Updating %r to %r', 'age', value)
obj._age = value
class Person:
age = LoggedAgeAccess() # Descriptor instance
def __init__(self, name, age):
self.name = name # Regular instance attribute
self.age = age # Calls __set__()
def birthday(self):
self.age += 1 # Calls both __get__() and __set__()
Eine interaktive Sitzung zeigt, dass jeder Zugriff auf das verwaltete Attribut age protokolliert wird, das gewöhnliche Attribut name aber nicht:
>>> mary = Person('Mary M', 30) # The initial age update is logged
INFO:root:Updating 'age' to 30
>>> dave = Person('David D', 40)
INFO:root:Updating 'age' to 40
>>> vars(mary) # The actual data is in a private attribute
{'name': 'Mary M', '_age': 30}
>>> vars(dave)
{'name': 'David D', '_age': 40}
>>> mary.age # Access the data and log the lookup
INFO:root:Accessing 'age' giving 30
30
>>> mary.birthday() # Updates are logged as well
INFO:root:Accessing 'age' giving 30
INFO:root:Updating 'age' to 31
>>> dave.name # Regular attribute lookup isn't logged
'David D'
>>> dave.age # Only the managed attribute is logged
INFO:root:Accessing 'age' giving 40
40
Ein wesentliches Problem an diesem Beispiel ist, dass der private Name _age in der Klasse LoggedAgeAccess fest verdrahtet ist. Das heißt, dass jede Instanz nur ein protokolliertes Attribut haben kann und dass sein Name unveränderlich ist. Im nächsten Beispiel beheben wir dieses Problem.
Angepasste Namen¶
Verwendet eine Klasse Deskriptoren, kann sie jedem Deskriptor mitteilen, welcher Variablenname verwendet wurde.
In diesem Beispiel hat die Klasse Person zwei Deskriptor-Instanzen, name und age. Wird die Klasse Person definiert, macht sie einen Rückruf an __set_name__() in LoggedAccess, damit die Feldnamen festgehalten werden können. Jeder Deskriptor bekommt so seinen eigenen public_name und private_name:
import logging
logging.basicConfig(level=logging.INFO)
class LoggedAccess:
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = '_' + name
def __get__(self, obj, objtype=None):
value = getattr(obj, self.private_name)
logging.info('Accessing %r giving %r', self.public_name, value)
return value
def __set__(self, obj, value):
logging.info('Updating %r to %r', self.public_name, value)
setattr(obj, self.private_name, value)
class Person:
name = LoggedAccess() # First descriptor instance
age = LoggedAccess() # Second descriptor instance
def __init__(self, name, age):
self.name = name # Calls the first descriptor
self.age = age # Calls the second descriptor
def birthday(self):
self.age += 1
Eine interaktive Sitzung zeigt, dass die Klasse Person __set_name__() aufgerufen hat, damit die Feldnamen festgehalten werden. Hier rufen wir vars() auf, um den Deskriptor nachzuschlagen, ohne ihn auszulösen:
>>> vars(vars(Person)['name'])
{'public_name': 'name', 'private_name': '_name'}
>>> vars(vars(Person)['age'])
{'public_name': 'age', 'private_name': '_age'}
Die neue Klasse protokolliert nun den Zugriff auf name und auf age:
>>> pete = Person('Peter P', 10)
INFO:root:Updating 'name' to 'Peter P'
INFO:root:Updating 'age' to 10
>>> kate = Person('Catherine C', 20)
INFO:root:Updating 'name' to 'Catherine C'
INFO:root:Updating 'age' to 20
Die beiden Person-Instanzen enthalten nur die privaten Namen:
>>> vars(pete)
{'_name': 'Peter P', '_age': 10}
>>> vars(kate)
{'_name': 'Catherine C', '_age': 20}
Abschließende Gedanken¶
Ein Deskriptor ist, wie wir jedes Objekt nennen, das __get__(), __set__() oder __delete__() definiert.
Deskriptoren können wahlweise eine Methode __set_name__() haben. Die wird nur in Fällen verwendet, in denen ein Deskriptor entweder die Klasse kennen muss, in der er erzeugt wurde, oder den Namen der Klassenvariablen, der er zugewiesen wurde. (Diese Methode wird, falls vorhanden, auch dann aufgerufen, wenn die Klasse kein Deskriptor ist.)
Deskriptoren werden beim Nachschlagen von Attributen durch den Punktoperator aufgerufen. Wird auf einen Deskriptor mittelbar über vars(some_class)[descriptor_name] zugegriffen, wird die Deskriptor-Instanz zurückgegeben, ohne sie aufzurufen.
Deskriptoren funktionieren nur, wenn sie als Klassenvariablen verwendet werden. In Instanzen abgelegt haben sie keine Wirkung.
Der wesentliche Grund für Deskriptoren ist, einen Haken zu bieten, mit dem in Klassenvariablen abgelegte Objekte steuern können, was beim Nachschlagen von Attributen geschieht.
Herkömmlich steuert die aufrufende Klasse, was beim Nachschlagen geschieht. Deskriptoren kehren dieses Verhältnis um und erlauben es den nachgeschlagenen Daten, ein Wort mitzureden.
Deskriptoren werden in der ganzen Sprache verwendet. So werden Funktionen zu gebundenen Methoden. Verbreitete Werkzeuge wie @classmethod, @staticmethod, @property und @functools.cached_property sind alle als Deskriptoren umgesetzt.
Vollständiges praxisnahes Beispiel¶
In diesem Beispiel bauen wir ein praxisnahes und mächtiges Werkzeug, um notorisch schwer zu findende Fehler bei der Datenbeschädigung aufzuspüren.
Die Klasse Validator¶
Ein Validator ist ein Deskriptor für den Zugriff auf ein verwaltetes Attribut. Bevor er Daten speichert, prüft er, ob der neue Wert verschiedene Typ- und Bereichsgrenzen einhält. Sind diese Grenzen nicht eingehalten, löst er eine Ausnahme aus, um Datenbeschädigung an ihrer Quelle zu verhindern.
Diese Klasse Validator ist sowohl eine abstrakte Basisklasse als auch ein Deskriptor für ein verwaltetes Attribut:
from abc import ABC, abstractmethod
class Validator(ABC):
def __set_name__(self, owner, name):
self.private_name = '_' + name
def __get__(self, obj, objtype=None):
return getattr(obj, self.private_name)
def __set__(self, obj, value):
self.validate(value)
setattr(obj, self.private_name, value)
@abstractmethod
def validate(self, value):
pass
Eigene Validatoren müssen von Validator erben und eine Methode validate() mitbringen, um je nach Bedarf verschiedene Grenzen zu prüfen.
Eigene Validatoren¶
Hier drei praxisnahe Hilfsmittel zur Datenprüfung:
OneOfprüft, ob ein Wert einer aus einer beschränkten Menge von Möglichkeiten ist.Numberprüft, ob ein Wert entweder einintoder einfloatist. Wahlweise prüft es, ob ein Wert zwischen einem gegebenen Minimum und Maximum liegt.Stringprüft, ob ein Wert einstrist. Wahlweise prüft es eine gegebene Mindest- oder Höchstlänge. Es kann außerdem ein selbst festgelegtes Prädikat prüfen.
class OneOf(Validator):
def __init__(self, *options):
self.options = set(options)
def validate(self, value):
if value not in self.options:
raise ValueError(
f'Expected {value!r} to be one of {self.options!r}'
)
class Number(Validator):
def __init__(self, minvalue=None, maxvalue=None):
self.minvalue = minvalue
self.maxvalue = maxvalue
def validate(self, value):
if not isinstance(value, (int, float)):
raise TypeError(f'Expected {value!r} to be an int or float')
if self.minvalue is not None and value < self.minvalue:
raise ValueError(
f'Expected {value!r} to be at least {self.minvalue!r}'
)
if self.maxvalue is not None and value > self.maxvalue:
raise ValueError(
f'Expected {value!r} to be no more than {self.maxvalue!r}'
)
class String(Validator):
def __init__(self, minsize=None, maxsize=None, predicate=None):
self.minsize = minsize
self.maxsize = maxsize
self.predicate = predicate
def validate(self, value):
if not isinstance(value, str):
raise TypeError(f'Expected {value!r} to be a str')
if self.minsize is not None and len(value) < self.minsize:
raise ValueError(
f'Expected {value!r} to be no smaller than {self.minsize!r}'
)
if self.maxsize is not None and len(value) > self.maxsize:
raise ValueError(
f'Expected {value!r} to be no bigger than {self.maxsize!r}'
)
if self.predicate is not None and not self.predicate(value):
raise ValueError(
f'Expected {self.predicate} to be true for {value!r}'
)
Praktische Anwendung¶
So lassen sich die Datenprüfer in einer echten Klasse verwenden:
class Component:
name = String(minsize=3, maxsize=10, predicate=str.isupper)
kind = OneOf('wood', 'metal', 'plastic')
quantity = Number(minvalue=0)
def __init__(self, name, kind, quantity):
self.name = name
self.kind = kind
self.quantity = quantity
Die Deskriptoren verhindern, dass ungültige Instanzen erzeugt werden:
>>> Component('Widget', 'metal', 5) # Blocked: 'Widget' is not all uppercase
Traceback (most recent call last):
...
ValueError: Expected <method 'isupper' of 'str' objects> to be true for 'Widget'
>>> Component('WIDGET', 'metle', 5) # Blocked: 'metle' is misspelled
Traceback (most recent call last):
...
ValueError: Expected 'metle' to be one of {'metal', 'plastic', 'wood'}
>>> Component('WIDGET', 'metal', -5) # Blocked: -5 is negative
Traceback (most recent call last):
...
ValueError: Expected -5 to be at least 0
>>> Component('WIDGET', 'metal', 'V') # Blocked: 'V' isn't a number
Traceback (most recent call last):
...
TypeError: Expected 'V' to be an int or float
>>> c = Component('WIDGET', 'metal', 5) # Allowed: The inputs are valid
Technisches Tutorial¶
Was folgt, ist ein technischeres Tutorial über die Mechanik und die Einzelheiten, wie Deskriptoren arbeiten.
Zusammenfassung¶
Legt Deskriptoren fest, fasst das Protokoll zusammen und zeigt, wie Deskriptoren aufgerufen werden. Bietet ein Beispiel, das zeigt, wie objektrelationale Abbildungen arbeiten.
Über Deskriptoren zu lernen gibt nicht nur Zugang zu einem größeren Werkzeugkasten, es schafft auch ein tieferes Verständnis davon, wie Python arbeitet.
Begriffsbestimmung und Einführung¶
Im Allgemeinen ist ein Deskriptor ein Attributwert, der eine der Methoden des Deskriptor-Protokolls hat. Diese Methoden sind __get__(), __set__() und __delete__(). Ist eine dieser Methoden für ein Attribut definiert, sagt man, es sei ein Deskriptor.
Das Standardverhalten beim Attributzugriff ist, das Attribut aus dem Wörterbuch eines Objekts zu lesen, darin zu setzen oder daraus zu löschen. So hat zum Beispiel a.x eine Nachschlagekette, die bei a.__dict__['x'] beginnt, dann type(a).__dict__['x'] folgt und sich durch die Reihenfolge der Methodenauflösung von type(a) fortsetzt. Ist der nachgeschlagene Wert ein Objekt, das eine der Deskriptor-Methoden definiert, kann Python das Standardverhalten überschreiben und stattdessen die Deskriptor-Methode aufrufen. Wo das in der Vorrangkette geschieht, hängt davon ab, welche Deskriptor-Methoden definiert wurden.
Deskriptoren sind ein mächtiges Protokoll für allgemeine Zwecke. Sie sind der Mechanismus hinter Properties, Methoden, statischen Methoden, Klassenmethoden und super(). Sie werden in Python selbst durchgehend verwendet. Deskriptoren vereinfachen den darunterliegenden C-Code und bieten einen flexiblen Satz neuer Werkzeuge für alltägliche Python-Programme.
Das Deskriptor-Protokoll¶
descr.__get__(self, obj, type=None)
descr.__set__(self, obj, value)
descr.__delete__(self, obj)
Mehr ist es nicht. Definiere eine dieser Methoden, und ein Objekt gilt als Deskriptor und kann das Standardverhalten überschreiben, wenn es als Attribut nachgeschlagen wird.
Definiert ein Objekt __set__() oder __delete__(), gilt es als Datendeskriptor. Deskriptoren, die nur __get__() definieren, nennt man Nicht-Datendeskriptoren (sie werden oft für Methoden verwendet, andere Verwendungen sind aber möglich).
Daten- und Nicht-Datendeskriptoren unterscheiden sich darin, wie das Überschreiben gegenüber Einträgen im Wörterbuch einer Instanz berechnet wird. Hat das Wörterbuch einer Instanz einen Eintrag mit demselben Namen wie ein Datendeskriptor, hat der Datendeskriptor Vorrang. Hat das Wörterbuch einer Instanz einen Eintrag mit demselben Namen wie ein Nicht-Datendeskriptor, hat der Wörterbucheintrag Vorrang.
Um einen nur lesbaren Datendeskriptor zu bauen, definiere sowohl __get__() als auch __set__(), wobei __set__() beim Aufruf einen AttributeError auslöst. Die Methode __set__() mit einem Platzhalter zu definieren, der eine Ausnahme auslöst, genügt, um daraus einen Datendeskriptor zu machen.
Überblick über den Aufruf von Deskriptoren¶
Ein Deskriptor lässt sich unmittelbar mit desc.__get__(obj) oder desc.__get__(None, cls) aufrufen.
Häufiger ist es aber, dass ein Deskriptor beim Attributzugriff automatisch aufgerufen wird.
Der Ausdruck obj.x schlägt das Attribut x in der Kette der Namensräume für obj nach. Findet die Suche einen Deskriptor außerhalb des __dict__ der Instanz, wird seine Methode __get__() nach den unten aufgeführten Vorrangregeln aufgerufen.
Die Einzelheiten des Aufrufs hängen davon ab, ob obj ein Objekt, eine Klasse oder eine super-Instanz ist.
Aufruf aus einer Instanz¶
Das Nachschlagen in einer Instanz durchläuft eine Kette von Namensräumen und gibt Datendeskriptoren den höchsten Vorrang, gefolgt von Instanzvariablen, dann Nicht-Datendeskriptoren, dann Klassenvariablen und zuletzt __getattr__(), sofern vorhanden.
Wird für a.x ein Deskriptor gefunden, wird er so aufgerufen: desc.__get__(a, type(a)).
Die Logik für ein Nachschlagen mit Punkt steckt in object.__getattribute__(). Hier ein reines Python-Gegenstück:
def find_name_in_mro(cls, name, default):
"Emulate _PyType_Lookup() in Objects/typeobject.c"
for base in cls.__mro__:
if name in vars(base):
return vars(base)[name]
return default
def object_getattribute(obj, name):
"Emulate PyObject_GenericGetAttr() in Objects/object.c"
null = sentinel('null')
objtype = type(obj)
cls_var = find_name_in_mro(objtype, name, null)
descr_get = getattr(type(cls_var), '__get__', null)
if descr_get is not null:
if (hasattr(type(cls_var), '__set__')
or hasattr(type(cls_var), '__delete__')):
return descr_get(cls_var, obj, objtype) # data descriptor
if hasattr(obj, '__dict__') and name in vars(obj):
return vars(obj)[name] # instance variable
if descr_get is not null:
return descr_get(cls_var, obj, objtype) # non-data descriptor
if cls_var is not null:
return cls_var # class variable
raise AttributeError(name)
Beachte, dass es im Code von __getattribute__() keinen Haken __getattr__() gibt. Deshalb umgeht ein unmittelbarer Aufruf von __getattribute__() oder einer über super().__getattribute__ __getattr__() vollständig.
Es sind stattdessen der Punktoperator und die Funktion getattr(), die dafür zuständig sind, __getattr__() aufzurufen, wenn __getattribute__() einen AttributeError auslöst. Ihre Logik ist in einer Hilfsfunktion gekapselt:
def getattr_hook(obj, name):
"Emulate slot_tp_getattr_hook() in Objects/typeobject.c"
try:
return obj.__getattribute__(name)
except AttributeError:
if not hasattr(type(obj), '__getattr__'):
raise
return type(obj).__getattr__(obj, name) # __getattr__
Aufruf aus einer Klasse¶
Die Logik für ein Nachschlagen mit Punkt wie A.x steckt in type.__getattribute__(). Die Schritte ähneln denen von object.__getattribute__(), aber das Nachschlagen im Instanzwörterbuch wird durch eine Suche in der Reihenfolge der Methodenauflösung der Klasse ersetzt.
Wird ein Deskriptor gefunden, wird er mit desc.__get__(None, A) aufgerufen.
Die vollständige C-Umsetzung findest du in type_getattro() und _PyType_Lookup() in Objects/typeobject.c.
Aufruf aus super¶
Die Logik für das Nachschlagen mit Punkt bei super steckt in der Methode __getattribute__() des Objekts, das super() zurückgibt.
Ein Nachschlagen mit Punkt wie super(A, obj).m durchsucht obj.__class__.__mro__ nach der Basisklasse B, die unmittelbar auf A folgt, und gibt dann B.__dict__['m'].__get__(obj, A) zurück. Ist m kein Deskriptor, wird es unverändert zurückgegeben.
Die vollständige C-Umsetzung findest du in super_getattro() in Objects/typeobject.c. Ein reines Python-Gegenstück findest du in Guidos Tutorial.
Zusammenfassung der Aufruflogik¶
Der Mechanismus für Deskriptoren steckt in den Methoden __getattribute__() von object, type und super().
Die wichtigen Punkte zum Merken sind:
Deskriptoren werden von der Methode
__getattribute__()aufgerufen.Klassen erben diese Maschinerie von
object,typeodersuper().__getattribute__()zu überschreiben verhindert automatische Deskriptor-Aufrufe, weil die gesamte Deskriptor-Logik in dieser Methode steckt.object.__getattribute__()undtype.__getattribute__()rufen__get__()unterschiedlich auf. Das erste übergibt die Instanz und möglicherweise die Klasse. Das zweite setzt für die InstanzNoneein und übergibt immer die Klasse.Datendeskriptoren überschreiben Instanzwörterbücher immer.
Nicht-Datendeskriptoren können von Instanzwörterbüchern überschrieben werden.
Automatische Namensbenachrichtigung¶
Manchmal ist es wünschenswert, dass ein Deskriptor weiß, welchem Klassenvariablennamen er zugewiesen wurde. Wird eine neue Klasse erzeugt, durchsucht die Metaklasse type das Wörterbuch der neuen Klasse. Sind Einträge darin Deskriptoren und definieren sie __set_name__(), wird diese Methode mit zwei Argumenten aufgerufen. owner ist die Klasse, in der der Deskriptor verwendet wird, und name ist die Klassenvariable, der der Deskriptor zugewiesen wurde.
Die Einzelheiten der Umsetzung stehen in type_new() und set_names() in Objects/typeobject.c.
Da die Aktualisierungslogik in type.__new__() steckt, finden Benachrichtigungen nur zum Zeitpunkt der Klassenerzeugung statt. Werden Deskriptoren der Klasse danach hinzugefügt, muss __set_name__() von Hand aufgerufen werden.
ORM-Beispiel¶
Der folgende Code ist ein vereinfachtes Gerüst, das zeigt, wie sich mit Datendeskriptoren eine objektrelationale Abbildung umsetzen ließe.
Die wesentliche Idee ist, dass die Daten in einer externen Datenbank gespeichert sind. Die Python-Instanzen halten nur Schlüssel zu den Tabellen der Datenbank. Die Deskriptoren kümmern sich um das Nachschlagen und Aktualisieren:
class Field:
def __set_name__(self, owner, name):
self.fetch = f'SELECT {name} FROM {owner.table} WHERE {owner.key}=?;'
self.store = f'UPDATE {owner.table} SET {name}=? WHERE {owner.key}=?;'
def __get__(self, obj, objtype=None):
return conn.execute(self.fetch, [obj.key]).fetchone()[0]
def __set__(self, obj, value):
conn.execute(self.store, [value, obj.key])
conn.commit()
Mit der Klasse Field können wir Modelle definieren, die das Schema jeder Tabelle in einer Datenbank beschreiben:
class Movie:
table = 'Movies' # Table name
key = 'title' # Primary key
director = Field()
year = Field()
def __init__(self, key):
self.key = key
class Song:
table = 'Music'
key = 'title'
artist = Field()
year = Field()
genre = Field()
def __init__(self, key):
self.key = key
Um die Modelle zu verwenden, verbinde dich zuerst mit der Datenbank:
>>> import sqlite3
>>> conn = sqlite3.connect('entertainment.db')
Eine interaktive Sitzung zeigt, wie Daten aus der Datenbank abgerufen und wie sie aktualisiert werden können:
>>> Movie('Star Wars').director
'George Lucas'
>>> jaws = Movie('Jaws')
>>> f'Released in {jaws.year} by {jaws.director}'
'Released in 1975 by Steven Spielberg'
>>> Song('Country Roads').artist
'John Denver'
>>> Movie('Star Wars').director = 'J.J. Abrams'
>>> Movie('Star Wars').director
'J.J. Abrams'
Reine Python-Gegenstücke¶
Das Deskriptor-Protokoll ist einfach und bietet spannende Möglichkeiten. Mehrere Anwendungsfälle sind so verbreitet, dass sie in integrierten Werkzeugen fertig verpackt sind. Properties, gebundene Methoden, statische Methoden, Klassenmethoden und __slots__ beruhen alle auf dem Deskriptor-Protokoll.
Properties¶
property() aufzurufen ist ein knapper Weg, einen Datendeskriptor zu bauen, der beim Zugriff auf ein Attribut einen Funktionsaufruf auslöst. Seine Signatur ist:
property(fget=None, fset=None, fdel=None, doc=None) -> property
Die Dokumentation zeigt eine typische Verwendung, um ein verwaltetes Attribut x zu definieren:
class C:
def getx(self): return self.__x
def setx(self, value): self.__x = value
def delx(self): del self.__x
x = property(getx, setx, delx, "I'm the 'x' property.")
Um zu sehen, wie property() mit Hilfe des Deskriptor-Protokolls umgesetzt ist, hier ein reines Python-Gegenstück, das den größten Teil der Kernfunktionalität umsetzt:
class Property:
"Emulate PyProperty_Type() in Objects/descrobject.c"
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
if doc is None and fget is not None:
doc = fget.__doc__
self.__doc__ = doc
def __set_name__(self, owner, name):
self.__name__ = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
Die integrierte Funktion property() hilft immer dann, wenn eine Benutzeroberfläche Attributzugriff gewährt hat und spätere Änderungen das Eingreifen einer Methode erfordern.
So kann eine Tabellenkalkulationsklasse zum Beispiel über Cell('b10').value Zugriff auf einen Zellenwert gewähren. Spätere Verbesserungen am Programm erfordern, dass die Zelle bei jedem Zugriff neu berechnet wird. Der Programmierer will bestehenden Clientcode, der unmittelbar auf das Attribut zugreift, aber nicht beeinträchtigen. Die Lösung ist, den Zugriff auf das Attribut value in einen property-Datendeskriptor zu hüllen:
class Cell:
...
@property
def value(self):
"Recalculate the cell before returning value"
self.recalc()
return self._value
In diesem Beispiel würden sowohl die integrierte Funktion property() als auch unser Gegenstück Property() funktionieren.
Funktionen und Methoden¶
Die objektorientierten Eigenschaften von Python ruhen auf einer funktionsbasierten Umgebung. Über Nicht-Datendeskriptoren werden die beiden bruchlos verbunden.
In Klassenwörterbüchern abgelegte Funktionen werden beim Aufruf zu Methoden. Methoden unterscheiden sich von gewöhnlichen Funktionen nur darin, dass die Objektinstanz den anderen Argumenten vorangestellt wird. Per Konvention heißt die Instanz self, könnte aber this oder wie jede andere Variable heißen.
Methoden lassen sich mit types.MethodType von Hand erzeugen, was etwa Folgendem entspricht:
class MethodType:
"Emulate PyMethod_Type in Objects/classobject.c"
def __init__(self, func, obj):
self.__func__ = func
self.__self__ = obj
def __call__(self, *args, **kwargs):
func = self.__func__
obj = self.__self__
return func(obj, *args, **kwargs)
def __getattribute__(self, name):
"Emulate method_getset() in Objects/classobject.c"
if name == '__doc__':
return self.__func__.__doc__
return object.__getattribute__(self, name)
def __getattr__(self, name):
"Emulate method_getattro() in Objects/classobject.c"
return getattr(self.__func__, name)
def __get__(self, obj, objtype=None):
"Emulate method_descr_get() in Objects/classobject.c"
return self
Um die automatische Erzeugung von Methoden zu unterstützen, haben Funktionen die Methode __get__(), um Methoden beim Attributzugriff zu binden. Das heißt, dass Funktionen Nicht-Datendeskriptoren sind, die beim Nachschlagen mit Punkt aus einer Instanz gebundene Methoden zurückgeben. So funktioniert es:
class Function:
...
def __get__(self, obj, objtype=None):
"Simulate func_descr_get() in Objects/funcobject.c"
if obj is None:
return self
return MethodType(self, obj)
Lässt man die folgende Klasse im Interpreter laufen, zeigt sich, wie der Funktionsdeskriptor in der Praxis arbeitet:
class D:
def f(self):
return self
class D2:
pass
Die Funktion hat ein Attribut qualifizierter Name, um Introspektion zu unterstützen:
>>> D.f.__qualname__
'D.f'
Auf die Funktion über das Klassenwörterbuch zuzugreifen ruft __get__() nicht auf. Es gibt stattdessen einfach das darunterliegende Funktionsobjekt zurück:
>>> D.__dict__['f']
<function D.f at 0x00C45070>
Der Zugriff mit Punkt aus einer Klasse ruft __get__() auf, das die darunterliegende Funktion einfach unverändert zurückgibt:
>>> D.f
<function D.f at 0x00C45070>
Das interessante Verhalten tritt beim Zugriff mit Punkt aus einer Instanz auf. Das Nachschlagen mit Punkt ruft __get__() auf, das ein Objekt für eine gebundene Methode zurückgibt:
>>> d = D()
>>> d.f
<bound method D.f of <__main__.D object at 0x00B18C90>>
Intern speichert die gebundene Methode die darunterliegende Funktion und die gebundene Instanz:
>>> d.f.__func__
<function D.f at 0x00C45070>
>>> d.f.__self__
<__main__.D object at 0x00B18C90>
Wenn du sich je gefragt hast, wo self in gewöhnlichen Methoden oder cls in Klassenmethoden herkommt: Das ist es!
Arten von Methoden¶
Nicht-Datendeskriptoren bieten einen einfachen Mechanismus für Abwandlungen der üblichen Muster, Funktionen in Methoden zu binden.
Zur Wiederholung: Funktionen haben eine Methode __get__(), damit sie beim Zugriff als Attribut in eine Methode umgewandelt werden können. Der Nicht-Datendeskriptor verwandelt einen Aufruf obj.f(*args) in f(obj, *args). Ein Aufruf von cls.f(*args) wird zu f(*args).
Diese Tabelle fasst die Bindung und ihre zwei nützlichsten Abwandlungen zusammen:
Umwandlung
Aufruf aus einem Objekt
Aufruf aus einer Klasse
function
f(obj, *args)
f(*args)
staticmethod
f(*args)
f(*args)
classmethod
f(type(obj), *args)
f(cls, *args)
Statische Methoden¶
Statische Methoden geben die darunterliegende Funktion unverändert zurück. Ein Aufruf von c.f oder C.f entspricht einem unmittelbaren Nachschlagen in object.__getattribute__(c, "f") beziehungsweise object.__getattribute__(C, "f"). Die Funktion ist dadurch aus einem Objekt und aus einer Klasse auf dieselbe Weise zugänglich.
Gute Kandidaten für statische Methoden sind Methoden, die die Variable self nicht verwenden.
So kann ein Statistikpaket zum Beispiel eine Containerklasse für Versuchsdaten enthalten. Die Klasse bietet gewöhnliche Methoden, um Durchschnitt, Mittelwert, Median und andere beschreibende Statistiken zu berechnen, die von den Daten abhängen. Es kann aber nützliche Funktionen geben, die begrifflich dazugehören, aber nicht von den Daten abhängen. So ist erf(x) zum Beispiel eine praktische Umwandlungsroutine, die in der statistischen Arbeit vorkommt, aber nicht unmittelbar von einem bestimmten Datensatz abhängt. Sie lässt sich entweder aus einem Objekt oder aus der Klasse aufrufen: s.erf(1.5) --> 0.9332 oder Sample.erf(1.5) --> 0.9332.
Da statische Methoden die darunterliegende Funktion unverändert zurückgeben, sind die Beispielaufrufe unspektakulär:
class E:
@staticmethod
def f(x):
return x * 10
>>> E.f(3)
30
>>> E().f(3)
30
Mit dem Protokoll für Nicht-Datendeskriptoren würde eine reine Python-Fassung von @staticmethod so aussehen:
import functools
class StaticMethod:
"Emulate PyStaticMethod_Type() in Objects/funcobject.c"
def __init__(self, f):
self.f = f
functools.update_wrapper(self, f)
def __get__(self, obj, objtype=None):
return self.f
def __call__(self, *args, **kwds):
return self.f(*args, **kwds)
@property
def __annotations__(self):
return self.f.__annotations__
Der Aufruf von functools.update_wrapper() ergänzt ein Attribut __wrapped__, das auf die darunterliegende Funktion verweist. Außerdem überträgt er die Attribute, die nötig sind, damit die Hülle wie die umhüllte Funktion aussieht, darunter __name__, __qualname__ und __doc__.
Klassenmethoden¶
Anders als statische Methoden stellen Klassenmethoden der Argumentliste die Klassenreferenz voran, bevor sie die Funktion aufrufen. Dieses Format ist gleich, ob der Aufrufer ein Objekt oder eine Klasse ist:
class F:
@classmethod
def f(cls, x):
return cls.__name__, x
>>> F.f(3)
('F', 3)
>>> F().f(3)
('F', 3)
Dieses Verhalten ist immer dann nützlich, wenn die Methode nur eine Klassenreferenz braucht und sich nicht auf Daten stützt, die in einer bestimmten Instanz gespeichert sind. Eine Verwendung von Klassenmethoden ist, alternative Klassenkonstruktoren zu bauen. So erzeugt zum Beispiel die Klassenmethode dict.fromkeys() ein neues Wörterbuch aus einer Liste von Schlüsseln. Das reine Python-Gegenstück ist:
class Dict(dict):
@classmethod
def fromkeys(cls, iterable, value=None):
"Emulate dict_fromkeys() in Objects/dictobject.c"
d = cls()
for key in iterable:
d[key] = value
return d
Nun lässt sich ein neues Wörterbuch mit eindeutigen Schlüsseln so bauen:
>>> d = Dict.fromkeys('abracadabra')
>>> type(d) is Dict
True
>>> d
{'a': None, 'b': None, 'r': None, 'c': None, 'd': None}
Mit dem Protokoll für Nicht-Datendeskriptoren würde eine reine Python-Fassung von @classmethod so aussehen:
import functools
class ClassMethod:
"Emulate PyClassMethod_Type() in Objects/funcobject.c"
def __init__(self, f):
self.f = f
functools.update_wrapper(self, f)
def __get__(self, obj, cls=None):
if cls is None:
cls = type(obj)
return MethodType(self.f, cls)
Der Aufruf von functools.update_wrapper() in ClassMethod ergänzt ein Attribut __wrapped__, das auf die darunterliegende Funktion verweist. Außerdem überträgt er die Attribute, die nötig sind, damit die Hülle wie die umhüllte Funktion aussieht: __name__, __qualname__, __doc__ und __annotations__.
Member-Objekte und __slots__¶
Definiert eine Klasse __slots__, ersetzt sie Instanzwörterbücher durch ein Feld fester Länge mit Slot-Werten. Aus Sicht des Benutzers hat das mehrere Wirkungen:
1. Provides immediate detection of bugs due to misspelled attribute
assignments. Only attribute names specified in __slots__ are allowed:
class Vehicle:
__slots__ = ('id_number', 'make', 'model')
>>> auto = Vehicle()
>>> auto.id_nubmer = 'VYE483814LQEX'
Traceback (most recent call last):
...
AttributeError: 'Vehicle' object has no attribute 'id_nubmer'
2. Helps create immutable objects where descriptors manage access to private
attributes stored in __slots__:
class Immutable:
__slots__ = ('_dept', '_name') # Replace the instance dictionary
def __init__(self, dept, name):
self._dept = dept # Store to private attribute
self._name = name # Store to private attribute
@property # Read-only descriptor
def dept(self):
return self._dept
@property
def name(self): # Read-only descriptor
return self._name
>>> mark = Immutable('Botany', 'Mark Watney')
>>> mark.dept
'Botany'
>>> mark.dept = 'Space Pirate'
Traceback (most recent call last):
...
AttributeError: property 'dept' of 'Immutable' object has no setter
>>> mark.location = 'Mars'
Traceback (most recent call last):
...
AttributeError: 'Immutable' object has no attribute 'location'
3. Saves memory. On a 64-bit Linux build, an instance with two attributes
takes 48 bytes with __slots__ and 152 bytes without. This flyweight
design pattern likely only
matters when a large number of instances are going to be created.
4. Improves speed. Reading instance variables is 35% faster with
__slots__ (as measured with Python 3.10 on an Apple M1 processor).
5. Blocks tools like @functools.cached_property which require an
instance dictionary to function correctly:
from functools import cached_property
class CP:
__slots__ = () # Eliminates the instance dict
@cached_property # Requires an instance dict
def pi(self):
return 4 * sum((-1.0)**n / (2.0*n + 1.0)
for n in reversed(range(100_000)))
>>> CP().pi
Traceback (most recent call last):
...
TypeError: No '__dict__' attribute on 'CP' instance to cache 'pi' property.
Es ist nicht möglich, eine genau austauschbare reine Python-Fassung von __slots__ zu bauen, weil dafür unmittelbarer Zugriff auf C-Strukturen und Kontrolle über die Speicheranforderung von Objekten nötig ist. Wir können aber eine größtenteils treue Nachbildung bauen, in der die eigentliche C-Struktur für Slots durch eine private Liste _slotvalues nachgebildet wird. Lese- und Schreibzugriffe auf diese private Struktur werden von Member-Deskriptoren verwaltet:
null = sentinel('null')
class Member:
def __init__(self, name, clsname, offset):
'Emulate PyMemberDef in Include/descrobject.h'
# Also see descr_new() in Objects/descrobject.c
self.name = name
self.clsname = clsname
self.offset = offset
def __get__(self, obj, objtype=None):
'Emulate member_get() in Objects/descrobject.c'
# Also see PyMember_GetOne() in Python/structmember.c
if obj is None:
return self
value = obj._slotvalues[self.offset]
if value is null:
raise AttributeError(self.name)
return value
def __set__(self, obj, value):
'Emulate member_set() in Objects/descrobject.c'
obj._slotvalues[self.offset] = value
def __delete__(self, obj):
'Emulate member_delete() in Objects/descrobject.c'
value = obj._slotvalues[self.offset]
if value is null:
raise AttributeError(self.name)
obj._slotvalues[self.offset] = null
def __repr__(self):
'Emulate member_repr() in Objects/descrobject.c'
return f'<Member {self.name!r} of {self.clsname!r}>'
Die Methode type.__new__() kümmert sich darum, Member-Objekte zu den Klassenvariablen hinzuzufügen:
class Type(type):
'Simulate how the type metaclass adds member objects for slots'
def __new__(mcls, clsname, bases, mapping, **kwargs):
'Emulate type_new() in Objects/typeobject.c'
# type_new() calls PyTypeReady() which calls add_methods()
slot_names = mapping.get('slot_names', [])
for offset, name in enumerate(slot_names):
mapping[name] = Member(name, clsname, offset)
return type.__new__(mcls, clsname, bases, mapping, **kwargs)
Die Methode object.__new__() kümmert sich darum, Instanzen zu erzeugen, die Slots anstelle eines Instanzwörterbuchs haben. Hier eine grobe Nachbildung in reinem Python:
class Object:
'Simulate how object.__new__() allocates memory for __slots__'
def __new__(cls, *args, **kwargs):
'Emulate object_new() in Objects/typeobject.c'
inst = super().__new__(cls)
if hasattr(cls, 'slot_names'):
empty_slots = [null] * len(cls.slot_names)
object.__setattr__(inst, '_slotvalues', empty_slots)
return inst
def __setattr__(self, name, value):
'Emulate _PyObject_GenericSetAttrWithDict() Objects/object.c'
cls = type(self)
if hasattr(cls, 'slot_names') and name not in cls.slot_names:
raise AttributeError(
f'{cls.__name__!r} object has no attribute {name!r}'
)
super().__setattr__(name, value)
def __delattr__(self, name):
'Emulate _PyObject_GenericSetAttrWithDict() Objects/object.c'
cls = type(self)
if hasattr(cls, 'slot_names') and name not in cls.slot_names:
raise AttributeError(
f'{cls.__name__!r} object has no attribute {name!r}'
)
super().__delattr__(name)
Um die Nachbildung in einer echten Klasse zu verwenden, erbe einfach von Object und setze die Metaklasse auf Type:
class H(Object, metaclass=Type):
'Instance variables stored in slots'
slot_names = ['x', 'y']
def __init__(self, x, y):
self.x = x
self.y = y
An diesem Punkt hat die Metaklasse Member-Objekte für x und y geladen:
>>> from pprint import pp
>>> pp(dict(vars(H)))
{'__module__': '__main__',
'__doc__': 'Instance variables stored in slots',
'slot_names': ['x', 'y'],
'__init__': <function H.__init__ at 0x7fb5d302f9d0>,
'x': <Member 'x' of 'H'>,
'y': <Member 'y' of 'H'>}
Werden Instanzen erzeugt, haben sie eine Liste slot_values, in der die Attribute gespeichert sind:
>>> h = H(10, 20)
>>> vars(h)
{'_slotvalues': [10, 20]}
>>> h.x = 55
>>> vars(h)
{'_slotvalues': [55, 20]}
Falsch geschriebene oder nicht zugewiesene Attribute lösen eine Ausnahme aus:
>>> h.xz
Traceback (most recent call last):
...
AttributeError: 'H' object has no attribute 'xz'