Как работают dict, slots и weakref в Python (и зачем это знать)

5b29b66947429b550d4e29af32e6a4c3.jpg

Привет, Хабр!

Сегодня рассмотрим как slots, dict и weakref помогают нам выжимать максимум из Python: экономить память, ускорять доступ к атрибутам и бороться с утечками.

dict

Любой Python‑объект по умолчанию хранит свои атрибуты в dict — это обычный словарь (dict), в котором динамически хранятся все данные экземпляра. То есть можно добавлять, удалять и модифицировать атрибуты на ходу, не меняя код класса.

Пример:

class User:
    def __init__(self, username, email):
        self.username = username
        self.email = email

user = User("dev_master", "dev@habr.com")
print(user.__dict__)
# Выведет: {'username': 'dev_master', 'email': 'dev@habr.com'}

Добавляем новый атрибут — никаких проблем:

user.age = 35
print(user.__dict__)
# {'username': 'dev_master', 'email': 'dev@habr.com', 'age': 35}

Вроде бы удобно, но не всё так радужно.

Почему dict может быть проблемой?

Все любят Python за его динамичность. Но каждый раз, когда вы создаёте новый объект, Python делает две вещи:

  1. Создаёт сам объект в памяти.

  2. Создаёт отдельный dict, который тоже занимает место.

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

Помимо памяти, хранение атрибутов в dict влияет на скорость доступа. Чтобы понять, почему, заглянем в исходники CPython.

В CPython, когда мы обращаемся к obj.attr, интерпретатор выполняет:

  1. Поиск атрибута в dict.

  2. Если не найдено — поиск в классовых атрибутах (методах, дескрипторах).

  3. Если всё ещё нет — проверку родительских классов (если есть наследование).

Но если объект не использует dict (например, через тот же slots), Python может сразу обратиться к нужному полю в памяти.

slots

slots — это способ сказать интерпретатору: «Я заранее знаю, какие атрибуты у объекта будут, поэтому не создавай мне динамический dict».

Как мы знаем, по стандарту в Python каждый объект хранит свои атрибуты в dict — обычном хеш‑таблице (словаре), которая позволяет динамически добавлять, изменять и удалять атрибуты. Но словари в Python — штука не дешёвая:

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

  • Доступ к атрибуту идёт через поиск по хеш‑таблице, а не по фиксированному адресу в памяти.

slots позволяет избавиться от dict и хранить атрибуты в самом объекте по заранее известным смещениям. Это ускоряет доступ к данным и сильно снижает потребление памяти.

Когда вы объявляете slots, Python делает три вещи:

  1. Отключает создание dict у объектов класса (если не указано обратное).

  2. Создаёт специальную структуру для хранения атрибутов — вместо словаря используются слотовые дескрипторы.

  3. Меняет способ доступа к атрибутам — теперь они хранятся в фиксированных местах в объекте, а не в динамическом словаре.

Как это выглядит:

class Point:
    __slots__ = ('x', 'y')

    def __init__(self, x, y):
        self.x = x
        self.y = y

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

Проверим:

p = Point(3, 4)

print(hasattr(p, '__dict__'))  # False, словаря нет!
print(p.__slots__)  # ('x', 'y')

Попробуем добавить новый атрибут:

try:
    p.z = 5
except AttributeError as err:
    print("Ошибка:", err)
# Ошибка: 'Point' object has no attribute 'z'

Из‑за того, что slots жёстко фиксирует список атрибутов, вы не можете динамически добавлять новые атрибуты.

А теперь замерим, сколько памяти реально экономится:

import sys

class WithoutSlots:
    def __init__(self, a, b):
        self.a = a
        self.b = b

class WithSlots:
    __slots__ = ('a', 'b')
    
    def __init__(self, a, b):
        self.a = a
        self.b = b

obj1 = WithoutSlots(1, 2)
obj2 = WithSlots(1, 2)

print("Размер объекта без __slots__:", sys.getsizeof(obj1))
print("Размер объекта с __slots__:", sys.getsizeof(obj2))

Вывод:

Размер объекта без __slots__: 56
Размер объекта с __slots__: 32

Разница почти в 2 раза. А теперь представьте, что у вас 100 000 таких объектов — экономия гигабайтов.

Как slots устроен внутри CPython

Обычный объект в Python выглядит так:

struct PyObject {
    Py_ssize_t ob_refcnt;  // Счетчик ссылок
    struct _typeobject *ob_type;  // Указатель на класс (тип)
    PyObject *dict;  // Указатель на словарь атрибутов
};

Когда создаём объект без slots, Python выделяет память для структуры PyObject, а затем ещё одну порцию памяти для dict.

Если же используется slots, Python не создаёт dict, а хранит атрибуты прямо в памяти объекта с фиксированными смещениями.

