Главная / Курсы / Python / Слоты

Глава 35. Слоты

Концепция слотов решает сразу три задачи, регулярно встающих при работе с объектами: экономия памяти, ускорение доступа к атрибутам, запрет на добавление в инстансы классов новых атрибутов. Разберемся, каким образом этого добиться с помощью слотов.

Мотивация

Атрибуты объектов класса хранятся в dunder-поле __dict__ типа dict. Мы разбирали это в главе про модель данных в питоне.

Посмотрим, как выглядит содержимое __dict__ объекта класса D:

class D:
def __init__(self):
self.a = 1
self.b = 2

def 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 = 1
self.b = 2

def 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.

class Message:
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: 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))
Задача # 1

Если вам интересно, почему в данной задаче мы использовали функцию из стороннего модуля 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.

import timeit


class C:
def __init__(self, val=None):
self.field = val


# Implement class S

# Implement function field_rw()


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 average of results_dict, results_slots

Пример подсчета времени выполнения 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))
Задача # 2

То, насколько добавление в класс слотов ускорит доступ к атрибутам, также сильно зависит от целевой платформы, версии языка и других факторов. На docs.python.org приведен замер, выполненный на процессоре Apple M1 (версия питона 3.10): чтение атрибутов объектов со слотами ускорилось на 35% относительно объектов без слотов.

Что выведет этот код?

В случае не обработанного исключения напишите error.

class X:
__slots__ = ()

def __init__(self, val):
self.val = val

print(X("val").val)

Поле __slots__ класса X пустое. Следовательно, у объектов класса не может быть никаких атрибутов. При попытке присвоения self.val = val сгенерируется исключение AttributeError: 'X' object has no attribute 'val'.

error
Задача # 3

Ограничения классов со слотами

Добавление в класс слотов приводит к некоторым сложностям при наследовании, при множественном наследовании и при заведении слабых ссылок (weak references) на объекты класса.

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

Слоты и множественное наследование. Не получится наследоваться от нескольких родителей, если более чем у одного из них определены слоты. Эту проблему можно обойти с помощью абстрактных классов.

Слоты и создание слабых ссылок на инстансы класса. Слабая ссылка — это ссылка, существование которой никак не помешает сборщику мусора уничтожить объект, на который она указывает. Если в класс добавлены слоты, то у его инстансов отсутствует не только __dict__, но и поле __weakref__, необходимое для заведения слабых ссылок. Исправляется это добавлением __weakref__ в перечисление слотов.

Слоты и встроенные типы. Не пустой атрибут __slots__ не может быть добавлен к классу, наследованному от встроенных типов переменной длины: int, bytes и tuple. При попытке добавления слотов будет сгенерировано исключение TypeError. Данное ограничение связано со спецификой внутреннего устройства типов переменной длины. Если смотреть на то, как преобразованный в сишную структуру объект расположен в памяти, то слоты занимают место по некоему фиксированному смещению в структуре. Объекты типов переменной длины могут содержать префикс фиксированной длины перед блоком данных изменяющегося размера. Но при наследовании от этих типов нельзя выделить блок фиксированного размера, в который могли бы быть встроены слоты. Первоочередной мотивацией для реализации слотов всегда выступала скорость, поэтому было принято решение запретить слоты в сценариях, когда они не принесут желаемого ускорения.

В связи с перечисленными нюансами не стоит без необходимости добавлять слоты во все подряд классы. Прибегайте к их помощи только ради существенной экономии памяти или ускорения кода. Например, если становится очевидно, что объекты какого-то класса стали бутылочным горлышком по производительности или потреблению памяти.

Иначе потенциальные неудобства грозят перевесить незначительный выигрыш в ресурсах.

Как устроены слоты

После добавления в класс слотов значения атрибутов больше не привязаны к ключам в словаре __dict__. Теперь они содержатся в массиве фиксированной длины. Каждый атрибут, перечисленный в поле __slots__, — на самом деле дескриптор, который задает правила для чтения и изменения атрибута по индексу в массиве. Под капотом логика работы со слотами имплементирована на C. Благодаря этому доступ к слотам очень эффективен.

Резюмируем

  • Слоты (slots) — это dunder-поле __slots__ класса, которому присваивается перечисление атрибутов объекта. Если в класс добавлены слоты, то в объекты класса становится невозможно добавить новые поля кроме тех, что перечислены в слотах.
  • Если в классе присутствует поле __slots__, то в нем уже нет поля __dict__.
  • Объект со слотами занимает меньше памяти, чем объект с __dict__. Доступ к его атрибутам также происходит быстрее.
Отправка...
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!