Зачем в Python используется __dict__ и __slots__

Слышали ли вы когда-то о шаблонах проектирования? Возможно вам приходилось видеть в коде на Python атрибуты __dict__ и __slots__. В этой статье разберемся, что это за атрибуты и как они используются в шаблонах проектирования.

Что за атрибуты в Python

Python — это очень динамический язык по своей натуре. Переменные (правильнее было бы сказать идентификаторы) не нужно объявлять и могут быть добавлены как атрибуты в любом месте. Рассмотрим пустой класс:

class MyClass:
    pass

В любом месте программы мы можем через класс создать атрибут, например, так:

MyClass.class_attribute = 42

Теперь у класса есть атрибут class_attribute. Можно это проверить через инстанциирование класса:

>>> my_object = MyClass()
>>> my_object.class_attribute
42

Код курса
DPREP
Ближайшая дата курса
по запросу
Продолжительность
ак.часов
Стоимость обучения
0 руб.

Конечно, мы также можем создать атрибут для конкретного объекта:

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

Разберемся, как это работает подробней.

Каким образом в Python хранятся атрибуты

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

class MyClass:
    class_attribute = "Class"

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

my_object = MyClass()

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

"""
{'instance_attribute': 'Instance'}
{'__module__': '__main__', 'class_attribute': 'Class', '__init__': <function MyClass.__init__ at 0x7f57d910d280>, '__dict__': <attribute '__dict__' of 'MyClass' objects>, '__weakref__': <attribute '__weakref__' of 'MyClass' objects>, '__doc__': None}
"""

Как можно заметить, class_attribute хранится в словаре всего класса, а instance_attribute хранится для объекта. Действительно, конструктор создается именно для объекта.

Это означает, что все в Python имеет атрибут __dict__, включая классы и функции. Кроме того, нужно понимать, что всё в Python — это объект, к которому можно обращаться.

Что такое __dict__

__dict__ ведет себя так же, как и все атрибуты в Python. Давайте рассмотрим проблему изменяемых переменных на примере списка:

class AddressBook:
    addresses = []

Создадим несколько экземпляров этого класса и заполним список. Код на Python:

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


bobs= AddressBook()
bobs.addresses.append(("Bart Simpson", "742 Evergreen Terrace, Springfield, USA"))
bobs.addresses.append(("Hercule Poirot", "Apt. 56B, Whitehaven"))

Теперь эти объекты делят один список на двоих:

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

Это происходит потому, что список определен на уровне класса. Пустой список создается только один раз: в тот момент когда интерпретатор создает этот самый класс. Кроме того, список является изменяемым объектом (mutable), о них мы говорили в этой статье.

Мы можем создавать список на уровне экземпляра класса, добавив его в конструктор:

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

Теперь список будет создаваться в момент инстанциирования, т.е. когда создается экземпляр класса.

Введение в шаблон проектирования Borg

Можем ли мы где-то использовать данное поведение? Есть ли случаи, когда мы хотим, чтобы экземпляры класса располагали одним хранилищем? Такой случай есть, и он даже имеет название — Singleton, один их шаблонов проектирования. Однако есть более усовершенствованная версия под названием Borg (различие заключается в том, подклассы Borg’а разделяют ту же память, что и родительский класс [1]). Он может пригодиться, например, в классах с соединениями баз данных или с конфигурационным хранилищем.

Правда, стоит понимать, что использование глобального состояния может привести к проблемам. Проводить юнит-тесты тоже будет трудно.

Как реализуется Borg

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

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

В классе есть атрибут _shared, который инициализирован на уровне класса в виде пустого словаря. Внутри конструктора мы присвоили __dict__ этот словарь. В результате во время выполнения все добавленные атрибуты станут разделяемой областью памяти.

Проверим это:

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

Зачем нужен атрибут __slots__

Динамическое добавление атрибутов в во время выполнения стоит дорого. Кроме того, мы используем еще и словари, которые неплохо нагружают память. Поэтому если у вас тысячи экземпляров, то готовьтесь исправлять код.

В Python есть еще один магический атрибут под названием __slots__, который ограничивает добавляемые атрибуты. Это позволяет сэкономить на памяти. Например, следующий код на Python:

class SlottedClass:
    __slots__ = ['value']

>>> s = SlottedClass
>>> s = 10
>>> s.other = 10
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'SlottedClass' object has no attribute 'other'

— позволяет создавать только атрибуты с именем value. Другим плюсом данного подхода является избегание ошибок в правописании названия атрибута.

Можем убедиться в том, что __slots__ уменьшает размер используемой памяти во время выполнения:

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 Мб.


Еще больше подробностей о тонкостях работы с разделяемым данными на реальных примерах из Data Science вы узнаете на наших образовательных курсах в лицензированном учебном центре обучения и повышения квалификации руководителей и ИТ-специалистов (менеджеров, архитекторов, инженеров, администраторов, Data Scientist’ов и аналитиков Big Data) в Москве:

Источники
  1. Borg vs Singleton

Добавить комментарий