Атрибуты, словари и слоты в Python

Понимание атрибутов в Python

Python по своей природе является очень динамичным языком. Переменные не нужно объявлять, их можно добавлять в качестве атрибутов практически везде. Рассмотрим пустой класс, например, такой:

class MyClass:
    pass

Это полное определение класса в Python. Конечно, оно ничего не делает, но все же является верным.

В любой более поздний момент времени мы можем "наложить" атрибуты на наш класс следующим образом:

MyClass.class_attribute = 42

Класс имеет это новое class_attribute значение с этого момента.

Если мы инстанцируем этот класс с помощью my_object = MyClass(), мы можем убедиться, что значение class_attribute равно 42:

>>> my_object.class_attribute
42

Конечно, мы также можем добавить атрибуты к нашим экземплярам:

>>> my_object.instance_attribute = 21
>>> my_object.instance_attribute
21

Вы когда-нибудь задумывались, где хранятся эти атрибуты?

Явное лучше неявного. (из Zen of Python)

Python не был бы Python без четко определенного и настраиваемого поведения для атрибутов. Атрибуты "items" в Python хранятся в магическом атрибуте под названием __dict__. Мы можем получить к нему доступ следующим образом:

class MyClass:
    class_attribute = "Class"

    def __init__(self):
        self.instance_attribute = "Instance"

my_object = MyClass()

print(my_object.__dict__)
print(MyClass.__dict__)

Как видите, class_attribute хранится в __dict__ самого MyClass, тогда как instance_attribute хранится в __dict__ самого my_object.

Это означает, что при каждом обращении к my_object.instance_attribute Python будет искать сначала в my_object.__dict__, а затем в MyClass.__dict__. Если атрибут instance_attribute не будет найден ни в одном из словарей, то возникнет ошибка AttributeError.

Побочная заметка

Что такое "item" в Python? Вы видите, что каждая "item" в Python имеет атрибут __dict__, даже сам класс. Логически, класс типа MyClass имеет тип class, что означает, что сам класс является объектом типа class. Поскольку это может показаться непонятным, я использую разговорный термин "item".

"Взлом" атрибута __dict__

Как всегда в Python, атрибут __dict__ ведет себя как любой другой атрибут в Python. Поскольку Python - язык, предпочитающий передачу по ссылке, мы можем рассмотреть ошибку, которая встречается довольно часто и случайно. Рассмотрим класс AddressBook:

class AddressBook:
    addresses = []

Теперь давайте создадим несколько адресных книг и создадим несколько адресов:

alices_address_book = AddressBook()
alices_address_book.addresses.append(("Sherlock Holmes", "221B Baker St., London"))
alices_address_book.addresses.append(("Al Bundy", "9764 Jeopardy Lane, Chicago, Illinois"))


bobs_address_book = AddressBook()
bobs_address_book.addresses.append(("Bart Simpson", "742 Evergreen Terrace, Springfield, USA"))
bobs_address_book.addresses.append(("Hercule Poirot", "Apt. 56B, Whitehaven Mansions, Sandhurst Square, London W1"))

Интересно, что Элис и Боб теперь имеют одну адресную книгу:

>>> alices_address_book.addresses
[('Sherlock Holmes', '221B Baker St., London'),
 ('Al Bundy', '9764 Jeopardy Lane, Chicago, Illinois'),
 ('Bart Simpson', '742 Evergreen Terrace, Springfield, USA'),
 ('Hercule Poirot', 'Apt. 56B, Whitehaven Mansions, Sandhurst Square, London W1')]
>>> bobs_address_book.addresses
[('Sherlock Holmes', '221B Baker St., London'),
 ('Al Bundy', '9764 Jeopardy Lane, Chicago, Illinois'),
 ('Bart Simpson', '742 Evergreen Terrace, Springfield, USA'),
 ('Hercule Poirot', 'Apt. 56B, Whitehaven Mansions, Sandhurst Square, London W1')]

Это происходит потому, что атрибут addresses определен на уровне class. Пустой список создается только один раз (addresses = []), а именно, когда интерпретатор Python создает класс. Таким образом, для любого последующего экземпляра класса AddressBook тот же list будет ссылаться на addresses. Мы можем исправить эту ошибку, перенеся создание пустого list на уровень экземпляра следующим образом:

class AddressBook:
    def __init__(self):
        self.addresses = []

Переместив создание пустого списка в конструктор (метод __init__), новый список создается каждый раз, когда создается новый экземпляр AddressBook. Таким образом, экземпляры больше не будут непреднамеренно использовать один и тот же list.

Знакомство с Боргом

Можем ли мы как-то специально использовать это поведение? Существует ли сценарий использования, когда мы хотим, чтобы все экземпляры использовали одно и то же хранилище? Оказывается, есть! Существует паттерн проектирования , называемый синглтон . Это гарантирует, что во время выполнения программы существует только один экземпляр класса. Например, это может быть полезно, если используется для класса соединения с базой данных или хранилища конфигурации.

Обратите внимание на то, что вы должны использовать синглтонные классы только иногда, потому что они вводят некоторое глобальное состояние в вашу программу, что затрудняет изолированное тестирование отдельных компонентов вашей программы.

Каким образом в Pythonic можно реализовать паттерн singleton?

Рассмотрим этот класс:

class Borg:
    _shared = {}
    def __init__(self):
        self.__dict__ = self._shared

