Atributos de clase de Python: una guía demasiado detallada

Publicado: 2022-03-11

Recientemente tuve una entrevista de programación, una pantalla de teléfono en la que usamos un editor de texto colaborativo.

Me pidieron que implementara una determinada API y elegí hacerlo en Python. Abstrayendo la declaración del problema, digamos que necesitaba una clase cuyas instancias almacenaran algunos data y algunos other_data .

Respiré hondo y comencé a escribir. Después de unas pocas líneas, tenía algo como esto:

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

Mi entrevistador me detuvo:

  • Entrevistador: “Esa línea: data = [] . ¿No creo que eso sea Python válido?
  • Yo: “Estoy bastante seguro de que lo es. Simplemente establece un valor predeterminado para el atributo de la instancia”.
  • Entrevistador: "¿Cuándo se ejecuta ese código?"
  • Yo: “No estoy muy seguro. Lo arreglaré para evitar confusiones”.

Como referencia, y para darle una idea de lo que buscaba, así es como modifiqué el código:

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

Resulta que ambos estábamos equivocados. La verdadera respuesta radica en comprender la distinción entre los atributos de clase de Python y los atributos de instancia de Python.

Atributos de clase de Python frente a atributos de instancia de Python

Nota: si tiene un manejo experto en atributos de clase, puede pasar directamente a los casos de uso.

Atributos de clase de Python

Mi entrevistador se equivocó en que el código anterior es sintácticamente válido.

Yo también me equivoqué porque no está configurando un "valor predeterminado" para el atributo de instancia. En cambio, está definiendo data como un atributo de clase con valor [] .

En mi experiencia, los atributos de clase de Python son un tema sobre el que mucha gente sabe algo , pero pocos entienden por completo.

Variable de clase de Python frente a variable de instancia: ¿cuál es la diferencia?

Un atributo de clase de Python es un atributo de la clase (circular, lo sé), en lugar de un atributo de una instancia de una clase.

Usemos un ejemplo de clase de Python para ilustrar la diferencia. Aquí, class_var es un atributo de clase e i_var es un atributo de instancia:

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

Tenga en cuenta que todas las instancias de la clase tienen acceso a class_var y que también se puede acceder a ella como una propiedad de la propia clase :

 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

Para los programadores de Java o C++, el atributo de clase es similar, pero no idéntico, al miembro estático. Veremos en qué se diferencian más adelante.

Espacios de nombres de clase frente a instancia

Para comprender lo que sucede aquí, hablemos brevemente sobre los espacios de nombres de Python .

Un espacio de nombres es un mapeo de nombres a objetos, con la propiedad de que no hay relación entre nombres en diferentes espacios de nombres. Por lo general, se implementan como diccionarios de Python, aunque esto se abstrae.

Según el contexto, es posible que deba acceder a un espacio de nombres mediante la sintaxis de puntos (p. ej., object.name_from_objects_namespace ) o como una variable local (p. ej., object_from_namespace ). Como ejemplo concreto:

 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

Las clases de Python y las instancias de clases tienen cada una sus propios espacios de nombres distintos representados por atributos predefinidos MyClass.__dict__ e instance_of_MyClass.__dict__ , respectivamente.

Cuando intenta acceder a un atributo desde una instancia de una clase, primero busca el espacio de nombres de su instancia . Si encuentra el atributo, devuelve el valor asociado. De lo contrario , busca en el espacio de nombres de la clase y devuelve el atributo (si está presente, arrojando un error de lo contrario). Por ejemplo:

 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

El espacio de nombres de la instancia tiene supremacía sobre el espacio de nombres de la clase: si hay un atributo con el mismo nombre en ambos, el espacio de nombres de la instancia se verificará primero y se devolverá su valor. Aquí hay una versión simplificada del código (fuente) para la búsqueda de atributos:

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

Y, en forma visual:

búsqueda de atributos en forma visual

Cómo los atributos de clase manejan la asignación

Con esto en mente, podemos dar sentido a cómo los atributos de clase de Python manejan la asignación:

  • Si se establece un atributo de clase accediendo a la clase, anulará el valor para todas las instancias. Por ejemplo:

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

    En el nivel del espacio de nombres... estamos configurando MyClass.__dict__['class_var'] = 2 . (Nota: este no es el código exacto (que sería setattr(MyClass, 'class_var', 2) ) ya que __dict__ devuelve un dictproxy, un envoltorio inmutable que evita la asignación directa, pero ayuda a modo de demostración). Luego, cuando accedemos a foo.class_var , class_var tiene un nuevo valor en el espacio de nombres de la clase y, por lo tanto, se devuelve 2.

  • Si se establece una variable de clase de Paython accediendo a una instancia, anulará el valor solo para esa instancia . Básicamente, esto anula la variable de clase y la convierte en una variable de instancia disponible, intuitivamente, solo para esa instancia . Por ejemplo:

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

    En el nivel del espacio de nombres... estamos agregando el atributo class_var a foo.__dict__ , por lo que cuando buscamos foo.class_var , devolvemos 2. Mientras tanto, otras instancias de MyClass no tendrán class_var en sus espacios de nombres de instancia, por lo que continúan encontrando class_var en MyClass.__dict__ y así devolver 1.

