Attributs de classe Python : un guide trop complet

Publié: 2022-03-11

J'ai eu un entretien de programmation récemment, un écran de téléphone dans lequel nous avons utilisé un éditeur de texte collaboratif.

On m'a demandé d'implémenter une certaine API et j'ai choisi de le faire en Python. En faisant abstraction de l'énoncé du problème, disons que j'avais besoin d'une classe dont les instances stockaient certaines data et d'autres other_data .

J'ai pris une profonde inspiration et j'ai commencé à taper. Après quelques lignes, j'ai eu quelque chose comme ça:

 class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...

Mon interlocuteur m'a arrêté :

  • Intervieweur : « Cette ligne : data = [] . Je ne pense pas que ce soit Python valide ? »
  • Moi : « J'en suis presque sûr. Il s'agit simplement de définir une valeur par défaut pour l'attribut d'instance. »
  • Interviewer : "Quand ce code est-il exécuté ?"
  • Moi : « Je ne suis pas vraiment sûr. Je vais juste arranger ça pour éviter toute confusion.

Pour référence, et pour vous donner une idée de ce que je recherchais, voici comment j'ai modifié le code :

 class Service(object): def __init__(self, other_data): self.data = [] self.other_data = other_data ...

Il s'avère que nous avions tous les deux tort. La vraie réponse réside dans la compréhension de la distinction entre les attributs de classe Python et les attributs d'instance Python.

Attributs de classe Python et attributs d'instance Python

Remarque : si vous maîtrisez parfaitement les attributs de classe, vous pouvez passer directement aux cas d'utilisation.

Attributs de classe Python

Mon intervieweur s'est trompé en ce que le code ci-dessus est syntaxiquement valide.

Moi aussi, j'avais tort en ce sens qu'il ne définissait pas de "valeur par défaut" pour l'attribut d'instance. Au lieu de cela, il définit les data comme un attribut de classe avec la valeur [] .

D'après mon expérience, les attributs de classe Python sont un sujet que beaucoup de gens connaissent, mais que peu comprennent complètement.

Variable de classe Python vs variable d'instance : quelle est la différence ?

Un attribut de classe Python est un attribut de la classe (circulaire, je sais), plutôt qu'un attribut d'une instance d'une classe.

Utilisons un exemple de classe Python pour illustrer la différence. Ici, class_var est un attribut de classe et i_var est un attribut d'instance :

 class MyClass(object): class_var = 1 def __init__(self, i_var): self.i_var = i_var

Notez que toutes les instances de la classe ont accès à class_var , et qu'il est également possible d'y accéder en tant que propriété de la classe elle-même :

 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

Pour les programmeurs Java ou C++, l'attribut class est similaire, mais pas identique, au membre statique. Nous verrons comment ils diffèrent plus tard.

Espaces de noms de classe et d'instance

Pour comprendre ce qui se passe ici, parlons brièvement des espaces de noms Python .

Un espace de noms est un mappage de noms à des objets, avec la propriété qu'il n'y a aucune relation entre les noms dans différents espaces de noms. Ils sont généralement implémentés sous forme de dictionnaires Python, bien que cela soit abstrait.

Selon le contexte, vous devrez peut-être accéder à un espace de noms en utilisant la syntaxe à points (par exemple, object.name_from_objects_namespace ) ou en tant que variable locale (par exemple, object_from_namespace ). A titre d'exemple concret :

 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

Les classes Python et les instances de classes ont chacune leurs propres espaces de noms distincts représentés par des attributs MyClass.__dict__ et instance_of_MyClass.__dict__ , respectivement.

