Атрибуты класса Python: чрезмерно подробное руководство
Опубликовано: 2022-03-11Недавно у меня было собеседование по программированию, экран телефона, в котором мы использовали совместный текстовый редактор.
Меня попросили реализовать определенный API, и я решил сделать это на Python. Абстрагируя формулировку проблемы, скажем, мне нужен класс, экземпляры которого хранят некоторые data и некоторые other_data .
Я глубоко вздохнул и начал печатать. После нескольких строк у меня было что-то вроде этого:
class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...Мой собеседник остановил меня:
- Интервьюер: «Эта строка:
data = []. Я не думаю, что это правильный Python?» - Я: «Я почти уверен, что это так. Это просто установка значения по умолчанию для атрибута экземпляра».
- Интервьюер: «Когда выполняется этот код?»
- Я: «Я не совсем уверен. Я просто исправлю это, чтобы избежать путаницы».
Для справки и чтобы дать вам представление о том, что я собирался сделать, вот как я изменил код:
class Service(object): def __init__(self, other_data): self.data = [] self.other_data = other_data ...Как оказалось, мы оба были неправы. Реальный ответ заключается в понимании различия между атрибутами класса Python и атрибутами экземпляра Python.
Примечание. Если у вас есть опыт работы с атрибутами класса, вы можете сразу перейти к вариантам использования.
Атрибуты класса Python
Мой интервьюер был неправ в том, что приведенный выше код синтаксически корректен.
Я тоже был неправ в том, что он не устанавливает «значение по умолчанию» для атрибута экземпляра. Вместо этого он определяет data как атрибут класса со значением [] .
По моему опыту, атрибуты классов Python — это тема, о которой многие люди что- то знают, но лишь немногие понимают ее полностью.
Переменная класса Python и переменная экземпляра: в чем разница?
Атрибут класса Python — это атрибут класса (круговой, я знаю), а не атрибут экземпляра класса.
Давайте используем пример класса Python, чтобы проиллюстрировать разницу. Здесь class_var — атрибут класса, а i_var — атрибут экземпляра:
class MyClass(object): class_var = 1 def __init__(self, i_var): self.i_var = i_var Обратите внимание, что все экземпляры класса имеют доступ к class_var , и что к нему также можно получить доступ как к свойству самого класса :
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Для программистов на Java или C++ атрибут класса подобен, но не идентичен статическому члену. Мы увидим, как они отличаются позже.
Пространства имен классов и экземпляров
Чтобы понять, что здесь происходит, давайте кратко поговорим о пространствах имен Python .
Пространство имен — это сопоставление имен с объектами со свойством нулевой связи между именами в разных пространствах имен. Обычно они реализуются как словари Python, хотя это и абстрагируется.
В зависимости от контекста вам может потребоваться доступ к пространству имен с использованием точечного синтаксиса (например, object.name_from_objects_namespace ) или в виде локальной переменной (например, object_from_namespace ). В качестве конкретного примера:
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 имеют свои собственные пространства имен, представленные предопределенными атрибутами MyClass.__dict__ и instance_of_MyClass.__dict__ соответственно.
Когда вы пытаетесь получить доступ к атрибуту из экземпляра класса, он сначала просматривает пространство имен своего экземпляра . Если он находит атрибут, он возвращает связанное значение. Если нет, то он просматривает пространство имен класса и возвращает атрибут (если он присутствует, в противном случае выдает ошибку). Например:
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Пространство имен экземпляра имеет приоритет над пространством имен класса: если в обоих есть атрибут с одинаковым именем, первым будет проверено пространство имен экземпляра и возвращено его значение. Вот упрощенная версия кода (исходный код) для поиска атрибутов:
def instlookup(inst, name): ## simplified algorithm... if inst.__dict__.has_key(name): return inst.__dict__[name] else: return inst.__class__.__dict__[name]И, в визуальной форме:
Как атрибуты класса обрабатывают назначение
Имея это в виду, мы можем понять, как атрибуты класса Python обрабатывают назначение:
Если атрибут класса установлен путем доступа к классу, он переопределит значение для всех экземпляров. Например:
foo = MyClass(2) foo.class_var ## 1 MyClass.class_var = 2 foo.class_var ## 2На уровне пространства имен… мы устанавливаем
MyClass.__dict__['class_var'] = 2. (Примечание: это не точный код (который будетsetattr(MyClass, 'class_var', 2)) , поскольку__dict__возвращает dictproxy, неизменяемую оболочку, которая предотвращает прямое назначение, но помогает для демонстрации). Затем, когда мы обращаемся кfoo.class_var,class_varимеет новое значение в пространстве имен класса, и поэтому возвращается 2.Если переменная класса Paython установлена путем доступа к экземпляру, она переопределит значение только для этого экземпляра . Это существенно переопределяет переменную класса и превращает ее в переменную экземпляра, интуитивно доступную только для этого экземпляра . Например:
foo = MyClass(2) foo.class_var ## 1 foo.class_var = 2 foo.class_var ## 2 MyClass.class_var ## 1На уровне пространства имен… мы добавляем атрибут
class_varкfoo.__dict__, поэтому при поискеfoo.class_varмы возвращаем 2. Между тем, другие экземплярыMyClassне будут иметьclass_varв своих пространствах имен экземпляров, поэтому они продолжают находитьclass_varвMyClass.__dict__и, таким образом, вернуть 1.
Изменчивость
Вопрос викторины: что делать, если ваш атрибут класса имеет изменяемый тип ? Вы можете манипулировать (искажать?) атрибутом класса, обращаясь к нему через конкретный экземпляр и, в свою очередь, в конечном итоге манипулировать ссылочным объектом, к которому обращаются все экземпляры (как указал Тимоти Уайзман).
Лучше всего это показать на примере. Давайте вернемся к Service , который я определил ранее, и посмотрим, как мое использование переменной класса могло привести к проблемам в будущем.
class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ... Моя цель состояла в том, чтобы иметь пустой список ( [] ) в качестве значения по умолчанию для data и для каждого экземпляра Service иметь свои собственные данные , которые будут изменяться с течением времени для каждого экземпляра. Но в этом случае мы получаем следующее поведение (напомним, что Service принимает некоторый аргумент other_data , который в данном примере произвольный):
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]Это нехорошо — изменение переменной класса через один экземпляр изменяет ее для всех остальных!
На уровне пространства имен… все экземпляры Service обращаются к одному и тому же списку в Service.__dict__ и изменяют его, не создавая собственные атрибуты data в своих пространствах имен экземпляров.
Мы могли бы обойти это, используя присваивание; то есть вместо того, чтобы использовать изменчивость списка, мы могли бы назначить нашим объектам Service собственные списки, как показано ниже:
s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data = [1] s2.data = [2] s1.data ## [1] s2.data ## [2] В этом случае мы добавляем s1.__dict__['data'] = [1] , поэтому исходный Service.__dict__['data'] остается неизменным.
К сожалению, для этого требуется, чтобы пользователи Service хорошо знали его переменные, и, безусловно, подвержены ошибкам. В каком-то смысле мы будем бороться с симптомами, а не с причиной. Мы бы предпочли что-то правильное по конструкции.
Мое личное решение: если вы просто используете переменную класса для присвоения значения по умолчанию потенциальной переменной экземпляра Python, не используйте изменяемые значения . В этом случае каждый экземпляр Service должен был в конечном итоге переопределить Service.data своим собственным атрибутом экземпляра, поэтому использование пустого списка по умолчанию привело к крошечной ошибке, которую легко было не заметить. Вместо вышеперечисленного мы могли бы:
- Полностью привязан к атрибутам экземпляра, как показано во введении.
Избегали использования пустого списка (изменяемое значение) в качестве «по умолчанию»:
class Service(object): data = None def __init__(self, other_data): self.other_data = other_data ...Конечно, нам пришлось бы соответствующим образом обрабатывать случай
None, но это небольшая цена.
Итак, когда следует использовать атрибуты класса Python?
Атрибуты класса сложны, но давайте рассмотрим несколько случаев, когда они могут пригодиться:
Хранение констант . Поскольку доступ к атрибутам класса можно получить как к атрибутам самого класса, часто удобно использовать их для хранения констант, специфичных для всего класса. Например:
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Определение значений по умолчанию . В качестве тривиального примера мы можем создать ограниченный список (т. е. список, который может содержать только определенное количество элементов или меньше) и выбрать ограничение по умолчанию, равное 10 элементам:
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Затем мы могли бы также создавать экземпляры с их собственными ограничениями, назначая их атрибуту
limitэкземпляра.foo = MyClass() foo.limit = 50 ## foo can now hold 50 elements—other instances can hold 10Это имеет смысл только в том случае, если вы хотите, чтобы ваш типичный экземпляр
MyClassвсего 10 элементов или меньше — если вы устанавливаете для всех своих экземпляров разные ограничения, тогдаlimitдолжен быть переменной экземпляра. (Помните, однако: будьте осторожны при использовании изменяемых значений по умолчанию.)Отслеживание всех данных по всем экземплярам данного класса . Это немного специфично, но я мог видеть сценарий, в котором вы могли бы захотеть получить доступ к фрагменту данных, относящемуся к каждому существующему экземпляру данного класса.
Чтобы сделать сценарий более конкретным, предположим, что у нас есть класс
Person, и у каждого человека естьname. Мы хотим отслеживать все имена, которые были использованы. Один из подходов может заключаться в переборе списка объектов сборщика мусора, но проще использовать переменные класса.Обратите внимание, что в этом случае
namesбудут доступны только как переменная класса, поэтому допустимо изменяемое значение по умолчанию.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']Мы могли бы даже использовать этот шаблон проектирования для отслеживания всех существующих экземпляров данного класса, а не только некоторых связанных данных.
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>]Производительность (вроде… см. ниже).
Под капотом
Примечание. Если вы беспокоитесь о производительности на этом уровне, возможно, вы вообще не захотите использовать Python, так как разница будет порядка десятых долей миллисекунды, но все равно интересно немного поковыряться, и помогает для иллюстрации.
Напомним, что пространство имен класса создается и заполняется во время определения класса. Это означает, что мы делаем только одно присваивание — всегда — для данной переменной класса, в то время как переменные экземпляра должны присваиваться каждый раз при создании нового экземпляра. Возьмем пример.
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" Мы назначаем Bar.y только один раз, но instance_of_Foo.y при каждом вызове __init__ .
В качестве дополнительного доказательства воспользуемся дизассемблером 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 Когда мы смотрим на байтовый код, снова становится очевидным, что Foo.__init__ должен выполнять два присваивания, а Bar.__init__ — только одно.
Как на практике выглядит этот выигрыш? Я буду первым, кто признает, что временные тесты сильно зависят от часто неконтролируемых факторов, и различия между ними часто трудно точно объяснить.
Однако я думаю, что эти небольшие фрагменты (запущенные с модулем timeit Python) помогают проиллюстрировать различия между переменными класса и экземпляра, поэтому я все равно включил их.
Примечание. У меня MacBook Pro с OS X 10.8.5 и Python 2.7.2.
Инициализация
10000000 calls to `Bar(2)`: 4.940s 10000000 calls to `Foo(2)`: 6.043s Инициализация Bar происходит быстрее более чем на секунду, поэтому разница здесь кажется статистически значимой.
Так почему же это так? Одно умозрительное объяснение: мы делаем два присваивания в Foo.__init__ , но только одно в Bar.__init__ .
Назначение
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Примечание. Невозможно повторно запустить код установки в каждой пробной версии с помощью timeit, поэтому нам нужно повторно инициализировать нашу переменную в нашей пробной версии. Вторая строка времени представляет указанное выше время за вычетом ранее рассчитанного времени инициализации.
Из вышеизложенного видно, что Foo всего около 60% времени, пока Bar обрабатывает задания.
Почему это так? Одно умозрительное объяснение: когда мы присваиваем значение Bar(2).y , мы сначала смотрим в пространство имен экземпляра ( Bar(2).__dict__[y] ), не можем найти y , а затем смотрим в пространство имен класса ( Bar.__dict__[y] ), затем выполните правильное присвоение. Когда мы присваиваем Foo(2).y , мы делаем вдвое меньше запросов, чем сразу присваиваем пространству имен экземпляра ( Foo(2).__dict__[y] ).
Таким образом, хотя в действительности эти приросты производительности не будут иметь значения, эти тесты интересны на концептуальном уровне. Во всяком случае, я надеюсь, что эти различия помогут проиллюстрировать механические различия между переменными класса и экземпляра.
В заключение
Атрибуты класса, похоже, недостаточно используются в Python; у многих программистов разные представления о том, как они работают и почему они могут быть полезны.
Мое мнение: переменные класса Python занимают свое место в школе хорошего кода. При осторожном использовании они могут упростить вещи и улучшить читаемость. Но когда их небрежно бросают в данный класс, они обязательно сбивают вас с толку.
Приложение : переменные частного экземпляра
Одна вещь, которую я хотел включить, но не имел естественной точки входа…
В Python, так сказать, нет приватных переменных, но еще одна интересная взаимосвязь между именами классов и экземпляров связана с искажением имен.
В руководстве по стилю Python сказано, что псевдоприватные переменные должны начинаться с двойного подчеркивания: '__'. Это не только знак для других, что ваша переменная предназначена для частной обработки, но и своего рода способ предотвратить доступ к ней. Вот что я имею в виду:
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 Посмотрите на это: атрибут экземпляра __zap автоматически имеет префикс имени класса, чтобы получить _Bar__zap .
Хотя это изменение имени по-прежнему можно установить и получить с помощью a._Bar__zap , оно является средством создания «частной» переменной, поскольку оно предотвращает доступ к ней вам и другим людям случайно или по незнанию.
Редактировать: как любезно заметил Педро Вернек, это поведение в значительной степени предназначено для помощи в создании подклассов. В руководстве по стилю PEP 8 они видят, что это служит двум целям: (1) предотвращение доступа подклассов к определенным атрибутам и (2) предотвращение конфликтов пространств имен в этих подклассах. Несмотря на то, что изменение переменных полезно, его не следует рассматривать как приглашение к написанию кода с предполагаемым разделением между общим и частным, как это присутствует в Java.
