Metaklassen in Python 1.5
Metaklassen in Python 1.5
(auch bekannt als: Der Killer-Witz :-)
(Nachschrift: Das Lesen dieses Essays ist wahrscheinlich nicht der beste Weg, um den hier beschriebenen Metaklassen-Hook zu verstehen. Sehen Sie sich eine Nachricht von Vladimir Marangozov an, die möglicherweise eine sanftere Einführung in das Thema bietet. Sie können auch Deja News nach Nachrichten mit "metaclass" im Betreff durchsuchen, die im Juli und August 1998 an comp.lang.python gesendet wurden.)
In früheren Python-Versionen (und immer noch in 1.5) gibt es etwas, das als "Don Beaudry Hook" bekannt ist, benannt nach seinem Erfinder und Verfechter. Dies ermöglicht es C-Erweiterungen, ein alternatives Klassenverhalten bereitzustellen, wodurch die Python-Klassensyntax verwendet werden kann, um andere klassenähnliche Entitäten zu definieren. Don Beaudry hat dies in seinem berüchtigten MESS-Paket verwendet; Jim Fulton hat es in seinem Extension Classes-Paket verwendet. (Es wurde auch als "Don Beaudry Hack" bezeichnet, aber das ist ein Missverständnis. Daran ist nichts hackelig – es ist tatsächlich ziemlich elegant und tiefgründig, auch wenn etwas Dunkles darin steckt.)
(Beim ersten Lesen möchten Sie vielleicht direkt zu den Beispielen im Abschnitt "Metaklassen in Python schreiben" unten springen, es sei denn, Sie möchten, dass Ihr Kopf explodiert.)
Die Dokumentation des Don Beaudry Hooks wurde bewusst minimal gehalten, da es sich um eine Funktion von unglaublicher Macht handelt und leicht missbraucht werden kann. Im Grunde prüft es, ob der Typ der Basisklasse aufrufbar ist, und wenn ja, wird er aufgerufen, um die neue Klasse zu erstellen.
Beachten Sie die beiden Indirektionsebenen. Nehmen wir ein einfaches Beispiel
class B:
pass
class C(B):
pass
Betrachten Sie die zweite Klassendefinition und versuchen Sie zu ergründen, ob "der Typ der Basisklasse aufrufbar ist".(Typen sind übrigens keine Klassen. Sehen Sie sich die Fragen 4.2, 4.19 und insbesondere 6.22 im Python FAQ für weitere Informationen zu diesem Thema an.)
- Die Basisklasse ist B; das ist einfach.
- Da B eine Klasse ist, ist ihr Typ "class"; der Typ der Basisklasse ist also der Typ "class". Dieser ist auch bekannt als types.ClassType, vorausgesetzt, das Standardmodul
typeswurde importiert. - Ist der Typ "class" nun aufrufbar? Nein, denn Typen (im Kern-Python) sind niemals aufrufbar. Klassen sind aufrufbar (das Aufrufen einer Klasse erstellt eine neue Instanz), aber Typen nicht.
Unser Fazit ist also, dass in unserem Beispiel der Typ der Basisklasse (von C) nicht aufrufbar ist. Daher greift der Don Beaudry Hook nicht, und der Standardmechanismus zur Klassenerstellung wird verwendet (der auch dann verwendet wird, wenn keine Basisklasse vorhanden ist). Tatsächlich greift der Don Beaudry Hook nie, wenn nur Kern-Python verwendet wird, da der Typ eines Kernobjekts niemals aufrufbar ist.
Was tun also Don und Jim, um Dons Hook zu nutzen? Sie schreiben eine Erweiterung, die mindestens zwei neue Python-Objekttypen definiert. Der erste wäre der Typ für "klassenähnliche" Objekte, die als Basisklasse verwendet werden können, um Dons Hook auszulösen. Dieser Typ muss aufrufbar gemacht werden. Deshalb brauchen wir einen zweiten Typ. Ob ein Objekt aufrufbar ist, hängt von seinem Typ ab. Ob ein Typobjekt aufrufbar ist, hängt also von seinem Typ ab, was ein Meta-Typ ist. (Im Kern-Python gibt es nur einen Meta-Typ, den Typ "type" (types.TypeType), der der Typ aller Typobjekte ist, sogar von sich selbst.) Ein neuer Meta-Typ muss definiert werden, der den Typ der klassenähnlichen Objekte aufrufbar macht. (Normalerweise würde auch ein dritter Typ benötigt, der neue "Instanz"-Typ, aber das ist keine absolute Anforderung – der neue Klassentyp könnte beim Aufrufen zur Erstellung einer Instanz ein Objekt eines vorhandenen Typs zurückgeben.)
Immer noch verwirrt? Hier ist ein einfaches Mittel von Don selbst, um Metaklassen zu erklären. Nehmen wir eine einfache Klassendefinition; nehmen wir an, B ist eine spezielle Klasse, die Dons Hook auslöst
class C(B):
a = 1
b = 2
Dies kann als äquivalent zu Folgendem betrachtet werden:C = type(B)('C', (B,), {'a': 1, 'b': 2})
Wenn Ihnen das zu dicht ist, hier dasselbe, ausgeschrieben mit temporären Variablencreator = type(B) # The type of the base class
name = 'C' # The name of the new class
bases = (B,) # A tuple containing the base class(es)
namespace = {'a': 1, 'b': 2} # The namespace of the class statement
C = creator(name, bases, namespace)
Dies ist analog zu dem, was ohne den Don Beaudry Hook geschieht, außer dass in diesem Fall die Erstellungsfunktion auf den Standard-Klassenersteller gesetzt ist.In beiden Fällen wird die Erstellungsfunktion mit drei Argumenten aufgerufen. Das erste, name, ist der Name der neuen Klasse (wie oben in der Klassenerklärung angegeben). Das bases-Argument ist ein Tupel von Basisklassen (ein Singleton-Tupel, wenn nur eine Basisklasse vorhanden ist, wie im Beispiel). Schließlich ist namespace ein Dictionary, das die lokalen Variablen enthält, die während der Ausführung der Klassenerklärung gesammelt wurden.
Beachten Sie, dass der Inhalt des Namespace-Dictionaries einfach alle Namen sind, die in der Klassenerklärung definiert wurden. Eine wenig bekannte Tatsache ist, dass, wenn Python eine Klassenerklärung ausführt, es einen neuen lokalen Namespace betritt und alle Zuweisungen und Funktionsdefinitionen in diesem Namespace stattfinden. Wenn also die folgende Klassenerklärung ausgeführt wird
class C:
a = 1
def f(s): pass
würde der Inhalt des Klassen-Namespaces {'a': 1, 'f': <function f ...>} lauten.Aber genug über das Schreiben von Python-Metaklassen in C; lesen Sie die Dokumentation von MESS oder Extension Classes für weitere Informationen.
Metaklassen in Python schreiben
In Python 1.5 wurde die Anforderung, eine C-Erweiterung zu schreiben, um Metaklassen zu schreiben, fallen gelassen (obwohl Sie es natürlich immer noch tun können). Zusätzlich zur Prüfung "ist der Typ der Basisklasse aufrufbar?" gibt es eine Prüfung "hat die Basisklasse ein __class__-Attribut?". Wenn ja, wird angenommen, dass das __class__-Attribut auf eine Klasse verweist.
Wiederholen wir unser einfaches Beispiel von oben
class C(B):
a = 1
b = 2
Vorausgesetzt, B hat ein __class__-Attribut, wird dies übersetzt zuC = B.__class__('C', (B,), {'a': 1, 'b': 2})
Dies ist genau dasselbe wie zuvor, außer dass anstelle von type(B) B.__class__ aufgerufen wird. Wenn Sie FAQ-Frage 6.22 gelesen haben, werden Sie verstehen, dass es zwar einen großen technischen Unterschied zwischen type(B) und B.__class__ gibt, sie aber auf verschiedenen Abstraktionsebenen die gleiche Rolle spielen. Und vielleicht werden sie eines Tages tatsächlich dasselbe sein (zu diesem Zeitpunkt könnten Sie von eingebauten Typen abgeleitet werden).An dieser Stelle könnte es sich lohnen zu erwähnen, dass C.__class__ dasselbe Objekt wie B.__class__ ist, d.h. C's Metaklasse ist dieselbe wie B's Metaklasse. Mit anderen Worten, das Unterklassenbilden einer vorhandenen Klasse erstellt eine neue (Meta-)Instanz der Metaklasse der Basisklasse.
Zurück zum Beispiel: Die Klasse B.__class__ wird instanziiert, wobei ihrem Konstruktor dieselben drei Argumente übergeben werden, die dem Standard-Klassenkonstruktor oder der Metaklasse einer Erweiterung übergeben werden: name, bases und namespace.
Es ist leicht, sich darüber zu verwirren, was genau passiert, wenn eine Metaklasse verwendet wird, da wir die absolute Unterscheidung zwischen Klassen und Instanzen verlieren: Eine Klasse ist eine Instanz einer Metaklasse (eine "Metainstanz"), aber technisch gesehen (d.h. in den Augen des Python-Laufzeitsystems) ist die Metaklasse nur eine Klasse, und die Metainstanz ist nur eine Instanz. Am Ende der Klassenerklärung wird die Metaklasse, deren Metainstanz als Basisklasse verwendet wird, instanziiert, was zu einer zweiten Metainstanz (derselben Metaklasse) führt. Diese Metainstanz wird dann als (normale, nicht-meta) Klasse verwendet; die Instanziierung der Klasse bedeutet das Aufrufen der Metainstanz, und dies gibt eine echte Instanz zurück. Und von welcher Klasse ist das eine Instanz? Konzeptionell ist es natürlich eine Instanz unserer Metainstanz; aber in den meisten Fällen wird das Python-Laufzeitsystem sie als Instanz einer Hilfsklasse sehen, die von der Metaklasse verwendet wird, um ihre (nicht-meta) Instanzen zu implementieren...
Hoffentlich macht ein Beispiel die Dinge klarer. Nehmen wir an, wir haben eine Metaklasse MetaClass1. Ihre Hilfsklasse (für nicht-meta Instanzen) heißt HelperClass1. Wir instanziieren nun (manuell) MetaClass1 einmal, um eine leere spezielle Basisklasse zu erhalten
BaseClass1 = MetaClass1("BaseClass1", (), {})
Wir können nun BaseClass1 als Basisklasse in einer Klassenerklärung verwendenclass MySpecialClass(BaseClass1):
i = 1
def f(s): pass
Zu diesem Zeitpunkt ist MySpecialClass definiert; sie ist eine Metainstanz von MetaClass1, genau wie BaseClass1, und tatsächlich ergibt der Ausdruck "BaseClass1.__class__ == MySpecialClass.__class__ == MetaClass1" den Wert True.Wir sind nun bereit, Instanzen von MySpecialClass zu erstellen. Nehmen wir an, keine Konstruktorargumente sind erforderlich
x = MySpecialClass() y = MySpecialClass() print x.__class__, y.__class__Die Print-Anweisung zeigt, dass x und y Instanzen von HelperClass1 sind. Wie ist das passiert? MySpecialClass ist eine Instanz von MetaClass1 ("meta" ist hier irrelevant); wenn eine Instanz aufgerufen wird, wird ihre __call__-Methode aufgerufen, und wahrscheinlich gibt die von MetaClass1 definierte __call__-Methode eine Instanz von HelperClass1 zurück.
Schauen wir uns nun an, wie wir Metaklassen verwenden könnten – was können wir mit Metaklassen tun, was wir nicht einfach ohne sie tun können? Hier ist eine Idee: Eine Metaklasse könnte automatisch Trace-Aufrufe für alle Methodenaufrufe einfügen. Entwickeln wir zuerst ein vereinfachtes Beispiel, ohne Unterstützung für Vererbung oder andere "fortgeschrittene" Python-Funktionen (das fügen wir später hinzu).
import types
class Tracing:
def __init__(self, name, bases, namespace):
"""Create a new class."""
self.__name__ = name
self.__bases__ = bases
self.__namespace__ = namespace
def __call__(self):
"""Create a new instance."""
return Instance(self)
class Instance:
def __init__(self, klass):
self.__klass__ = klass
def __getattr__(self, name):
try:
value = self.__klass__.__namespace__[name]
except KeyError:
raise AttributeError, name
if type(value) is not types.FunctionType:
return value
return BoundMethod(value, self)
class BoundMethod:
def __init__(self, function, instance):
self.function = function
self.instance = instance
def __call__(self, *args):
print "calling", self.function, "for", self.instance, "with", args
return apply(self.function, (self.instance,) + args)
Trace = Tracing('Trace', (), {})
class MyTracedClass(Trace):
def method1(self, a):
self.a = a
def method2(self):
return self.a
aninstance = MyTracedClass()
aninstance.method1(10)
print "the answer is %d" % aninstance.method2()
Schon verwirrt? Die Absicht ist, dies von oben nach unten zu lesen. Die Klasse Tracing ist die Metaklasse, die wir definieren. Ihre Struktur ist wirklich einfach.
- Die __init__-Methode wird aufgerufen, wenn eine neue Tracing-Instanz erstellt wird, z. B. die Definition der Klasse MyTracedClass später im Beispiel. Sie speichert einfach den Klassennamen, die Basisklassen und den Namespace als Instanzvariablen.
- Die __call__-Methode wird aufgerufen, wenn eine Tracing-Instanz aufgerufen wird, z. B. die Erstellung einer Instanz später im Beispiel. Sie gibt eine Instanz der Klasse Instance zurück, die als Nächstes definiert ist.
Die Klasse Instance ist die Klasse, die für alle Instanzen von Klassen verwendet wird, die mit der Tracing-Metaklasse erstellt wurden, z. B. aninstance. Sie hat zwei Methoden
- Die __init__-Methode wird aus der obigen Tracing.__call__-Methode aufgerufen, um eine neue Instanz zu initialisieren. Sie speichert die Klassenreferenz als Instanzvariable. Sie verwendet einen komischen Namen, weil die Instanzvariablen des Benutzers (z. B. self.a später im Beispiel) im selben Namespace leben.
- Die __getattr__-Methode wird aufgerufen, wenn der Benutzercode auf ein Attribut der Instanz zugreift, das keine Instanzvariable ist (noch eine Klassenvariable; aber bis auf __init__ und __getattr__ gibt es keine Klassenvariablen). Sie wird zum Beispiel aufgerufen, wenn aninstance.method1 im Beispiel referenziert wird, wobei self auf aninstance und name auf den String "method1" gesetzt ist.
Die __getattr__-Methode sucht den Namen im __namespace__-Dictionary. Wenn er nicht gefunden wird, löst sie eine AttributeError-Ausnahme aus. (In einem realistischeren Beispiel müsste sie zuerst auch die Basisklassen durchsuchen.) Wenn er gefunden wird, gibt es zwei Möglichkeiten: Es ist entweder eine Funktion oder nicht. Wenn es keine Funktion ist, wird angenommen, dass es sich um eine Klassenvariable handelt, und ihr Wert wird zurückgegeben. Wenn es eine Funktion ist, müssen wir sie in eine Instanz einer weiteren Hilfsklasse, BoundMethod, "wickeln".
Die BoundMethod-Klasse wird benötigt, um eine bekannte Funktion zu implementieren: Wenn eine Methode definiert wird, hat sie ein erstes Argument, self, das automatisch an die relevante Instanz gebunden wird, wenn sie aufgerufen wird. Zum Beispiel ist aninstance.method1(10) äquivalent zu method1(aninstance, 10). Im Beispiel wird bei diesem Aufruf zuerst eine temporäre BoundMethod-Instanz mit dem folgenden Konstruktoraufruf erstellt: temp = BoundMethod(method1, aninstance); dann wird diese Instanz als temp(10) aufgerufen. Nach dem Aufruf wird die temporäre Instanz verworfen.
- Die __init__-Methode wird für den Konstruktoraufruf BoundMethod(method1, aninstance) aufgerufen. Sie speichert einfach ihre Argumente.
- Die __call__-Methode wird aufgerufen, wenn die gebundene Methodeninstanz aufgerufen wird, wie in temp(10). Sie muss method1(aninstance, 10) aufrufen. Obwohl self.function nun method1 und self.instance aninstance ist, kann sie nicht direkt self.function(self.instance, args) aufrufen, da sie unabhängig von der Anzahl der übergebenen Argumente funktionieren muss. (Der Einfachheit halber wurde die Unterstützung für Schlüsselwortargumente weggelassen.)
Um beliebige Argumentlisten unterstützen zu können, konstruiert die __call__-Methode zuerst ein neues Argumenttupel. Praktischerweise werden aufgrund der Notation *args in der Argumentenliste von __call__ die Argumente für __call__ (außer self) im Tupel args platziert. Um die gewünschte Argumentliste zu konstruieren, verketten wir ein Singleton-Tupel, das die Instanz enthält, mit dem args-Tupel: (self.instance,) + args. (Beachten Sie das abschließende Komma, das zur Konstruktion des Singleton-Tupels verwendet wird.) In unserem Beispiel ist das resultierende Argumenttupel (aninstance, 10).
Die interne Funktion apply() nimmt eine Funktion und ein Argumenttupel entgegen und ruft die Funktion damit auf. In unserem Beispiel rufen wir apply(method1, (aninstance, 10)) auf, was dem Aufruf von method(aninstance, 10) entspricht.
Von hier an sollten die Dinge ziemlich einfach zusammenkommen. Die Ausgabe des Beispielcodes sieht ungefähr so aus
calling <function method1 at ae8d8> for <Instance instance at 95ab0> with (10,) calling <function method2 at ae900> for <Instance instance at 95ab0> with () the answer is 10
Das war ungefähr das kürzeste aussagekräftige Beispiel, das ich mir einfallen lassen konnte. Eine echte Trace-Metaklasse (z. B. Trace.py, das unten besprochen wird) muss in zweierlei Hinsicht komplizierter sein.
Erstens muss sie fortgeschrittenere Python-Funktionen wie Klassenvariablen, Vererbung, __init__-Methoden und Schlüsselwortargumente unterstützen.
Zweitens muss sie eine flexiblere Möglichkeit bieten, die eigentlichen Trace-Informationen zu verarbeiten; vielleicht sollte es möglich sein, eine eigene Trace-Funktion zu schreiben, die aufgerufen wird, vielleicht sollte es möglich sein, das Tracing pro Klasse oder pro Instanz zu aktivieren und zu deaktivieren, und vielleicht ein Filter, damit nur interessante Aufrufe getraced werden; außerdem sollte es den Rückgabewert des Aufrufs (oder die aufgetretene Ausnahme, falls ein Fehler auftritt) traceen können. Selbst das Trace.py-Beispiel unterstützt noch nicht all diese Funktionen.
Reale Beispiele
Schauen Sie sich einige sehr vorläufige Beispiele an, die ich erstellt habe, um mich selbst im Schreiben von Metaklassen zu unterrichten
- Enum.py
- Dies (miss)braucht die Klassensyntax als elegante Möglichkeit, Aufzählungstypen zu definieren. Die resultierenden Klassen werden nie instanziiert – vielmehr sind ihre Klassenattribute die Aufzählungswerte. Zum Beispiel
class Color(Enum): red = 1 green = 2 blue = 3 print Color.reddruckt die Zeichenkette "Color.red", während "Color.red==1" wahr ist und "Color.red + 1" eine TypeError-Ausnahme auslöst. - Trace.py
- Die resultierenden Klassen funktionieren weitgehend wie Standardklassen, aber durch Setzen eines speziellen Klassen- oder Instanzattributs __trace_output__ auf eine Datei werden alle Aufrufe der Methoden der Klasse getraced. Es war ein ziemlicher Kampf, das richtig hinzubekommen. Dies sollte wahrscheinlich mit der generischen Metaklasse unten neu gemacht werden.
- Meta.py
- Eine generische Metaklasse. Dies ist ein Versuch herauszufinden, wie viel Standard-Klassenverhalten durch eine Metaklasse nachgeahmt werden kann. Die vorläufige Antwort scheint zu sein, dass alles in Ordnung ist, solange die Klasse (oder ihre Clients) nicht auf das __class__-Attribut der Instanz oder das __dict__-Attribut der Klasse schauen. Die interne Verwendung von __getattr__ macht die klassische Implementierung von __getattr__-Hooks schwierig; wir bieten stattdessen einen ähnlichen Hook _getattr_ an. (__setattr__ und __delattr__ sind davon nicht betroffen.) (XXX Hm. Könnte die Anwesenheit von __getattr__ erkennen und umbenennen.)
- Eiffel.py
- Verwendet die obige generische Metaklasse, um Eiffel-artige Vor- und Nachbedingungen zu implementieren.
- Synch.py
- Verwendet die obige generische Metaklasse, um synchronisierte Methoden zu implementieren.
- Simple.py
- Das oben verwendete Beispielmodul.
Es scheint sich ein Muster abzuzeichnen: Fast alle diese Verwendungen von Metaklassen (außer Enum, das wahrscheinlich eher niedlich als nützlich ist) funktionieren hauptsächlich, indem sie Methodenaufrufe wrappen. Ein offensichtliches Problem dabei ist, dass es nicht einfach ist, die Funktionen verschiedener Metaklassen zu kombinieren, während dies tatsächlich sehr nützlich wäre: Ich hätte zum Beispiel nichts dagegen, einen Trace vom Testlauf des Synch-Moduls zu erhalten, und es wäre interessant, ihm auch Vorbedingungen hinzuzufügen. Dies erfordert weitere Forschung. Vielleicht könnte eine Metaklasse bereitgestellt werden, die stapelbare Wrapper ermöglicht...
Dinge, die Sie mit Metaklassen tun könnten
Es gibt viele Dinge, die Sie mit Metaklassen tun könnten. Die meisten davon können auch mit kreativer Verwendung von __getattr__ erreicht werden, aber Metaklassen erleichtern die Änderung des Attributsuchverhaltens von Klassen. Hier ist eine Teilliste.
- Erzwingen unterschiedlicher Vererbungssemantiken, z.B. automatische Aufrufe von Basisklassenmethoden, wenn eine abgeleitete Klasse überschreibt
- Implementieren von Klassenmethoden (z.B. wenn das erste Argument nicht 'self' genannt wird)
- Implementieren, dass jede Instanz mit Kopien aller Klassenvariablen initialisiert wird
- Implementieren einer anderen Methode zur Speicherung von Instanzvariablen (z.B. in einer Liste außerhalb der Instanz, aber indiziert nach der ID der Instanz)
- Automatisches Umwickeln oder Abfangen aller oder bestimmter Methoden
- zum Tracen
- zur Vor- und Nachbedingungsprüfung
- für synchronisierte Methoden
- zur automatischen Wert-Cache
- Wenn ein Attribut eine parameterlose Funktion ist, rufen Sie es bei Referenzierung auf (um es als Instanzvariable zu imitieren); dasselbe bei Zuweisung
- Instrumentierung: Sehen, wie oft verschiedene Attribute verwendet werden
- Unterschiedliche Semantiken für __setattr__ und __getattr__ (z.B. sie deaktivieren, wenn sie rekursiv verwendet werden)
- Klassensyntax für andere Dinge missbrauchen
- Experimentieren mit automatischer Typüberprüfung
- Delegation (oder Erwerb)
- Dynamische Vererbungsmuster
- Automatische Caching von Methoden
Danksagung
Vielen Dank an David Ascher und Donald Beaudry für ihre Kommentare zu früheren Entwürfen dieses Papiers. Ebenfalls Dank an Matt Conway und Tommy Burnette dafür, dass sie vor fast drei Jahren einen Keim für die Idee der Metaklassen in meinen Kopf gepflanzt haben, auch wenn meine damalige Antwort "das kann man doch mit __getattr__ Hooks machen..." war :-)