Lorsque vous essayez d'accéder à un attribut à partir d'une instance d'une classe, il examine d'abord son espace de noms d' instance . S'il trouve l'attribut, il renvoie la valeur associée. Si ce n'est pas le cas, il regarde alors dans l'espace de noms de la classe et renvoie l'attribut (s'il est présent, sinon il génère une erreur). Par exemple:

 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

L'espace de noms d'instance prend le pas sur l'espace de noms de classe : s'il existe un attribut portant le même nom dans les deux, l'espace de noms d'instance sera vérifié en premier et sa valeur renvoyée. Voici une version simplifiée du code (source) pour la recherche d'attribut :

 def instlookup(inst, name): ## simplified algorithm... if inst.__dict__.has_key(name): return inst.__dict__[name] else: return inst.__class__.__dict__[name]

Et, sous forme visuelle :

recherche d'attribut sous forme visuelle

Comment les attributs de classe gèrent l'affectation

Dans cet esprit, nous pouvons comprendre comment les attributs de classe Python gèrent l'affectation :

  • Si un attribut de classe est défini en accédant à la classe, il remplacera la valeur de toutes les instances. Par exemple:

     foo = MyClass(2) foo.class_var ## 1 MyClass.class_var = 2 foo.class_var ## 2

    Au niveau de l'espace de noms… nous définissons MyClass.__dict__['class_var'] = 2 . (Remarque: ce n'est pas le code exact (qui serait setattr(MyClass, 'class_var', 2) ) car __dict__ renvoie un dictproxy, un wrapper immuable qui empêche l'affectation directe, mais cela aide à des fins de démonstration). Ensuite, lorsque nous accédons à foo.class_var , class_var a une nouvelle valeur dans l'espace de noms de classe et donc 2 est renvoyé.

  • Si une variable de classe Paython est définie en accédant à une instance, elle remplacera la valeur uniquement pour cette instance . Cela remplace essentiellement la variable de classe et la transforme en une variable d'instance disponible, intuitivement, uniquement pour cette instance . Par exemple:

     foo = MyClass(2) foo.class_var ## 1 foo.class_var = 2 foo.class_var ## 2 MyClass.class_var ## 1

    Au niveau de l'espace de noms… nous ajoutons l'attribut class_var à foo.__dict__ , donc quand nous recherchons foo.class_var , nous renvoyons 2. Pendant ce temps, les autres instances de MyClass n'auront pas class_var dans leurs espaces de noms d'instance, donc elles continuent à trouver class_var dans MyClass.__dict__ et retourne donc 1.

Mutabilité

Question du quiz : Que se passe-t-il si votre attribut de classe a un type mutable ? Vous pouvez manipuler (mutiler ?) L'attribut de classe en y accédant via une instance particulière et, à son tour, finir par manipuler l'objet référencé auquel toutes les instances accèdent (comme l'a souligné Timothy Wiseman).

Ceci est mieux démontré par l'exemple. Revenons au Service que j'ai défini plus tôt et voyons comment mon utilisation d'une variable de classe aurait pu entraîner des problèmes sur la route.

 class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...

Mon objectif était d'avoir la liste vide ( [] ) comme valeur par défaut pour data , et pour chaque instance de Service d'avoir ses propres données qui seraient modifiées au fil du temps, instance par instance. Mais dans ce cas, nous obtenons le comportement suivant (rappelons que Service prend un argument other_data , qui est arbitraire dans cet exemple) :

 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]

Cela ne sert à rien - modifier la variable de classe via une instance la modifie pour toutes les autres !

Au niveau de l'espace de noms… toutes les instances de Service accèdent et modifient la même liste dans Service.__dict__ sans créer leurs propres attributs de data dans leurs espaces de noms d'instance.

Nous pourrions contourner ce problème en utilisant l'affectation ; c'est-à-dire qu'au lieu d'exploiter la mutabilité de la liste, nous pourrions attribuer à nos objets Service leur propre liste, comme suit :

 s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data = [1] s2.data = [2] s1.data ## [1] s2.data ## [2]

Dans ce cas, nous ajoutons s1.__dict__['data'] = [1] , donc le Service.__dict__['data'] origine reste inchangé.

Malheureusement, cela nécessite que les utilisateurs Service aient une connaissance intime de ses variables et est certainement sujet aux erreurs. En un sens, on s'attaquerait aux symptômes plutôt qu'à la cause. Nous préférerions quelque chose qui était correct par construction.

Ma solution personnelle : si vous utilisez simplement une variable de classe pour attribuer une valeur par défaut à une variable d'instance Python potentielle, n'utilisez pas de valeurs mutables . Dans ce cas, chaque instance de Service allait éventuellement remplacer Service.data par son propre attribut d'instance, donc l'utilisation d'une liste vide comme valeur par défaut a conduit à un petit bogue qui a été facilement négligé. Au lieu de ce qui précède, nous aurions pu soit :

  1. Collé aux attributs d'instance entièrement, comme démontré dans l'introduction.
  2. Évité d'utiliser la liste vide (une valeur mutable) comme notre "défaut":

     class Service(object): data = None def __init__(self, other_data): self.other_data = other_data ...

    Bien sûr, nous devrons gérer le cas None de manière appropriée, mais c'est un petit prix à payer.

Alors, quand devriez-vous utiliser les attributs de classe Python ?

Les attributs de classe sont délicats, mais examinons quelques cas où ils seraient utiles :

  1. Stockage des constantes . Comme les attributs de classe sont accessibles en tant qu'attributs de la classe elle-même, il est souvent agréable de les utiliser pour stocker des constantes spécifiques à la classe et à l'échelle de la classe. Par exemple:

     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. Définition des valeurs par défaut . À titre d'exemple trivial, nous pourrions créer une liste délimitée (c'est-à-dire une liste qui ne peut contenir qu'un certain nombre d'éléments ou moins) et choisir d'avoir un plafond par défaut de 10 éléments :

     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

    Nous pourrions ensuite créer des instances avec leurs propres limites spécifiques également en les attribuant à l'attribut limit de l'instance.

     foo = MyClass() foo.limit = 50 ## foo can now hold 50 elements—other instances can hold 10

    Cela n'a de sens que si vous voulez que votre instance typique de MyClass ne contienne que 10 éléments ou moins - si vous donnez à toutes vos instances des limites différentes, alors limit devrait être une variable d'instance. (Rappelez-vous cependant : soyez prudent lorsque vous utilisez des valeurs modifiables par défaut.)

  3. Suivi de toutes les données sur toutes les instances d'une classe donnée . C'est en quelque sorte spécifique, mais je pourrais voir un scénario dans lequel vous pourriez vouloir accéder à un élément de données lié à chaque instance existante d'une classe donnée.

    Pour rendre le scénario plus concret, supposons que nous ayons une classe Person et que chaque personne ait un name . Nous voulons garder une trace de tous les noms qui ont été utilisés. Une approche peut consister à parcourir la liste d'objets du ramasse-miettes, mais il est plus simple d'utiliser des variables de classe.

    Notez que, dans ce cas, les names ne seront accessibles qu'en tant que variable de classe, donc la valeur mutable par défaut est acceptable.

     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']

    Nous pourrions même utiliser ce modèle de conception pour suivre toutes les instances existantes d'une classe donnée, plutôt que seulement certaines données associées.

     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. Performance (en quelque sorte… voir ci-dessous).

