__slots__ в Python

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

Давайте создадим простой класс Article, у которого изначально есть два атрибута: date и writer. Если мы выведем __dict__ данного объекта, то получим ключи и значения для каждого атрибута. Также мы выведем __dict__ для самого класса – это нам понадобится позже. После этого добавим в объект новый атрибут reviewer и увидим его в обновленном __dict__.

class Article:
    def __init__(self, date, writer):
        self.date = date
        self.writer = writer

article = Article("2020-06-01","xiaoxu")
print(article.__dict__)
# {'date': '2020-06-01', 'writer': 'xiaoxu'}

print(Article.__dict__)
# {'__module__': '__main__', '__init__': <function Article.__init__ at
# 0x10d28f0e0>, 
#  '__dict__': <attribute '__dict__' of 'Article' objects>, '__weakref__':
# <attribute '__weakref__' of 'Article' objects>, 
#  '__doc__': None}

article.reviewer = "jojo"
print(article.__dict__)
# {'date': '2020-06-01', 'writer': 'xiaoxu', 'reviewer': 'jojo'}

print(article.reviewer)
# jojo

Это хорошо?

Ну, мы не можем сказать, что это плохо, пока не найдём решение получше. Словарь – очень мощный инструмент Python, но когда речь заходит о создании тысяч или миллионов объектов, мы можем столкнуться со следующими проблемами:

  1. Словарю нужна память. Миллионы объектов определённо съедят много оперативной памяти.
  2. Словарь по сути является хэш-таблицей. В наихудшем случае сложность операций get/set в хэш-таблице составляет O(n).

Решение с помощью __slots__

Из документации Python: __slots__ позволяет явно объявлять элементы данных (например, свойства), не прибегая к созданию __dict__ и __weakref__ (за исключением тех случаев, когда они объявлены в __slots__ явно или доступны в родительском классе).

Как это относится к вышеописанным проблемам?

Создадим класс ArticleWithSlots. Единственное различие между нашими двумя классами – дополнительное поле __slots__.

class ArticleWithSlots:
    __slots__ = ["date", "writer"]

    def __init__(self, date, writer):
        self.date = date
        self.writer = writer

__slots__ создаётся на уровне класса, а это значит, что если мы выведем ArticleWithSlots.__dict__, мы должны его увидеть. Помимо того, мы видим ещё 2 атрибута: date: <member 'date' ..> и writer: <member 'writer' ..> – они принадлежат классу member_descriptor.

print(ArticleWithSlots.__dict__)
# {'__module__': '__main__', '__slots__': ['date', 'writer'],
#  '__init__': <function ArticleWithSlots.__init__ at 0x103f6c290>, 
# 'date': <member 'date' of 'ArticleWithSlots' objects>,
# 'writer': <member 'writer' of 'ArticleWithSlots' objects>, 
#  '__doc__': None}

print(ArticleWithSlots.date.__class__)
# <class 'member_descriptor'>
[python_ad_block]

Что такое дескриптор в Python?

Перед тем, как говорить о протоколе дескриптора, мы должны рассмотреть обычный случай обращения к атрибутам в Python. Когда мы пишем article.writer, Python использует метод __getattribute__(), который заглядывает в __dict__, обращается по ключу self.__dict__["writer"] и возвращает значение.

Если в найденном объекте определён один из методов дескриптора, то стандартное поведение будет заменено на этот метод.

Методы протокола дескриптора: __get__()__set__() и __delete__(). Дескриптор – это просто объект Python, в котором определён хотя бы один из этих методов.

А __slots__ автоматически создаёт дескриптор для каждого атрибута с определением этих методов. Их вы увидите на скриншоте. Это значит, что для взаимодействия с атрибутами объект будет использовать методы __get__(), __set__() и __delete__(), а не стандартное поведение.

Согласно Гвидо ван Россуму, определяя __get__() и __set__(), вместо словаря мы используем массив, полностью реализованный на С, что приводит к большой эффективности кода.