Этот класс имеет атрибут _shared, инициализированный как пустой массив. Из предыдущих параграфов мы знаем, что экземпляр dict является тем же объектом для класса . Тогда внутри конструктора (__init__) мы устанавливаем __dict__ экземпляра в этот общий словарь. В результате все динамически добавляемые атрибуты становятся общими для каждого экземпляра этого класса.

Проверим:

>>> borg_1 = Borg()
>>> borg_2 = Borg()
>>> 
>>> borg_1.value = 42
>>> borg_2.value 
42

Почему мы не можем установить __dict__ = {} непосредственно в класс, как например

class Borg:
    __dict__ = {}
>>> borg_1 = Borg()
>>> borg_2 = Borg()
>>> 
>>> borg_1.value = 42
>>> borg_2.value 
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Borg' object has no attribute 'value'

Это связано с тем, что в последнем случае мы задаем атрибут __dict__ самому классу class. Однако мы получаем доступ к атрибуту instance, набирая borg_2.value. Только когда атрибут __dict__ установлен на уровне instance, мы можем использовать наш шаблон Борга. Этого можно добиться, используя конструктор для изменения атрибута __dict__ на уровне экземпляра.

Использование памяти для атрибутов

Динамическое добавление атрибутов во время выполнения на уровне экземпляра или класса сопряжено с определенными затратами. Структура словаря занимает много памяти во внутреннем пространстве Python. В ситуациях, когда вы создаете много (тысячи) экземпляров, это может стать узким местом.

Однако, сначала о главном: Что такое слоты? Хотя в Python вы можете динамически добавлять атрибуты к "вещам", слоты ограничивают эту функциональность. Когда вы добавляете __slots__ атрибут к class, вы предварительно определяете, какие атрибуты-члены вы разрешаете. Давайте посмотрим:

class SlottedClass:
    __slots__ = ['value']
    def __init__(self, i):
        self.value = i

При таком определении любой экземпляр SlottedClass может обращаться только к атрибуту value. Доступ к другим (динамическим) атрибутам вызовет ошибку AttributeError:

>>> slotted = SlottedClass(42)
>>> slotted.value
42
>>> slotted.forbidden_value = 21
AttributeError: 'SlottedClass' object has no attribute 'forbidden_value'

Ограничение возможности динамического добавления атрибутов полезно для уменьшения ошибок во время выполнения, которые могут возникнуть из-за опечаток в именах атрибутов. Однако, что более важно, это ограничение уменьшит использование памяти вашим кодом - в некоторых случаях значительно. Давайте попробуем проверить это.

Мы создаем два класса, один щелевой и один нещелевой. Оба класса обращаются к атрибуту с именем value внутри своего метода __init__, и в случае класса со слотом это единственный атрибут в __slots__.

Мы создаем миллион экземпляров для каждого класса и храним эти экземпляры в списке. После этого мы смотрим на размер списка. Список экземпляров класса со щелью должен быть меньше.

import sys

class SlottedClass:
    __slots__ = ['value']
    def __init__(self, i):
        self.value = i

class UnSlottedClass:
    def __init__(self, i):
        self.value = i

slotted = []
for i in range(1_000_000):
    slotted.append(SlottedClass(i))
print(sys.getsizeof(slotted))

unslotted = []
for i in range(1_000_000):
    unslotted.append(UnSlottedClass(i))
print(sys.getsizeof(unslotted))

Однако для каждого списка мы получаем обратно значение 8448728. Как же нам сэкономить память, используя слоты?

Давайте воспользуемся модулем ipython-memory-usage, чтобы проверить, сколько памяти потребляется во время выполнения нашей тестовой программы.

In [1]: def slotted_fn():
   ...:     class SlottedClass:
   ...:         __slots__ = ["value"]
   ...:
   ...:         def __init__(self, i):
   ...:             self.value = i
   ...:
   ...:     slotted = []
   ...:     for i in range(1_000_000):
   ...:         slotted.append(SlottedClass(i))
   ...:     return slotted
   ...:
   ...:
   ...: def unslotted_fn():
   ...:     class UnSlottedClass:
   ...:         def __init__(self, i):
   ...:             self.value = i
   ...:
   ...:     unslotted = []
   ...:     for i in range(1_000_000):
   ...:         unslotted.append(UnSlottedClass(i))
   ...:     return unslotted
   ...:
   ...:
   ...: import ipython_memory_usage.ipython_memory_usage as imu
   ...:
   ...: imu.start_watching_memory()
In [1] used 0.0000 MiB RAM in 0.11s, peaked 0.00 MiB above current, total RAM usage 52.48 MiB

In [2]: slotted_fn()
Out[2]: ...
In [2] used 84.9766 MiB RAM in 0.73s, peaked 0.00 MiB above current, total RAM usage 139.00 MiB

In [3]: unslotted_fn()
Out[3]: ...
In [3] used 200.1562 MiB RAM in 0.84s, peaked 0.00 MiB above current, total RAM usage 339.16 MiB

Как видите, версия со слотами заняла всего 85 Мб оперативной памяти, а версия без слотов - более 200 Мб, хотя результирующий размер списков одинаков.

Причина этого заключается в том, как Python внутренне обрабатывает dict. Если не указывать __slots__, Python по умолчанию использует словарь для хранения атрибутов. Этот словарь динамичен по своей природе, может изменяться в размерах, должен быть организован по ключам и т.д. Поэтому Python требуется много памяти для управления словарем.

В щелевой версии класса ключевые характеристики dict больше не нужны, поскольку динамическое изменение размера больше не допускается. Таким образом, Python заранее выделяет память для атрибутов, упомянутых в __slots__.

https://bas.codes/posts/python-dict-slots

Вернуться на верх