Connexes : Meilleures pratiques et astuces Python par les développeurs Toptal

Sous la capuche

Remarque : Si vous vous inquiétez des performances à ce niveau, vous ne voudrez peut-être pas utiliser Python en premier lieu, car les différences seront de l'ordre de dixièmes de milliseconde, mais c'est toujours amusant de fouiller un peu, et aide à titre d'illustration.

Rappelez-vous que l'espace de noms d'une classe est créé et rempli au moment de la définition de la classe. Cela signifie que nous ne faisons qu'une seule affectation - jamais - pour une variable de classe donnée, tandis que les variables d'instance doivent être affectées à chaque fois qu'une nouvelle instance est créée. Prenons un exemple.

 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"

Nous attribuons à Bar.y une seule fois, mais instance_of_Foo.y à chaque appel à __init__ .

Comme preuve supplémentaire, utilisons le désassembleur Python :

 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

Lorsque nous regardons le bytecode, il est à nouveau évident que Foo.__init__ doit faire deux affectations, tandis que Bar.__init__ n'en fait qu'une.

En pratique, à quoi ressemble réellement ce gain ? Je serai le premier à admettre que les tests de chronométrage dépendent fortement de facteurs souvent incontrôlables et que les différences entre eux sont souvent difficiles à expliquer avec précision.