__slots__ позволяет быстрее обращаться к атрибутам

В следующем коде мы сравним время создания объекта и обращения к атрибутам для классов Article и ArticleWithSlots__slots__ даёт ускорение на 10%.

@Timer()
def create_object(cls, size):
    for _ in range(size):
        article = cls("2020-01-01", "xiaoxu")

create_object(Article, 1000000)
# 0.755430193 сек.
create_object(ArticleWithSlots, 1000000)
# 0.6753360239999999 seconds

@Timer()
def access_attribute(obj, size):
    for _ in range(size):
        writer = obj.writer
        
article = Article("2020-01-01", "xiaoxu")
article_slots = ArticleWithSlots("2020-01-01", "xiaoxu")

access_attribute(article, 1000000)
# 0.06791842000000003 сек.

access_attribute(article_slots, 1000000)
# 0.06492474199999987 сек.

__slots__ обеспечивает чуть большую производительность. А всё потому, что сложность операций get и set в списке меньше, чем в словаре (если рассматривать сложность в наихудшем случае).Так как O(n) обычно справедливо только для наихудшего случая, чаще всего эта разница будет незаметна, особенно при работе с небольшими объемами данных.

__slots__ сокращает использование оперативной памяти

В силу того, что к атрибутам можно обращаться как к элементам данных, нет необходимости хранить их в словаре __dict__. На самом деле, __slots__ вообще не допустит создания __dict__. Так что, если вы попробуете вывести article_slots.__dict__, получите исключение AttributeError.

article_slots = ArticleWithSlots("2020-01-01", "xiaoxu")
print(article_slots.__dict__)
#AttributeError: 'ArticleWithSlots' object has no attribute '__dict__'

А ещё такое поведение использует меньше оперативной памяти объектом. Сравним размеры article и article_slots с помощью pympler. Мы не будем использовать sys.getsizeof(), потому что getsizeof() не учитывает размер всего, на что ссылается наш объект. Именно поэтому __dict__ будет проигнорирован getsizeof().

from pympler import asizeof
import sys

a = {"key":"value"}
b = {"key":{"key2":"value"}}

print(sys.getsizeof(a))
# 248
print(sys.getsizeof(b))
# 248
print(asizeof.asizeof(a))
# 360
print(asizeof.asizeof(b))
# 664

Оказывается, article_slots экономит нам более 50% памяти. Значительное улучшение!

from pympler import asizeof

article = Article("2020-01-01", "xiaoxu")
article_slots = ArticleWithSlots("2020-01-01", "xiaoxu")

print(asizeof.asizeof(article))
# 416
print(asizeof.asizeof(article_slots))
# 184

Мы наблюдаем такой результат потому, что в article_slots больше не создается атрибут __dict__ , который раньше занимал много памяти.

Когда следует использовать __slots__?

Похоже, __slots__ – вещь замечательная. Можно ли теперь добавить её в каждый класс?

Ответ: НЕТ! Очевидно, надо стремиться к какому-то компромиссу.

Фиксированные атрибуты

Одна из причин использовать __dict__ – его гибкость: после создания объекта можно добавить к нему новые атрибуты. А вот __slots__ при создании объекта зафиксирует его состав. Поэтому новые атрибуты добавить уже не получится.

article_slots = ArticleWithSlots("2020-01-01", "xiaoxu")
article_slots.reviewer = "jojo"
# AttributeError: 'ArticleWithSlots' object has no attribute 'reviewer'

Однако…

Иногда можно воспользоваться преимуществами __slots__ и одновременно обеспечить возможность добавления новых атрибутов. Этого можно добиться, указав __dict__ внутри __slots__ в качестве одного из атрибутов. Однако, в этом случае в __dict__ появятся только новые, добавленные атрибуты. Такой прием может пригодиться, когда в классе 10+ зафиксированных атрибутов, а вам в дальнейшем не помешают 1 или 2 динамических.