Mutabilidad

Pregunta de prueba: ¿Qué sucede si su atributo de clase tiene un tipo mutable ? Puede manipular (¿mutilar?) el atributo de clase accediendo a él a través de una instancia en particular y, a su vez, terminar manipulando el objeto al que acceden todas las instancias (como señaló Timothy Wiseman).

Esto se demuestra mejor con un ejemplo. Volvamos al Service que definí anteriormente y veamos cómo mi uso de una variable de clase podría haber causado problemas en el futuro.

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

Mi objetivo era tener la lista vacía ( [] ) como el valor predeterminado para data , y que cada instancia de Service tuviera sus propios datos que se modificarían con el tiempo instancia por instancia. Pero en este caso, obtenemos el siguiente comportamiento (recuerde que Service toma algún argumento other_data , que es arbitrario en este ejemplo):

 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]

Esto no es bueno: ¡alterar la variable de clase a través de una instancia la altera para todas las demás!

A nivel de espacio de nombres... todas las instancias de Service acceden y modifican la misma lista en Service.__dict__ sin crear sus propios atributos de data en sus espacios de nombres de instancia.

Podríamos evitar esto usando asignación; es decir, en lugar de explotar la mutabilidad de la lista, podríamos asignar nuestros objetos de Service para tener sus propias listas, de la siguiente manera:

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

En este caso, agregamos s1.__dict__['data'] = [1] , por lo que el Service.__dict__['data'] original permanece sin cambios.

Desafortunadamente, esto requiere que los usuarios Service tengan un conocimiento profundo de sus variables y ciertamente es propenso a errores. En cierto sentido, estaríamos abordando los síntomas en lugar de la causa. Preferiríamos algo que fuera correcto por construcción.

Mi solución personal: si solo está usando una variable de clase para asignar un valor predeterminado a una posible variable de instancia de Python, no use valores mutables . En este caso, cada instancia de Service iba a anular Service.data con su propio atributo de instancia eventualmente, por lo que usar una lista vacía como predeterminada generaba un pequeño error que se pasaba por alto fácilmente. En lugar de lo anterior, podríamos haber:

  1. Apegado a los atributos de instancia por completo, como se demostró en la introducción.
  2. Se evitó usar la lista vacía (un valor mutable) como nuestro "predeterminado":

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

    Por supuesto, tendríamos que manejar el caso None apropiadamente, pero ese es un pequeño precio a pagar.

Entonces, ¿cuándo debería usar los atributos de clase de Python?

Los atributos de clase son complicados, pero veamos algunos casos en los que serían útiles:

  1. Almacenamiento de constantes . Como se puede acceder a los atributos de clase como atributos de la clase en sí, a menudo es bueno usarlos para almacenar constantes específicas de clase y de toda la clase. Por ejemplo:

     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. Definición de valores por defecto . Como ejemplo trivial, podríamos crear una lista limitada (es decir, una lista que solo puede contener una cierta cantidad de elementos o menos) y elegir tener un límite predeterminado de 10 elementos:

     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

    Luego, también podríamos crear instancias con sus propios límites específicos, asignándolos al atributo de limit de la instancia.

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

    Esto solo tiene sentido si desea que su instancia típica de MyClass contenga solo 10 elementos o menos; si le da a todas sus instancias límites diferentes, entonces el limit debe ser una variable de instancia. (Sin embargo, recuerde: tenga cuidado al usar valores mutables como valores predeterminados).

  3. Seguimiento de todos los datos en todas las instancias de una clase dada . Esto es algo específico, pero podría ver un escenario en el que es posible que desee acceder a una parte de los datos relacionados con cada instancia existente de una clase determinada.

    Para hacer el escenario más concreto, digamos que tenemos una clase Person y cada persona tiene un name . Queremos realizar un seguimiento de todos los nombres que se han utilizado. Un enfoque podría ser iterar sobre la lista de objetos del recolector de basura, pero es más sencillo usar variables de clase.

    Tenga en cuenta que, en este caso, solo se accederá a los names como una variable de clase, por lo que el valor predeterminado mutable es aceptable.

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

    Incluso podríamos usar este patrón de diseño para rastrear todas las instancias existentes de una clase determinada, en lugar de solo algunos datos asociados.

     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. Rendimiento (algo así como... ver más abajo).

Relacionado: Mejores prácticas y consejos de Python por parte de los desarrolladores de Toptal

Bajo el capó

