Python-Klassenattribute: Ein übermäßig gründlicher Leitfaden

Veröffentlicht: 2022-03-11

Ich 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.

Python-Klassenattribute vs. Python-Instanzattribute

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:

Attributsuche 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äre setattr(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 auf foo.class_var zugreifen, hat class_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 zu foo.__dict__ , wenn wir also nach foo.class_var suchen, geben wir 2 zurück. In der Zwischenzeit haben andere Instanzen von MyClass class_var nicht in ihren Instanz-Namespaces, sodass sie weiterhin class_var finden in MyClass.__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:

  1. Vollständig an Instanzattributen festgehalten, wie in der Einführung gezeigt.
  2. 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:

  1. 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
  2. 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, sollte limit eine Instanzvariable sein. (Denken Sie jedoch daran: Seien Sie vorsichtig, wenn Sie veränderliche Werte als Standardwerte verwenden.)

  3. 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 einen name . 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>]
  4. Leistung (irgendwie … siehe unten).

Siehe auch : Best Practices und Tipps für Python von Toptal-Entwicklern

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.

Verwandte Themen: Fortgeschrittener werden: Vermeiden Sie die 10 häufigsten Fehler, die Python-Programmierer machen