class ArticleWithSlotsAndDict:
    __slots__ = ["date", "writer", "__dict__"]

    def __init__(self, date, writer):
        self.date = date
        self.writer = writer

article_slots_dict = ArticleWithSlotsAndDict("2020-01-01", "xiaoxu")
print(article_slots_dict.__dict__)
# {}

article_slots_dict.reviewer = "jojo"
print(article_slots_dict.__dict__)
# {'reviewer': 'jojo'}

Наследование

Если вам нужно унаследовать класс с атрибутом __slots__, нет нужды заново указывать эти атрибуты в подклассе. Иначе подкласс займёт больше места. Кроме того, повторяющиеся атрибуты будут недоступны в родительском классе.

class ArticleBase:
    __slots__ = ["date", "writer"]

class ArticleAdvanced(ArticleBase):
    __slots__ = ["reviewer"]

article = ArticleAdvanced()
article.writer = "xiaoxu"
article.reviewer = "jojo"

print(ArticleBase.writer.__get__(article))
# xiaoxu

print(ArticleAdvanced.reviewer.__get__(article))
# jojo

То же самое происходит, когда мы наследуемся от NamedTuple. Нет необходимости повторно указывать все атрибуты в подклассе (подробнее о NamedTuple – в другой статье автора).

import collections

ArticleNamedTuple = collections.namedtuple("ArticleNamedTuple", ["date", "writer"])

class ArticleAdvancedNamedTuple(ArticleNamedTuple):
    __slots__ = ()

article = ArticleAdvancedNamedTuple("2020-01-01", "xiaoxu")
print(article.writer)
# xiaoxu 

Атрибут __dict__ можно также добавить в подклассе. Или можно просто не указывать __slots__ в подклассе, тогда в нём по умолчанию появится __dict__.

class ArticleBase:
    __slots__ = ["date", "writer"]

class ArticleAdvanced(ArticleBase):
    __slots__ = ["__dict__"]

article = ArticleAdvanced()
article.reviewer = "jojo"
# {'reviewer': 'jojo'}

class ArticleAdvancedWithoutSlots(ArticleBase):
    pass

article = ArticleAdvancedWithoutSlots()
article.reviewer = "jojo"
print(article.__dict__)
# {'reviewer': 'jojo'}

Если наследоваться от класса без __slots__, подкласс будет содержать __dict__.

class Article:
    pass

class ArticleWithSlots(Article):
    __slots__ = ["date", "writer"]

article = ArticleWithSlots()
article.writer = "xiaoxu"
article.reviewer = "jojo"
print(article.__dict__)
# {'reviewer': 'jojo'}

Заключение

Надеюсь, что вам теперь понятно, что такое __slots__ и как его можно использовать. В конце статьи я приведу плюсы и минусы этого приема, основанные на моём собственном опыте и данных некоторых интернет-ресурсов.

Плюсы

Применение __slots__ точно будет оправданным, когда приходится экономить память. Его крайне легко добавить и удалить – всего лишь одна строчка кода. Благодаря возможности указать __dict__ в качестве атрибута __slots__ разработчики могут без проблем работать с атрибутами, одновременно заботясь о производительности.

Минусы

Нужно чётко осознавать, чего вы хотите добиться, используя __slots__, особенно при наследовании класса с этим свойством – в этом случае на результат может повлиять много различных факторов.

Невозможно наследоваться от встроенных типов (таких как intbytestuple) с непустыми __slots__. Кроме того, вы не можете установить значение по умолчанию для атрибутов в __slots__. Всё потому, что эти атрибуты должны быть дескрипторами. Вместо этого можно присвоить значение по умолчанию в __init __().

class ArticleNumber(int):
    __slots__ = ["number"]
# TypeError: nonempty __slots__ not supported for subtype of 'int'

class Article:
    __slots__ = ["date", "writer"]
    date = "2020-01-01"
# ValueError: 'date' in __slots__ conflicts with class variable

Надеюсь, статья вам понравилась!

Перевод статьи Understand slots in Python.