Привет, Хабр!
Сегодня мы рассмотри замечательный механизм в Python — slots. Они помогают бороться с утечками памяти и тормозами в системах, где создается миллион объектов.
Каждый экземпляр класса в Python хранит свои атрибуты в словаре dict. Это дает некую гибкость — можно динамически добавлять атрибуты, менять их на ходу. Но за такую гибкость приходится платить — расход памяти растет, а это критично, когда речь идет о сотнях тысяч или миллионах объектов.
slots позволяет заранее зафиксировать набор атрибутов для класса, тем самым исключая создание дополнительного словаря, что приводит к уменьшению объема памяти, занимаемой каждым объектом.
Внутреннее устройство slots
Когда мы объявляем атрибут slots в классе, Python не создает обычный dict, а выделяет компактную структуру, представляющую фиксированный набор «полей». Рассмотрим простой пример:
class Normal:
def __init__(self, x, y):
self.x = x
self.y = y
class Slotted:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
В классе Normal
каждый объект содержит словарь dict, в котором динамически размещаются ссылки на x и y. В то время как Slotted
заранее знает, что его атрибуты — это только x
и y
, и их место в памяти выделяется статически.
Технические нюансы
Самая очевидная особенность использования slots — вы теряете возможность динамически добавлять новые атрибуты. Попробуйте сделать так:
obj = Slotted(10, 20)
try:
obj.z = 30 # Попытка добавить новый атрибут
except AttributeError as e:
print("Ошибка:", e)
Такой код выдаст AttributeError
, потому что атрибут z
не объявлен в slots. Если необходима динамичность, можно добавить специальное поле '__dict__'
в slots
, но тогда выгода по памяти практически теряется.
Стоит также сказать про наследование. Если базовый класс определен с slots
, то дочерний класс не будет иметь dict
по дефолту, если вы явно не укажете slots
и для него. Пример:
class Parent:
__slots__ = ('a',)
def __init__(self, a):
self.a = a
class Child(Parent):
__slots__ = ('b',)
def __init__(self, a, b):
super().__init__(a)
self.b = b
child = Child(1, 2)
try:
child.c = 3 # Ошибка, потому что 'c' не объявлен ни в Parent, ни в Child
except AttributeError as e:
print("Ошибка:", e)
При множественном наследовании все родительские классы должны быть слотовыми. Если хотя бы один из них не использует slots
, система не сможет объединить их slots
корректно.
По умолчанию слотовые классы не поддерживают слабые ссылки, так как для этого требуется поле weakref в slots. Если нужно, чтобы объекты поддерживали weakref, обязательно добавьте '__weakref__'
в список slots
:
class WeakSlotted:
__slots__ = ('x', 'y', '__weakref__')
def __init__(self, x, y):
self.x = x
self.y = y
import weakref
obj = WeakSlotted(100, 200)
ref = weakref.ref(obj)
print("Слабая ссылка:", ref())
Без добавления weakref вы получите ошибку, когда попытаетесь создать слабую ссылку на экземпляр.
Еще один нюанс: slots может осложнить сериализацию объектов (например, через модуль pickle). Если класс не имеет dict, стандартный механизм сериализации может не справиться с сохранением состояния. Решением может быть реализация специальных методов getstate и setstate, чтобы явно указать, что именно нужно сериализовать.
Интеграция с @dataclass
С выходом Python 3.10 и дальнейшей поддержки параметра slots=True
в декораторе @dataclass
, стало намного удобнее создавать слотовые классы, сохраняя адекватный синтаксис. Пример:
from dataclasses import dataclass
@dataclass(slots=True)
class Point:
x: int
y: int
p = Point(10, 20)
print(f"Point: ({p.x}, {p.y})")
try:
p.z = 30 # Попытка добавить новый атрибут
except AttributeError as e:
print("Ошибка:", e)
Объединяем все фичи dataclass (автоматическая генерация init, repr и т. д.) с преимуществами экономии памяти от slots.
Примеры из реальной жизни
ML/ETL пайплайны
Представляем себе, что мы обрабатываем огромный поток данных, где для каждого элемента создается объект с фиксированным набором атрибутов. Если таких объектов — миллион, а у каждого есть свой dict, то расход памяти может просто взорваться. С slots фиксируем набор атрибутов и резко снижаем накладные расходы. Пример:
class DataPoint:
__slots__ = ('id', 'timestamp', 'value')
def __init__(self, id, timestamp, value):
self.id = id
self.timestamp = timestamp
self.value = value
# Генерируем данные в ETL-процессе:
data_points = [DataPoint(i, 1627845123 + i, i * 0.5) for i in range(1_000_000)]
Всего пара строчек кода — и вы уже экономите кучу памяти.
Серверные приложения
В высоконагруженных серверных приложениях каждый клиент часто представлен объектом. Чем меньше памяти у объекта, тем больше клиентов вы сможете обслужить без лишней нагрузки на систему. Пример для подключения клиента:
class ClientConnection:
__slots__ = ('client_id', 'ip_address', 'port', 'connected')
def __init__(self, client_id, ip_address, port):
self.client_id = client_id
self.ip_address = ip_address
self.port = port
self.connected = True
# Симулируем создание нескольких подключений:
connections = [ClientConnection(i, f"192.168.0.{i % 255}", 8000 + i % 100) for i in range(10000)]
Меньше накладных расходов — быстрее обработка запросов и стабильность сервера.
Кэширование и объектные пулы
В системах кэширования или объектных пулах постоянно создаются и уничтожаются тысячи объектов. Применяя slots, четко фиксируем набор атрибутов и облегчаем сборку мусора, т.к система знает, что не будет никаких неожиданных атрибутов. Пример простого объекта для кэширования:
class CacheItem:
__slots__ = ('key', 'value', 'timestamp')
def __init__(self, key, value, timestamp):
self.key = key
self.value = value
self.timestamp = timestamp
# Представим, что это наш кэш с объектным пулом:
cache = {f"item_{i}": CacheItem(f"item_{i}", i * 10, 1627845123 + i) for i in range(5000)}
Если у вас остались вопросы или вы хотите поделиться своим опытом — пишите в комментариях.
Все актуальные лучшие практики программирования можно освоить на онлайн‑курсах OTUS: в каталоге можно посмотреть список всех программ, а в календаре — записаться на открытые уроки.