Внутри slots работает механизм, который создаёт список дескрипторов атрибутов в таблице PyMemberDef.

static PyMemberDef Point_members[] = {
    {"x", T_INT, offsetof(PointObject, x), 0, "X coordinate"},
    {"y", T_INT, offsetof(PointObject, y), 0, "Y coordinate"},
    {NULL}  /* Sentinel */
};

Когда Python обращается к p.x, он просто идёт по известному смещению в памяти, а не лезет в хеш‑таблицу, как в случае с dict.

Что если класс с slots наследуется?

Если у базового класса есть slots, а у наследника его нет — Python создаст dict в дочернем классе.

class Base:
    __slots__ = ('a',)

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

class Derived(Base):
    # Здесь __slots__ нет, значит у экземпляров Derived будет __dict__
    def __init__(self, a, b):
        super().__init__(a)
        self.b = b

d = Derived(1, 2)
print(hasattr(d, '__dict__'))  # True! Теперь есть __dict__

Как избежать этого? Добавьте slots в дочерний класс тоже.

class Derived(Base):
    __slots__ = ('b',)

Теперь у Derived тоже нет dict, и объект остаётся лёгким.

Как совместить slots и динамичность?

Иногда хочется и памяти сэкономить, и динамически добавлять атрибуты. Как быть? Добавьте dict в slots:

class FlexiblePoint:
    __slots__ = ('x', 'y', '__dict__')

    def __init__(self, x, y):
        self.x = x
        self.y = y

fp = FlexiblePoint(10, 20)
fp.label = "A"
print(fp.__dict__)  # {'label': 'A'}

Теперь объект использует slots для экономии памяти, а также поддерживает dict, если нужен.

Переходим к weakref.

weakref

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

В CPython каждый объект имеет внутреннее поле, отвечающее за слабые ссылки (обычно это «tp_weaklist» в структуре PyObject). Когда вы создаёте слабую ссылку через weakref.ref(), вы фактически просите интерпретатор добавить ваш объект в этот список. Если объект удаляется (то есть его счётчик ссылок падает до нуля), сборщик мусора освобождает память, а слабая ссылка автоматически становится мертвой — возвращает None.

Взглянем на классический пример:

import weakref

class Resource:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f""

res = Resource("CacheResource")
weak_res = weakref.ref(res)
print("До удаления:", weak_res())
del res
print("После удаления:", weak_res())

Пока объект жив, weak‑ссылка возвращает его, а как только объект удалён, weak_res() даёт None.

Иногда бывает полезно не просто получить None, а выполнить какую‑то доп. логику в момент, когда объект уходит из жизни. Weakref позволяет передать callback‑функцию, которая срабатывает при удалении объекта. Пример:

import weakref

def on_object_finalize(weak_ref):
    print("Объект был удалён:", weak_ref)

class CachedData:
    __slots__ = ('data', '__weakref__')

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

data_obj = CachedData("Важные данные")
weak_data = weakref.ref(data_obj, on_object_finalize)
print("Слабая ссылка:", weak_data())
del data_obj

В этом случае, когда data_obj удаляется, callback on_object_finalize срабатывает и сообщает вам, что объект ушёл.

Если вы решите использовать слабые ссылки в классах, где применяются slots, не забудьте включить специальную строку '__weakref__' в список слотов. Без этого класс не сможет корректно поддерживать слабые ссылки, и попытка создать их вызовет ошибку. Пример:

class MyCachedObject:
    __slots__ = ('id', 'value', '__weakref__')

    def __init__(self, id, value):
        self.id = id
        self.value = value

obj = MyCachedObject(1, "data")
obj_ref = weakref.ref(obj)
print("Слабая ссылка:", obj_ref())

Объединяем slots, dict и weakref

Иногда хочется всего одновременно. Решение — объединить слоты с возможностью динамического добавления атрибутов, добавив в slots специальные поля dict и weakref.

import weakref

class Resource:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f""

res = Resource("CacheResource")
weak_res = weakref.ref(res)
print("До удаления:", weak_res())
del res
print("После удаления:", weak_res())
# До удаления: 
# После удаления: None

Так можно экономить память на фиксированных атрибутах и при этом оставлять возможность динамически расширять объект, если это, конечно, необходимо.

В завершение рекомендую посетить открытые уроки по Python, которые пройдут в марте в Otus:

  • 18 марта: «Искусство тестирования с pytest». На занятии вы узнаете про основы pytest, фикстуры, параметризацию и оптимизацию тестов.
    Записаться

  • 26 марта: «API: Как работают gRPC и GraphQL». Рассмотрим альтернативные подходы к созданию API, такие как GraphQL, gRPC и HATEOAS, сравним их между собой, выявим их сильные и слабые стороны.
    Записаться

© Habrahabr.ru