Cependant, je pense que ces petits extraits (exécutés avec le module Python timeit) aident à illustrer les différences entre les variables de classe et d'instance, donc je les ai quand même inclus.

Remarque : je suis sur un MacBook Pro avec OS X 10.8.5 et Python 2.7.2.

Initialisation

 10000000 calls to `Bar(2)`: 4.940s 10000000 calls to `Foo(2)`: 6.043s

Les initialisations de Bar sont plus rapides de plus d'une seconde, donc la différence ici semble être statistiquement significative.

Alors pourquoi est-ce le cas? Une explication spéculative : nous effectuons deux affectations dans Foo.__init__ , mais une seule dans Bar.__init__ .

Mission

 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

Remarque : il n'y a aucun moyen de réexécuter votre code d'installation à chaque essai avec timeit, nous devons donc réinitialiser notre variable sur notre essai. La deuxième ligne de temps représente les temps ci-dessus avec les temps d'initialisation précédemment calculés déduits.

D'après ce qui précède, il semble que Foo ne prenne qu'environ 60% du temps que Bar gère les affectations.

pourquoi est-ce le cas? Une explication spéculative : lorsque nous attribuons à Bar(2).y , nous regardons d'abord dans l'espace de noms d'instance ( Bar(2).__dict__[y] ), ne parvenons pas à trouver y , puis regardons dans l'espace de noms de classe ( Bar.__dict__[y] ), puis en faisant la bonne affectation. Lorsque nous attribuons à Foo(2).y , nous effectuons deux fois moins de recherches que nous attribuons immédiatement à l'espace de noms d'instance ( Foo(2).__dict__[y] ).

En résumé, même si ces gains de performances n'auront pas d'importance dans la réalité, ces tests sont intéressants au niveau conceptuel. Au contraire, j'espère que ces différences aideront à illustrer les distinctions mécaniques entre les variables de classe et d'instance.

En conclusion

Les attributs de classe semblent être sous-utilisés en Python ; beaucoup de programmeurs ont des impressions différentes sur la façon dont ils fonctionnent et pourquoi ils pourraient être utiles.

Mon point de vue : les variables de classe Python ont leur place dans l'école du bon code. Utilisés avec précaution, ils peuvent simplifier les choses et améliorer la lisibilité. Mais lorsqu'ils sont jetés négligemment dans une classe donnée, ils sont sûrs de vous faire trébucher.

Annexe : Variables d'instance privée

Une chose que je voulais inclure mais je n'avais pas de point d'entrée naturel…

Python n'a pas de variables privées pour ainsi dire, mais une autre relation intéressante entre la dénomination de classe et d'instance vient avec la manipulation de noms.

Dans le guide de style Python, il est dit que les variables pseudo-privées doivent être préfixées d'un double trait de soulignement : '__'. Ce n'est pas seulement un signe pour les autres que votre variable est destinée à être traitée en privé, mais aussi un moyen d'en empêcher l'accès, en quelque sorte. Voici ce que je veux dire :

 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

Regardez ça : l'attribut d'instance __zap est automatiquement préfixé avec le nom de la classe pour donner _Bar__zap .

Bien qu'il soit toujours paramétrable et accessible à l'aide a._Bar__zap , ce nom mangling est un moyen de créer une variable "privée" car il vous empêche, ainsi qu'aux autres, d'y accéder par accident ou par ignorance.

Edit: comme Pedro Werneck l'a gentiment souligné, ce comportement est en grande partie destiné à aider au sous-classement. Dans le guide de style PEP 8, ils y voient deux objectifs : (1) empêcher les sous-classes d'accéder à certains attributs et (2) empêcher les conflits d'espaces de noms dans ces sous-classes. Bien qu'utile, la modification des variables ne doit pas être considérée comme une invitation à écrire du code avec une distinction supposée public-privé, comme c'est le cas en Java.

Connexe : Devenez plus avancé : évitez les 10 erreurs les plus courantes commises par les programmeurs Python