user-photo

Bartosz Szmyt

Fullstack Developer | Software Engineer | CERT

__slots__ - szybka optymalizacja w Pythonie

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'

Korzyści

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.

Dataclasses

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')

Ograniczenia

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.

user-photo

Bartosz Szmyt

Fullstack Developer | Software Engineer | CERT

Copyright 2022-2025 Bartosz Szmyt