Python-Klassenattribute: Ein übermäßig gründlicher Leitfaden
Veröffentlicht: 2022-03-11Ich hatte kürzlich ein Programmierinterview, einen Telefonbildschirm, in dem wir einen kollaborativen Texteditor verwendet haben.
Ich wurde gebeten, eine bestimmte API zu implementieren, und entschied mich dafür, dies in Python zu tun. Um die Problemstellung zu abstrahieren, nehmen wir an, ich brauchte eine Klasse, deren Instanzen einige data
und einige other_data
gespeichert haben.
Ich holte tief Luft und begann zu tippen. Nach ein paar Zeilen hatte ich so etwas:
class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...
Mein Gesprächspartner hielt mich auf:
- Interviewer: „Diese Zeile:
data = []
. Ich glaube nicht, dass das gültiges Python ist?“ - Ich: „Da bin ich mir ziemlich sicher. Es wird lediglich ein Standardwert für das Instanzattribut festgelegt.“
- Interviewer: „Wann wird dieser Code ausgeführt?“
- Ich: „Ich bin mir nicht sicher. Ich werde es nur reparieren, um Verwirrung zu vermeiden.“
Als Referenz und um Ihnen eine Vorstellung davon zu geben, was ich vorhatte, habe ich den Code folgendermaßen geändert:
class Service(object): def __init__(self, other_data): self.data = [] self.other_data = other_data ...
Wie sich herausstellte, lagen wir beide falsch. Die wirkliche Antwort lag darin, den Unterschied zwischen Python-Klassenattributen und Python-Instanzattributen zu verstehen.
Hinweis: Wenn Sie sich mit Klassenattributen auskennen, können Sie mit den Anwendungsfällen fortfahren.
Attribute der Python-Klasse
Mein Interviewer hat sich geirrt, da der obige Code syntaktisch gültig ist .
Auch ich habe mich geirrt, da es keinen „Standardwert“ für das Instanzattribut gesetzt hat. Stattdessen definiert es data
als Klassenattribut mit dem Wert []
.
Meiner Erfahrung nach sind Python-Klassenattribute ein Thema, von dem viele Leute etwas wissen, aber nur wenige vollständig verstehen.
Python-Klassenvariable vs. Instanzvariable: Was ist der Unterschied?
Ein Python-Klassenattribut ist eher ein Attribut der Klasse (kreisförmig, ich weiß) als ein Attribut einer Instanz einer Klasse.
Verwenden wir ein Python-Klassenbeispiel, um den Unterschied zu veranschaulichen. Hier ist class_var
ein Klassenattribut und i_var
ein Instanzattribut:
class MyClass(object): class_var = 1 def __init__(self, i_var): self.i_var = i_var
Beachten Sie, dass alle Instanzen der Klasse Zugriff auf class_var
haben und dass darauf auch als Eigenschaft der Klasse selbst zugegriffen werden kann:
foo = MyClass(2) bar = MyClass(3) foo.class_var, foo.i_var ## 1, 2 bar.class_var, bar.i_var ## 1, 3 MyClass.class_var ## <— This is key ## 1
Für Java- oder C++-Programmierer ist das class-Attribut ähnlich – aber nicht identisch – mit dem statischen Member. Wir werden später sehen, wie sie sich unterscheiden.
Klassen- vs. Instanz-Namespaces
Um zu verstehen, was hier passiert, lassen Sie uns kurz über Python-Namespaces sprechen.
Ein Namensraum ist eine Zuordnung von Namen zu Objekten mit der Eigenschaft, dass es keine Beziehung zwischen Namen in verschiedenen Namensräumen gibt. Sie werden normalerweise als Python-Wörterbücher implementiert, obwohl dies abstrahiert wird.
Je nach Kontext müssen Sie möglicherweise auf einen Namespace mit Punktsyntax (z. B. object.name_from_objects_namespace
) oder als lokale Variable (z. B. object_from_namespace
) zugreifen. Als konkretes Beispiel:
class MyClass(object): ## No need for dot syntax class_var = 1 def __init__(self, i_var): self.i_var = i_var ## Need dot syntax as we've left scope of class namespace MyClass.class_var ## 1
Python-Klassen und Instanzen von Klassen haben jeweils ihre eigenen unterschiedlichen Namespaces, die durch vordefinierte Attribute MyClass.__dict__
bzw. instance_of_MyClass.__dict__
werden.
Wenn Sie versuchen, von einer Instanz einer Klasse aus auf ein Attribut zuzugreifen, wird zunächst der Namespace der Instanz überprüft. Wenn es das Attribut findet, gibt es den zugehörigen Wert zurück. Wenn nicht, sucht es im Klassen- Namespace und gibt das Attribut zurück (wenn es vorhanden ist, wird andernfalls ein Fehler ausgegeben). Zum Beispiel:
foo = MyClass(2) ## Finds i_var in foo's instance namespace foo.i_var ## 2 ## Doesn't find class_var in instance namespace… ## So look's in class namespace (MyClass.__dict__) foo.class_var ## 1
Der Instanz-Namensraum hat Vorrang vor dem Klassen-Namensraum: Wenn es in beiden ein Attribut mit demselben Namen gibt, wird der Instanz-Namensraum zuerst geprüft und sein Wert zurückgegeben. Hier ist eine vereinfachte Version des Codes (Quelle) für die Attributsuche:
def instlookup(inst, name): ## simplified algorithm... if inst.__dict__.has_key(name): return inst.__dict__[name] else: return inst.__class__.__dict__[name]
Und in visueller Form:
Wie Klassenattribute die Zuweisung handhaben
Vor diesem Hintergrund können wir verstehen, wie Python-Klassenattribute die Zuweisung handhaben:
Wenn ein Klassenattribut durch Zugriff auf die Klasse festgelegt wird, überschreibt es den Wert für alle Instanzen. Zum Beispiel:
foo = MyClass(2) foo.class_var ## 1 MyClass.class_var = 2 foo.class_var ## 2
Auf Namespace-Ebene … setzen wir
MyClass.__dict__['class_var'] = 2
. (Hinweis: Dies ist nicht der genaue Code (der wäresetattr(MyClass, 'class_var', 2)
), da__dict__
einen dictproxy zurückgibt, einen unveränderlichen Wrapper, der eine direkte Zuweisung verhindert, aber zur Demonstration hilft). Wenn wir dann auffoo.class_var
zugreifen, hatclass_var
einen neuen Wert im Klassennamensraum und daher wird 2 zurückgegeben.Wenn eine Paython-Klassenvariable durch Zugriff auf eine Instanz festgelegt wird, überschreibt sie den Wert nur für diese Instanz . Dies überschreibt im Wesentlichen die Klassenvariable und verwandelt sie in eine Instanzvariable, die intuitiv nur für diese Instanz verfügbar ist. Zum Beispiel:
foo = MyClass(2) foo.class_var ## 1 foo.class_var = 2 foo.class_var ## 2 MyClass.class_var ## 1
Auf Namespace-Ebene … fügen wir das Attribut
class_var
zufoo.__dict__
, wenn wir also nachfoo.class_var
suchen, geben wir 2 zurück. In der Zwischenzeit haben andere Instanzen vonMyClass
class_var
nicht in ihren Instanz-Namespaces, sodass sie weiterhinclass_var
finden inMyClass.__dict__
und geben somit 1 zurück.
Wandlungsfähigkeit
Quizfrage: Was ist, wenn Ihr Klassenattribut einen veränderlichen Typ hat? Sie können das Klassenattribut manipulieren (verstümmeln?), indem Sie über eine bestimmte Instanz darauf zugreifen und am Ende das referenzierte Objekt manipulieren, auf das alle Instanzen zugreifen (wie von Timothy Wiseman hervorgehoben).
Dies lässt sich am besten an einem Beispiel demonstrieren. Kehren wir zu dem Service
zurück, den ich zuvor definiert habe, und sehen wir uns an, wie meine Verwendung einer Klassenvariablen später zu Problemen hätte führen können.
class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...
Mein Ziel war es, die leere Liste ( []
) als Standardwert für data
zu haben und für jede Instanz von Service
eigene Daten zu haben, die im Laufe der Zeit von Instanz zu Instanz geändert werden. Aber in diesem Fall erhalten wir das folgende Verhalten (denken Sie daran, dass Service
einige Argumente other_data
, die in diesem Beispiel willkürlich sind):
s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data.append(1) s1.data ## [1] s2.data ## [1] s2.data.append(2) s1.data ## [1, 2] s2.data ## [1, 2]
Das ist nicht gut – das Ändern der Klassenvariablen über eine Instanz ändert sie für alle anderen!
Auf Namespace-Ebene … greifen alle Instanzen von Service
auf dieselbe Liste in Service.__dict__
zu und ändern sie, ohne ihre eigenen data
in ihren Instanz-Namespaces zu erstellen.
Wir könnten dies umgehen, indem wir eine Zuweisung verwenden; Das heißt, anstatt die Veränderlichkeit der Liste auszunutzen, könnten wir unseren Service
Objekten wie folgt ihre eigenen Listen zuweisen:
s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data = [1] s2.data = [2] s1.data ## [1] s2.data ## [2]
In diesem Fall fügen wir s1.__dict__['data'] = [1]
hinzu, sodass der ursprüngliche Service.__dict__['data']
unverändert bleibt.
Unglücklicherweise erfordert dies, dass Service
genaue Kenntnisse seiner Variablen haben, und ist sicherlich anfällig für Fehler. In gewisser Weise würden wir eher die Symptome als die Ursache angehen. Wir würden etwas bevorzugen, das von der Konstruktion her korrekt ist.
Meine persönliche Lösung: Wenn Sie nur eine Klassenvariable verwenden, um einer potenziellen Python-Instanzvariablen einen Standardwert zuzuweisen, verwenden Sie keine veränderlichen Werte . In diesem Fall würde jede Instanz von Service
schließlich Service.data
mit ihrem eigenen Instanzattribut überschreiben, sodass die Verwendung einer leeren Liste als Standard zu einem kleinen Fehler führte, der leicht übersehen werden konnte. Anstelle des oben Gesagten hätten wir auch Folgendes tun können:
- Vollständig an Instanzattributen festgehalten, wie in der Einführung gezeigt.
Die Verwendung der leeren Liste (ein veränderlicher Wert) als „Standard“ wurde vermieden:
class Service(object): data = None def __init__(self, other_data): self.other_data = other_data ...
Natürlich müssten wir den
None
-Fall angemessen handhaben, aber das ist ein geringer Preis.
Wann sollten Sie also Python-Klassenattribute verwenden?
Klassenattribute sind knifflig, aber schauen wir uns ein paar Fälle an, in denen sie sich als nützlich erweisen würden:
Konstanten speichern . Da auf Klassenattribute als Attribute der Klasse selbst zugegriffen werden kann, ist es oft schön, sie zum Speichern von klassenweiten, klassenspezifischen Konstanten zu verwenden. Zum Beispiel:
class Circle(object): pi = 3.14159 def __init__(self, radius): self.radius = radius def area(self): return Circle.pi * self.radius * self.radius Circle.pi ## 3.14159 c = Circle(10) c.pi ## 3.14159 c.area() ## 314.159
Standardwerte definieren . Als triviales Beispiel könnten wir eine begrenzte Liste erstellen (d. h. eine Liste, die nur eine bestimmte Anzahl von Elementen oder weniger enthalten kann) und eine Standardobergrenze von 10 Elementen festlegen:
class MyClass(object): limit = 10 def __init__(self): self.data = [] def item(self, i): return self.data[i] def add(self, e): if len(self.data) >= self.limit: raise Exception("Too many elements") self.data.append(e) MyClass.limit ## 10
Wir könnten dann auch Instanzen mit ihren eigenen spezifischen Limits erstellen, indem wir sie dem
limit
-Attribut der Instanz zuweisen.foo = MyClass() foo.limit = 50 ## foo can now hold 50 elements—other instances can hold 10
Dies ist nur sinnvoll, wenn Sie möchten, dass Ihre typische Instanz von
MyClass
nur 10 Elemente oder weniger enthält. Wenn Sie allen Ihren Instanzen unterschiedliche Grenzwerte zuweisen, solltelimit
eine Instanzvariable sein. (Denken Sie jedoch daran: Seien Sie vorsichtig, wenn Sie veränderliche Werte als Standardwerte verwenden.)Verfolgen aller Daten über alle Instanzen einer bestimmten Klasse hinweg . Das ist irgendwie spezifisch, aber ich könnte mir ein Szenario vorstellen, in dem Sie vielleicht auf ein Datenelement zugreifen möchten, das sich auf jede vorhandene Instanz einer bestimmten Klasse bezieht.
Um das Szenario konkreter zu machen, nehmen wir an, wir haben eine
Person
und jede Person hat einenname
. Wir wollen alle verwendeten Namen nachverfolgen. Ein Ansatz könnte darin bestehen, die Objektliste des Garbage Collectors zu durchlaufen, aber es ist einfacher, Klassenvariablen zu verwenden.Beachten Sie, dass in diesem Fall auf
names
nur als Klassenvariable zugegriffen wird, sodass der veränderliche Standardwert akzeptabel ist.class Person(object): all_names = [] def __init__(self, name): self.name = name Person.all_names.append(name) joe = Person('Joe') bob = Person('Bob') print Person.all_names ## ['Joe', 'Bob']
Wir könnten dieses Entwurfsmuster sogar verwenden, um alle vorhandenen Instanzen einer bestimmten Klasse zu verfolgen, anstatt nur einige zugehörige Daten.
class Person(object): all_people = [] def __init__(self, name): self.name = name Person.all_people.append(self) joe = Person('Joe') bob = Person('Bob') print Person.all_people ## [<__main__.Person object at 0x10e428c50>, <__main__.Person object at 0x10e428c90>]
Leistung (irgendwie … siehe unten).
Unter der Haube
Hinweis: Wenn Sie sich Sorgen um die Leistung auf dieser Ebene machen, möchten Sie Python vielleicht gar nicht erst verwenden, da die Unterschiede in der Größenordnung von Zehntel Millisekunden liegen – aber es macht trotzdem Spaß, ein bisschen herumzustöbern, und hilft zur Veranschaulichung.
Denken Sie daran, dass der Namespace einer Klasse zum Zeitpunkt der Definition der Klasse erstellt und ausgefüllt wird. Das bedeutet, dass wir immer nur eine Zuweisung für eine bestimmte Klassenvariable vornehmen, während Instanzvariablen jedes Mal zugewiesen werden müssen, wenn eine neue Instanz erstellt wird. Nehmen wir ein Beispiel.
def called_class(): print "Class assignment" return 2 class Bar(object): y = called_class() def __init__(self, x): self.x = x ## "Class assignment" def called_instance(): print "Instance assignment" return 2 class Foo(object): def __init__(self, x): self.y = called_instance() self.x = x Bar(1) Bar(2) Foo(1) ## "Instance assignment" Foo(2) ## "Instance assignment"
Wir weisen Bar.y
nur einmal zu, aber instance_of_Foo.y
bei jedem Aufruf von __init__
.
Als weiteren Beweis verwenden wir den Python-Disassembler:
import dis class Bar(object): y = 2 def __init__(self, x): self.x = x class Foo(object): def __init__(self, x): self.y = 2 self.x = x dis.dis(Bar) ## Disassembly of __init__: ## 7 0 LOAD_FAST 1 (x) ## 3 LOAD_FAST 0 (self) ## 6 STORE_ATTR 0 (x) ## 9 LOAD_CONST 0 (None) ## 12 RETURN_VALUE dis.dis(Foo) ## Disassembly of __init__: ## 11 0 LOAD_CONST 1 (2) ## 3 LOAD_FAST 0 (self) ## 6 STORE_ATTR 0 (y) ## 12 9 LOAD_FAST 1 (x) ## 12 LOAD_FAST 0 (self) ## 15 STORE_ATTR 1 (x) ## 18 LOAD_CONST 0 (None) ## 21 RETURN_VALUE
Wenn wir uns den Bytecode ansehen, ist es wieder offensichtlich, dass Foo.__init__
zwei Zuweisungen machen muss, während Bar.__init__
nur eine macht.
Wie sieht dieser Gewinn in der Praxis wirklich aus? Ich bin der Erste, der zugibt, dass Timing-Tests stark von oft unkontrollierbaren Faktoren abhängen und die Unterschiede zwischen ihnen oft schwer genau zu erklären sind.
Ich denke jedoch, dass diese kleinen Ausschnitte (die mit dem Python-Modul timeit ausgeführt werden) helfen, die Unterschiede zwischen Klassen- und Instanzvariablen zu veranschaulichen, also habe ich sie trotzdem eingefügt.
Hinweis: Ich verwende ein MacBook Pro mit OS X 10.8.5 und Python 2.7.2.
Initialisierung
10000000 calls to `Bar(2)`: 4.940s 10000000 calls to `Foo(2)`: 6.043s
Die Initialisierungen von Bar
sind um über eine Sekunde schneller, sodass der Unterschied hier statistisch signifikant zu sein scheint.
Warum ist das so? Eine spekulative Erklärung: Wir machen zwei Zuweisungen in Foo.__init__
, aber nur eine in Bar.__init__
.
Abtretung
10000000 calls to `Bar(2).y = 15`: 6.232s 10000000 calls to `Foo(2).y = 15`: 6.855s 10000000 `Bar` assignments: 6.232s - 4.940s = 1.292s 10000000 `Foo` assignments: 6.855s - 6.043s = 0.812s
Hinweis: Es gibt keine Möglichkeit, Ihren Einrichtungscode bei jedem Versuch mit timeit erneut auszuführen, daher müssen wir unsere Variable bei unserem Versuch neu initialisieren. Die zweite Zeitlinie stellt die obigen Zeiten dar, wobei die vorher berechneten Initialisierungszeiten abgezogen sind.
Aus dem Obigen sieht es so aus, als würde Foo
nur etwa 60 % so lange brauchen wie Bar
, um Aufgaben zu erledigen.
Warum ist das so? Eine spekulative Erklärung: Wenn wir Bar(2).y
, suchen wir zuerst im Instanz-Namensraum ( Bar(2).__dict__[y]
), finden y
nicht und suchen dann im Klassen-Namensraum ( Bar.__dict__[y]
), dann die richtige Zuweisung vornehmen. Wenn wir Foo(2).y
, führen wir halb so viele Suchen durch, wie wir sofort dem Instanznamensraum zuweisen ( Foo(2).__dict__[y]
).
Zusammenfassend lässt sich sagen, dass diese Tests auf konzeptioneller Ebene interessant sind, obwohl diese Leistungssteigerungen in der Realität keine Rolle spielen. Wenn überhaupt, hoffe ich, dass diese Unterschiede dazu beitragen, die mechanischen Unterschiede zwischen Klassen- und Instanzvariablen zu veranschaulichen.
Abschließend
Klassenattribute scheinen in Python zu wenig genutzt zu werden; Viele Programmierer haben unterschiedliche Vorstellungen davon, wie sie arbeiten und warum sie hilfreich sein könnten.
Meine Meinung: Python-Klassenvariablen haben ihren Platz in der Schule des guten Codes. Bei sorgfältiger Verwendung können sie Dinge vereinfachen und die Lesbarkeit verbessern. Aber wenn sie unachtsam in eine bestimmte Klasse geworfen werden, werden sie Sie sicher stolpern lassen.
Anhang : Private Instanzvariablen
Eine Sache, die ich einbeziehen wollte, aber keinen natürlichen Einstiegspunkt hatte …
Python hat sozusagen keine privaten Variablen, aber eine weitere interessante Beziehung zwischen Klassen- und Instanzbenennung ergibt sich aus der Namensverstümmelung.
Im Python-Styleguide heißt es, dass pseudo-private Variablen mit einem doppelten Unterstrich versehen werden sollten: „__“. Dies ist nicht nur ein Zeichen für andere, dass Ihre Variable privat behandelt werden soll, sondern auch eine Art, den Zugriff darauf zu verhindern. Hier ist, was ich meine:
class Bar(object): def __init__(self): self.__zap = 1 a = Bar() a.__zap ## Traceback (most recent call last): ## File "<stdin>", line 1, in <module> ## AttributeError: 'Bar' object has no attribute '__baz' ## Hmm. So what's in the namespace? a.__dict__ {'_Bar__zap': 1} a._Bar__zap ## 1
Sehen Sie sich das an: Dem __zap
wird automatisch der Klassenname vorangestellt, um _Bar__zap
zu ergeben.
Obwohl es immer noch mit a._Bar__zap
einstellbar und abrufbar ist, ist dieses Namensverstümmeln ein Mittel zum Erstellen einer "privaten" Variablen, da es Sie und andere daran hindert, versehentlich oder aus Unwissenheit darauf zuzugreifen.
Bearbeiten: Wie Pedro Werneck freundlicherweise darauf hingewiesen hat, soll dieses Verhalten hauptsächlich beim Unterklassen helfen. Im Styleguide von PEP 8 dienen sie zwei Zwecken: (1) Verhindern des Zugriffs von Unterklassen auf bestimmte Attribute und (2) Verhindern von Namespace-Konflikten in diesen Unterklassen. Das Variablen-Mangling ist zwar nützlich, sollte aber nicht als Aufforderung gesehen werden, Code mit einer angenommenen öffentlich-privaten Unterscheidung zu schreiben, wie es in Java der Fall ist.