Глава 35. Слоты
Концепция слотов решает сразу три задачи, регулярно встающих при работе с объектами: экономия памяти, ускорение доступа к атрибутам, запрет на добавление в инстансы классов новых атрибутов. Разберемся, каким образом этого добиться с помощью слотов.
Мотивация
Атрибуты объектов класса хранятся в dunder-поле __dict__
типа dict
. Мы разбирали это в главе про модель данных в питоне.
Посмотрим, как выглядит содержимое __dict__
объекта класса D
:
class D:def __init__(self):self.a = 1self.b = 2def f(self):...print("Instance attributes", D().__dict__)
Instance attributes {'a': 1, 'b': 2}
В данном случае инстанс класса содержит словарь с именами и значениями атрибутов a
и b
. Но этот словарь оптимизирован особым образом: начиная с версии питона 3.3 словари, хранящие атрибуты объектов, разделяют ключи между различными инстансами класса. Это улучшение было предложено в PEP-412: в случае, если объект типа dict
используется для хранения атрибутов объекта, нужно ключи и хэши держать отдельно от значений.
Несмотря на эту оптимизацию, поле __dict__
остается довольно затратным с точки зрения памяти: возможность в любой момент добавлять в объект класса новые атрибуты не дается бесплатно. Однако на лету добавлять и удалять атрибуты объектов требуется довольно редко. В большинстве случаев у инстансов одного класса набор полей совпадает. А значит, напрашивается экономия: отказ от поля __dict__
.
Для этого и используются слоты. С их помощью выделяется память под фиксированное количество атрибутов.
Что такое слоты
Слоты (slots) — это dunder-поле класса, которому присваивается перечисление атрибутов, которыми должен обладать объект. После добавления в класс слотов его инстансам уже не получится добавлять новые атрибуты.
Убедимся в этом. Добавим слоты в класс S
:
class S:__slots__ = ("a", "b")def __init__(self):self.a = 1self.b = 2def f(self):...print("Slots", S().__slots__)print("Instance attributes", S().__dict__)
Slots ('a', 'b')
Traceback (most recent call last):
File "example.py", line 12, in <module>
print("Instance attributes", S().__dict__)
^^^^^^^^^^^^
AttributeError: 'S' object has no attribute '__dict__'
Как видите, добавление в класс __slots__
предотвращает появление у объектов поля __dict__
. Попытка присвоить значение несуществующему полю объекта тоже обречена на провал:
s = S()s.c = 3
AttributeError: 'S' object has no attribute 'c'
В качестве присваиваемого полю __slots__
перечисления может выступать кортеж, список, ключи словаря. Примеры:
__slots__ = ["a", "b"]__slots__ = {"a": "val1", "b": "val2"}
Преимущества классов со слотами
Добавление слотов преследует три цели:
- Сэкономить память. Вместо словаря
__dict__
значения полей хранятся в массиве фиксированного размера. - Ускорить обращения к полям. Работа с полем превращается в доступ к элементу массива вместо обращения к хэш-таблице.
- Лишить пользователя класса возможности добавлять поля в инстансы класса. Это предотвращает целый класс ошибок, связанных с опечатками в именах полей. При попытке обращения к несуществующему полю генерируется исключение
AttributeError
.
Проверим, насколько класс со слотами экономнее, чем без слотов.
Есть класс Message
и список messages
, состоящий из его инстансов. Заведите класс MessageSlots
с таким же набором полей, но с добавлением слотов. Аналогичным образом заполните список messages_slots
.
С помощью функции asizeof()
из стороннего модуля pympler.asizeof
выведите в консоль две строки: сколько памяти занимает список messages
и сколько занимает messages_slots
.
Так выглядит вывод в консоль размера messages_slots
: pympler.asizeof.asizeof(messages_slots)
.
import pympler.asizeof
class Message:
def __init__(self, msg_id, text):
self._id = msg_id
self._text = text
class MessageSlots:
__slots__ = ("_id", "_text")
def __init__(self, msg_id, text):
self._id = msg_id
self._text = text
messages = [Message(x, str(x)) for x in range(1000)]
messages_slots = [MessageSlots(x, str(x)) for x in range(1000)]
print(pympler.asizeof.asizeof(messages))
print(pympler.asizeof.asizeof(messages_slots))
Если вам интересно, почему в данной задаче мы использовали функцию из стороннего модуля pympler.asizeof.asizeof()
вместо родной функции sys.getsizeof()
, то ответ прост: sys.getsizeof()
считает размер объекта без учета вложенных объектов (shallow size). А pympler.asizeof.asizeof()
считает фактический размер, занимаемый в памяти с учетом объектов, на которые ссылается интересующий объект.
На самом деле то, какой объем памяти получится сэкономить с помощью слотов в конкретном участке кода, зависит от многих факторов, среди которых:
- Архитектура целевой платформы.
- Версия языка.
- Количество полей в объекте.
- Количество аллоцируемых объектов.
В некоторых случаях удается добиться экономии памяти в разы. На официальном сайте docs.python.org приведены следующие цифры: на 64-битной машине с Linux на борту инстанс с двумя атрибутами занял 48 байт со слотами и 152 байта без слотов.
А теперь давайте посмотрим, насколько доступ к полям класса со слотами быстрее, чем к полям класса без слотов.
Создайте класс S
, такой же как C
, но со слотом для поля field
.
Напишите функцию field_rw()
, которая принимает на вход объект, записывает в его поле field
строку "test"
и читает это поле в некоторую переменную.
Выведите в консоль среднее арифметическое измерений времени выполнения, полученных с помощью функции repeat()
из модуля timeit
.
Пример подсчета времени выполнения test_class_with_slots
: timeit.repeat(test_class_with_slots)
.
import timeit
class C:
def __init__(self, val=None):
self.field = val
class S:
__slots__ = ("field",)
def __init__(self, val=None):
self.field = val
def field_rw(obj):
obj.field = "test"
x = obj.field
def test_class_with_dict():
obj = C()
field_rw(obj)
def test_class_with_slots():
obj = S()
field_rw(obj)
results_dict = timeit.repeat(test_class_with_dict)
results_slots = timeit.repeat(test_class_with_slots)
print(sum(results_dict) / len(results_dict))
print(sum(results_slots) / len(results_slots))
То, насколько добавление в класс слотов ускорит доступ к атрибутам, также сильно зависит от целевой платформы, версии языка и других факторов. На docs.python.org приведен замер, выполненный на процессоре Apple M1 (версия питона 3.10): чтение атрибутов объектов со слотами ускорилось на 35% относительно объектов без слотов.
Что выведет этот код?
В случае не обработанного исключения напишите error
.
class X:__slots__ = ()def __init__(self, val):self.val = valprint(X("val").val)
Поле __slots__
класса X
пустое. Следовательно, у объектов класса не может быть никаких атрибутов. При попытке присвоения self.val = val
сгенерируется исключение AttributeError: 'X' object has no attribute 'val'
.
error
Ограничения классов со слотами
Добавление в класс слотов приводит к некоторым сложностям при наследовании, при множественном наследовании и при заведении слабых ссылок (weak references) на объекты класса.
Слоты и наследование. Значение __slots__
наследуется. Однако если в дочернем классе появляются поля, которых нет у родителя, это приводит к появлению в дочернем классе поля __dict__
. Чтобы этого избежать, в дочерний класс тоже нужно добавить __slots__
и перечислить в нем новые поля. Поля класса-родителя дублировать не надо.
Слоты и множественное наследование. Не получится наследоваться от нескольких родителей, если более чем у одного из них определены слоты. Эту проблему можно обойти с помощью абстрактных классов.
Слоты и создание слабых ссылок на инстансы класса. Слабая ссылка — это ссылка, существование которой никак не помешает сборщику мусора уничтожить объект, на который она указывает. Если в класс добавлены слоты, то у его инстансов отсутствует не только __dict__
, но и поле __weakref__
, необходимое для заведения слабых ссылок. Исправляется это добавлением __weakref__
в перечисление слотов.
Слоты и встроенные типы. Не пустой атрибут __slots__
не может быть добавлен к классу, наследованному от встроенных типов переменной длины: int
, bytes
и tuple
. При попытке добавления слотов будет сгенерировано исключение TypeError
. Данное ограничение связано со спецификой внутреннего устройства типов переменной длины. Если смотреть на то, как преобразованный в сишную структуру объект расположен в памяти, то слоты занимают место по некоему фиксированному смещению в структуре. Объекты типов переменной длины могут содержать префикс фиксированной длины перед блоком данных изменяющегося размера. Но при наследовании от этих типов нельзя выделить блок фиксированного размера, в который могли бы быть встроены слоты. Первоочередной мотивацией для реализации слотов всегда выступала скорость, поэтому было принято решение запретить слоты в сценариях, когда они не принесут желаемого ускорения.
В связи с перечисленными нюансами не стоит без необходимости добавлять слоты во все подряд классы. Прибегайте к их помощи только ради существенной экономии памяти или ускорения кода. Например, если становится очевидно, что объекты какого-то класса стали бутылочным горлышком по производительности или потреблению памяти.
Иначе потенциальные неудобства грозят перевесить незначительный выигрыш в ресурсах.
Как устроены слоты
После добавления в класс слотов значения атрибутов больше не привязаны к ключам в словаре __dict__
. Теперь они содержатся в массиве фиксированной длины. Каждый атрибут, перечисленный в поле __slots__
, — на самом деле дескриптор, который задает правила для чтения и изменения атрибута по индексу в массиве. Под капотом логика работы со слотами имплементирована на C. Благодаря этому доступ к слотам очень эффективен.
Резюмируем
- Слоты (slots) — это dunder-поле
__slots__
класса, которому присваивается перечисление атрибутов объекта. Если в класс добавлены слоты, то в объекты класса становится невозможно добавить новые поля кроме тех, что перечислены в слотах. - Если в классе присутствует поле
__slots__
, то в нем уже нет поля__dict__
. - Объект со слотами занимает меньше памяти, чем объект с
__dict__
. Доступ к его атрибутам также происходит быстрее.