Nota: si le preocupa el rendimiento a este nivel, es posible que no quiera usar Python en primer lugar, ya que las diferencias serán del orden de décimas de milisegundo, pero aún así es divertido hurgar un poco. y ayuda por el bien de la ilustración.

Recuerde que el espacio de nombres de una clase se crea y se completa en el momento de la definición de la clase. Eso significa que solo hacemos una asignación, siempre , para una variable de clase dada, mientras que las variables de instancia deben asignarse cada vez que se crea una nueva instancia. Tomemos un ejemplo.

 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"

Asignamos a Bar.y solo una vez, pero instance_of_Foo.y en cada llamada a __init__ .

Como evidencia adicional, usemos el desensamblador de 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

Cuando observamos el código de bytes, nuevamente es obvio que Foo.__init__ tiene que hacer dos asignaciones, mientras que Bar.__init__ solo hace una.

En la práctica, ¿cómo es realmente esta ganancia? Seré el primero en admitir que las pruebas de tiempo dependen en gran medida de factores a menudo incontrolables y que las diferencias entre ellas suelen ser difíciles de explicar con precisión.

Sin embargo, creo que estos pequeños fragmentos (que se ejecutan con el módulo timeit de Python) ayudan a ilustrar las diferencias entre las variables de clase y de instancia, por lo que los he incluido de todos modos.

Nota: estoy en una MacBook Pro con OS X 10.8.5 y Python 2.7.2.

Inicialización

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

Las inicializaciones de Bar son más rápidas en más de un segundo, por lo que la diferencia aquí parece ser estadísticamente significativa.

Entonces porqué es este el caso? Una explicación especulativa : hacemos dos asignaciones en Foo.__init__ , pero solo una en Bar.__init__ .

Asignación

 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

Nota: No hay forma de volver a ejecutar su código de configuración en cada prueba con timeit, por lo que tenemos que reinicializar nuestra variable en nuestra prueba. La segunda línea de tiempos representa los tiempos anteriores con los tiempos de inicialización calculados previamente deducidos.

De lo anterior, parece que Foo solo tarda alrededor del 60% del tiempo que tarda Bar en manejar las tareas.

¿Por qué es este el caso? Una explicación especulativa : cuando asignamos a Bar(2).y , primero buscamos en el espacio de nombres de la instancia ( Bar(2).__dict__[y] ), fallamos en encontrar y , y luego buscamos en el espacio de nombres de la clase ( Bar.__dict__[y] ), luego haciendo la asignación adecuada. Cuando asignamos a Foo(2).y , hacemos la mitad de búsquedas, ya que asignamos inmediatamente al espacio de nombres de la instancia ( Foo(2).__dict__[y] ).

En resumen, aunque estas ganancias de rendimiento no importan en la realidad, estas pruebas son interesantes a nivel conceptual. En todo caso, espero que estas diferencias ayuden a ilustrar las distinciones mecánicas entre las variables de clase y de instancia.

En conclusión

Los atributos de clase parecen estar infrautilizados en Python; muchos programadores tienen diferentes impresiones sobre cómo funcionan y por qué podrían ser útiles.

Mi opinión: las variables de clase de Python tienen su lugar dentro de la escuela de buen código. Cuando se usan con cuidado, pueden simplificar las cosas y mejorar la legibilidad. Pero cuando se lanzan sin cuidado a una clase determinada, seguramente te harán tropezar.

Apéndice : Variables de instancias privadas

Una cosa que quería incluir pero no tenía un punto de entrada natural...

Python no tiene variables privadas , por así decirlo, pero otra relación interesante entre la clase y la denominación de instancias viene con la manipulación de nombres.

En la guía de estilo de Python, se dice que las variables pseudoprivadas deben tener un prefijo con un guión bajo doble: '__'. Esto no es solo una señal para otros de que su variable debe ser tratada de forma privada, sino también una forma de evitar el acceso a ella. Esto es lo que quiero decir:

 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

Mire eso: el atributo de instancia __zap se prefija automáticamente con el nombre de la clase para producir _Bar__zap .

Si bien aún se puede configurar y obtener usando a._Bar__zap , esta modificación de nombres es un medio para crear una variable 'privada', ya que evita que usted y otros accedan a ella por accidente o por ignorancia.

Editar: como señaló amablemente Pedro Werneck, este comportamiento está destinado en gran medida a ayudar con la subclasificación. En la guía de estilo de PEP 8, lo ven con dos propósitos: (1) evitar que las subclases accedan a ciertos atributos y (2) evitar conflictos de espacio de nombres en estas subclases. Si bien es útil, la manipulación de variables no debe verse como una invitación a escribir código con una supuesta distinción entre público y privado, como la que se presenta en Java.

Relacionado: Vuélvase más avanzado: evite los 10 errores más comunes que cometen los programadores de Python