Bartosz Szmyt
Fullstack Developer | Software Engineer | CERT
16/03/2025
Tworząc instancje klas w Pythonie, możemy oszczędzić sporo pamięci korzystając z __slots__
. To jednak nie jedyna korzyść – oprócz zmniejszenia zużycia pamięci __slots__
przyspiesza również dostęp do atrybutów instancji. Właśnie to było pierwotnym powodem wprowadzenia __slots__
przez Guido van Rossuma, twórcę Pythona.
__dict__, __weakref__
Każdy nowo utworzony obiekt, nawet na bazie „pustej” klasy, dziedziczy zestaw metod specjalnych z klasy object
(mowa wyłącznie o Pythonie 3). Wśród nich domyślnie znajdują się __dict__
oraz __weakref__
. Dzięki __dict__
mamy możliwość dynamicznego dodawania atrybutów obiektu po jego utworzeniu.
class MyNewClass:
pass
my_object = MyNewClass()
print("__dict__" in dir(my_object))
# OUTPUT: True
my_object.new_attr = "value"
print(my_object.__dict__)
# OUTPUT: {'new_attr': 'value'}
Atrybut __weakref__
umożliwia tworzenie tzw. słabych referencji, jednak szczegółowe omówienie tego mechanizmu to temat na osobny artykuł.
__slots__
Choć możliwość dynamicznej manipulacji atrybutami instancji bywa bardzo użyteczna, wiąże się z pewnym kosztem.
Pierwszym z nich jest większe zużycie pamięci. Obiekty w Pythonie przechowują atrybuty w słowniku (__dict__
), który jest zaimplementowany jako hashmapa. Taka struktura to coś więcej niż tylko zestaw par klucz-wartość – przechowuje także metadane niezbędne do swojego działania, co dodatkowo zwiększa zużycie pamięci. Ponadto Python rezerwuje więcej pamięci, niż jest aktualnie potrzebne, aby zoptymalizować przyszłe operacje.
Drugim problemem jest szybkość dostępu do atrybutów. Choć dostęp do elementów w hashmapie ma średnią złożoność czasową O(1)
, nadal nie jest tak szybki jak bezpośredni dostęp do elementu w pamięci.
__slots__
rozwiązuje oba powyższe problemy – zamiast w słowniku, atrybuty są przechowywane w tablicy o stałej długości, zaimplementowanej w języku C (dla CPython).
Według dokumentacji Pythona __slots__
to zmienna klasowa, której można przypisać pojedynczy ciąg znaków, obiekt iterowalny lub sekwencję ciągów znaków. Określa ona nazwy atrybutów, które mogą być używane przez instancje danej klasy:
class MyNewClass:
__slots__ = ("attr1", "attr2")
def __init__(self) -> None:
self.attr1 = "value1"
self.attr2 = "value2"
my_object = MyNewClass()
print(my_object.__slots__)
# OUTPUT: ('attr1', 'attr2')
print(my_object.attr1)
# OUTPUT: value2
Ponieważ użycie __slots__
automatycznie zapobiega tworzeniu __dict__
, nie mamy już możliwości dodania nowego atrybutu, nie wymienionego w __slots__
:
my_object = MyNewClass()
my_object.attr3 = "value3"
Traceback (most recent call last):
File "example.py", line 11, in <module>
my_object.attr3 = "value3"
AttributeError: 'MyNewClass' object has no attribute 'attr3'
Zbadajmy teraz jakie korzyści daje nam użycie __slots__
. Poniżej przeprowadziłem mały eksperyment, w którym tworzę listę 1000 instancji klas, w których zastosowano __slots__
oraz listę 1000 instancji bez __slots__
.
Następnie badam rozmiar każdej z list używając asizeof
z biblioteki pympler
oraz czas dostępu do atrybutów przy użyciu timeit
.
import timeit
from functools import partial
from pympler.asizeof import asizeof
class MyNewClassWithSlots:
__slots__ = ("attr1", "attr2")
def __init__(self) -> None:
self.attr1 = "value1"
self.attr2 = "value2"
class MyNewClassWithoutSlots:
def __init__(self) -> None:
self.attr1 = "value1"
self.attr2 = "value2"
def get_set_delete(instance):
instance.attr1 = "New value"
_ = instance.attr1
del instance.attr1
without_slots = [MyNewClassWithoutSlots() for _ in range(1000)]
with_slots = [MyNewClassWithSlots() for _ in range(1000)]
size_without_slots = asizeof(item for item in without_slots)
size_with_slots = asizeof(item for item in with_slots)
print(f"Pamięć bez slots : {size_without_slots}")
print(f"Pamieć z slots: {size_with_slots}")
diff_mem = size_without_slots - size_with_slots
print(f"Różnica w pamięci: {round(diff_mem * 100 / size_without_slots)}%")
time_without_slots = min(
timeit.repeat(partial(get_set_delete, without_slots[0]), number=1000)
)
time_with_slots = min(
timeit.repeat(partial(get_set_delete, with_slots[0]), number=1000)
)
print(f"Dostęp bez slots: {time_without_slots}")
print(f"Dostęp z slots: {time_with_slots}")
diff_time = time_without_slots - time_with_slots
print(f"Różnica w czasie: {round(diff_time * 100 / time_without_slots)}%")
# OUTPUT:
# Pamięć bez slots : 161520
# Pamieć z slots: 57408
# Różnica w pamięci: 64%
# Dostęp bez slots: 0.00017728799957694719
# Dostęp z slots: 0.0001354670002911007
# Różnica w czasie: 24%
Różnica jest zatem znacząca, zwłaszcza jeśli weźmiemy pod uwagę ilość zaoszczędzonej pamięci. Warto mieć to na uwadze podczas projektowania kodu lub refaktoryzacji. Oczywiście, użycie __slots__
wiąże się również z pewnymi ograniczeniami, ale o tym w dalszej części artykułu.
Choć zdefiniowanie __slots__
nie jest szczególnie problematyczne to jednak wprowadza pewną duplikację w kodzie. Musimy zdefiniować atrybuty w samym __slots__
oraz w konstruktorze. Aby to uprościć, możemy użyć dataclasses:
from dataclasses import dataclass
@dataclass(slots=True)
class DataclassSlots:
field1: int
field2: str
new_object = DataclassSlots(field1=1, field2="Hello")
print("__dict__" in dir(new_object))
# OUTPUT: False
print("__slots__" in dir(new_object))
# OUTPUT: True
print(new_object.__slots__)
# OUTPUT: ('field1', 'field2')
Użycie __slots__
bez wątpienia przynosi korzyści, jednak – jak większość rozwiązań (o ile nie wszystkie) – ma również swoje wady.
Pierwszą z nich jest utrata możliwości dynamicznego dodawania atrybutów oraz tworzenia słabych referencji. Można co prawda obejść to ograniczenie, dodając __dict__
i __weakref__
do __slots__
, ale wówczas tracimy część zalet wynikających z jego użycia, szczególnie w kontekście oszczędności pamięci.
Kolejnym ograniczeniem jest problem z wielokrotnym dziedziczeniem – nie można jednocześnie dziedziczyć po dwóch klasach korzystających z __slots__
. Python zgłosi w takim przypadku TypeError
, ponieważ nie będzie w stanie określić, którego układu pamięci należy użyć.
from dataclasses import dataclass
@dataclass(slots=True)
class WithSlots:
attr1: str
attr2: str
@dataclass(slots=True)
class WithoutSlots:
attr1: str
attr2: str
class Mixed(WithoutSlots, WithSlots):
pass
# OUTPUT:
# Traceback (most recent call last):
# File "example.py", line 69, in <module>
# class Mixed(WithoutSlots, WithSlots):
# TypeError: multiple bases have instance lay-out conflict
Na koniec chciałbym mocno podkreślić, że celem tego artykułu jest przedstawienie alternatywnego podejścia, a nie magicznego rozwiązania optymalizacyjnego, które należy stosować zawsze i wszędzie. Wszystko zależy od konkretnego przypadku, z jakim mamy do czynienia.
Dla bardziej dociekliwych polecam świetny i bardzo dokładny artykuł na Stack Overflow.
Copyright 2022-2025 Bartosz Szmyt