Атрибуты класса 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.