Python Python

Ce sunt `__slots__` în Python și ce problemă rezolvă

Dima Авг 30, 2026

Pe scurt

__slots__ declară o mulțime fixă de nume de atribute pe o clasă, iar în schimb CPython încetează să mai dea fiecărei instanțe propriul __dict__, stocând în schimb valorile la decalaje fixe. Problema pe care o rezolvă este memoria: o instanță obișnuită plătește pentru un dicționar a cărui supraîncărcare le eclipsează pe cele două-trei atribute pe care le conține efectiv, iar acel cost se înmulțește când un program ține milioane de obiecte mici vii în același timp. Accesul la atribute nu este mai lent, pentru că fiecare slot devine un descriptor la nivel de clasă, care citește un decalaj cunoscut. Prețul este rigiditatea — atribuirea unui atribut nedeclarat ridică AttributeError, nu există niciun __dict__ de inspectat sau de modificat, iar o subclasă care uită să își declare propriul __slots__ primește tăcut dicționarul înapoi.


Cum funcționează

Implicit, fiecare atribut de instanță trăiește într-un dicționar atârnat de instanță. CPython se străduiește deja mult să facă asta ieftin: instanțele aceleiași clase împart un singur tabel de chei, deci fiecare obiect stochează doar un tablou compact de valori, plus un pointer către el. Ieftin nu înseamnă însă gratuit, iar costul per obiect este în continuare de câteva ori mai mare decât ocupă valorile în sine.

Declararea __slots__ = ("symbol", "price_cents") schimbă ce construiește mașinăria de clase. La crearea clasei, Python creează câte un member_descriptor per nume declarat și rezervă pentru fiecare un decalaj fix în interiorul blocului de memorie al instanței. Nu se alocă niciun dicționar, iar instanțele acelei clase nu au deloc un atribut __dict__.

Căutarea trece apoi prin acei descriptori. Un member_descriptor este un descriptor de date care trăiește pe clasă, deci citirea lui tick.symbol apelează __get__-ul lui, care încarcă direct de la decalajul rezervat — nicio dispersie, nicio sondare de tabel. De aceea sloturile nu schimbă viteza pe dimensiune; dacă e ceva, citirea este marginal mai ieftină decât o căutare în dicționar.

Atribuirea este locul unde apare rigiditatea. Setarea unui nume care nu are niciun descriptor și niciun __dict__ pe care să cadă înapoi nu îi lasă interpretorului niciun loc unde să pună valoarea, deci el ridică AttributeError — iar mesajul spune explicit că obiectul nu are niciun __dict__ pentru setarea de atribute noi, ceea ce este indiciul care deosebește asta de o simplă greșeală de scriere.

Sloturile sunt declarate per clasă, nu moștenite ca politică. Dacă o subclasă nu își declară propriul __slots__, Python dă instanțelor ei un __dict__, ca să poată ține orice adaugă subclasa — ceea ce restabilește flexibilitatea și, odată cu ea, costul de memorie. Fiecare clasă din lanț trebuie să opteze, ca economia să supraviețuiască.


Cum arată la rulare

Creează un fișier:

touch tick_storage.py

Rulează-l cu:

python3 tick_storage.py

Ce schimbă de fapt declarația

Cel mai mic caz: aceeași clasă cu două atribute, scrisă în ambele feluri, apoi întrebată ce poartă și ce va accepta.

class Tick:
    def __init__(self, symbol, price_cents, volume):
        self.symbol = symbol
        self.price_cents = price_cents
        self.volume = volume


class SlottedTick:
    __slots__ = ("symbol", "price_cents", "volume")

    def __init__(self, symbol, price_cents, volume):
        self.symbol = symbol
        self.price_cents = price_cents
        self.volume = volume


plain = Tick("BTC-USD", 6431250, 12)
slotted = SlottedTick("BTC-USD", 6431250, 12)

print("plain instance has __dict__: ", hasattr(plain, "__dict__"))
print("slotted instance has __dict__:", hasattr(slotted, "__dict__"))
print("plain __dict__ contents:", plain.__dict__)
print("each slot is a class-level:", type(SlottedTick.symbol).__name__)

plain.exchange = "COINBASE"
print("plain accepted a new attribute:", plain.exchange)

try:
    slotted.exchange = "COINBASE"
except AttributeError as exc:
    print("slotted refused it:", type(exc).__name__)


plain instance has __dict__:  True
slotted instance has __dict__: False
plain __dict__ contents: {'symbol': 'BTC-USD', 'price_cents': 6431250, 'volume': 12}
each slot is a class-level: member_descriptor
plain accepted a new attribute: COINBASE
slotted refused it: AttributeError


  • Instanța simplă poartă un dicționar real, care conține cele trei atribute ale ei; cea cu sloturi nu are deloc un __dict__, ceea ce este toată diferența structurală.
  • SlottedTick.symbol este un member_descriptor — declarația nu a creat un atribut de clasă care să conțină o valoare, ci a creat un descriptor care știe unde stă valoarea în fiecare instanță.
  • Instanța simplă acceptă exchange pentru că un dicționar va ține orice cheie. Cea cu sloturi nu are nici un descriptor pentru acel nume, nici un dicționar pe care să cadă înapoi, deci atribuirea pică.
  • Este afișat doar tipul excepției. Textul mesajului a căpătat o clauză explicativă într-o versiune ulterioară de Python, deci tipul este partea pe care merită să te bazezi.

Economia, măsurată

O dimensiune în plus: scara. Supraîncărcarea unui singur obiect este invizibilă; două sute de mii dintre ele sunt cazul pentru care există __slots__. Înlocuiește fișierul cu:

import tracemalloc


class Tick:
    def __init__(self, symbol, price_cents):
        self.symbol = symbol
        self.price_cents = price_cents


class SlottedTick:
    __slots__ = ("symbol", "price_cents")

    def __init__(self, symbol, price_cents):
        self.symbol = symbol
        self.price_cents = price_cents


COUNT = 200_000


def peak_bytes(build_row):
    tracemalloc.start()
    book = [build_row() for _ in range(COUNT)]
    peak = tracemalloc.get_traced_memory()[1]
    tracemalloc.stop()
    del book
    return peak


container_only = peak_bytes(lambda: None)
plain_total = peak_bytes(lambda: Tick("BTC-USD", 6431250))
slotted_total = peak_bytes(lambda: SlottedTick("BTC-USD", 6431250))

plain_instances = plain_total - container_only
slotted_instances = slotted_total - container_only

print("slotted book is smaller overall:      ", slotted_total < plain_total)
print("overall saving to the nearest 10%:    ", f"{round((1 - slotted_total / plain_total) * 10) * 10}%")
print("per-instance saving to the nearest 10%:", f"{round((1 - slotted_instances / plain_instances) * 10) * 10}%")


slotted book is smaller overall:       True
overall saving to the nearest 10%:     40%
per-instance saving to the nearest 10%: 50%


Fiecare instanță este construită din aceleași două obiecte — un șir internat și un întreg — deci încărcătura utilă este partajată, iar singurul lucru care variază între cele două rulări este supraîncărcarea per instanță. Punctul de referință container_only măsoară de unul singur lista de două sute de mii de referințe, iar scăderea lui separă obiectele de containerul care le ține: de aceea cifra per instanță este mai mare decât cea globală. Aproximativ jumătate din costul per obiect dispare, ceea ce este titlul cinstit pentru o clasă mică. Numerele absolute de octeți diferă între variantele de build ale Python-ului și, în mod deliberat, nu sunt afișate; proporția este rezultatul durabil.


Subclasa care dă totul înapoi

Ultima treaptă este greșeala care anulează tăcut toată munca. Sloturile sunt declarate per clasă, deci și o subclasă trebuie să opteze. Înlocuiește fișierul cu:

class SlottedTick:
    __slots__ = ("symbol", "price_cents")

    def __init__(self, symbol, price_cents):
        self.symbol = symbol
        self.price_cents = price_cents


class VenueTick(SlottedTick):
    __slots__ = ("venue",)

    def __init__(self, symbol, price_cents, venue):
        super().__init__(symbol, price_cents)
        self.venue = venue


class AnnotatedTick(SlottedTick):
    pass


venue = VenueTick("BTC-USD", 6431250, "COINBASE")
annotated = AnnotatedTick("BTC-USD", 6431250)

print("subclass that declares __slots__ has __dict__:", hasattr(venue, "__dict__"))
print("subclass that does not declare it has __dict__:", hasattr(annotated, "__dict__"))

annotated.desk = "APAC"
print("so the subclass accepts anything again:", annotated.desk)

try:
    venue.desk = "APAC"
except AttributeError:
    print("while the fully slotted subclass still refuses")

print("slots seen along the MRO:", [c.__slots__ for c in VenueTick.__mro__ if hasattr(c, "__slots__")])


subclass that declares __slots__ has __dict__: False
subclass that does not declare it has __dict__: True
so the subclass accepts anything again: APAC
while the fully slotted subclass still refuses
slots seen along the MRO: [('venue',), ('symbol', 'price_cents')]


AnnotatedTick nu adaugă nimic și nu declară nimic, iar tocmai asta este problema: Python nu poate ști că subclasa va rămâne goală, deci dă instanțelor un __dict__, iar economia se evaporă pentru fiecare AnnotatedTick creat vreodată. Nimic nu avertizează despre asta — clasa funcționează, doar că costă cât costă o clasă fără sloturi. VenueTick declară doar propriul lui nume nou, iar ultima linie arată de ce este de ajuns: sloturile se acumulează de-a lungul MRO-ului, deci subclasa rezervă venue, moștenind descriptorii pentru symbol și price_cents. Observă că o subclasă care nu adaugă niciun atribut are totuși nevoie de __slots__ = () ca să rămână cu sloturi.


Cazuri limită și capcane

  • Referințele slabe încetează să funcționeze dacă nu le ceri. O clasă cu sloturi nu are __weakref__ decât dacă este listat în declarație, iar weakref.ref pe o astfel de instanță ridică TypeError: cannot create weak reference. Memoriile cache și registrele de observatori care țin obiectele slab se strică la trecere.
  • Doi părinți cu sloturi nu pot fi combinați. Moștenirea din două clase care declară amândouă sloturi nevide ridică TypeError: multiple bases have instance lay-out conflict, pentru că fiecare părinte și-a rezervat propriile decalaje, iar dispunerile nu pot fi îmbinate.
  • Un nume de slot nu poate fi în același timp atribut de clasă. Scrierea __slots__ = ("x",) și x = 1 în același corp ridică ValueError: 'x' in __slots__ conflicts with class variable, de vreme ce descriptorul și atributul de clasă ar ocupa același nume.
  • Dataclasele îl pot genera. @dataclass(slots=True) construiește declarația din câmpurile adnotate, ceea ce este modul cel mai puțin predispus la erori de a pune sloturi pe o clasă care poartă date.
  • Aceasta este o optimizare de scară, nu o valoare implicită. Pe o clasă cu o mână de instanțe, economia este zgomot, iar rigiditatea un cost real — niciun atribut la rulare, nicio modificare de instanță în teste, niciun vars(). Recurge la ea când un profil arată o clasă instanțiată în sute de mii de exemplare.

Rezumat

__slots__ schimbă dicționarul per instanță pe o mulțime fixă de decalaje susținute de descriptori, declarată din start. Rezolvă o singură problemă — supraîncărcarea de memorie a ținerii foarte multor obiecte mici — și înjumătățește aproximativ costul per instanță al unei clase mici, fără să încetinească accesul la atribute. Tot ce ia decurge din același fapt: nu există niciun dicționar, deci orice se baza pe unul a dispărut.

  • Declararea lui __slots__ împiedică instanțele să primească un __dict__ și creează câte un member_descriptor per nume, fiecare citind un decalaj fix.
  • Economia este de memorie, la scară; accesul la atribute este la fel de rapid ca înainte sau ușor mai rapid, deci viteza nu este motivul pentru care recurgi la el.
  • Atribuirea unui atribut nedeclarat ridică AttributeError, pentru că nu există nici descriptor, nici dicționar care să primească valoarea.
  • Sloturile sunt per clasă: o subclasă care omite __slots__ primește un __dict__ înapoi, iar un __slots__ = () gol este necesar ca să rămână cu sloturi.
  • Sloturile se acumulează de-a lungul MRO-ului, deci o subclasă declară doar numele pe care le adaugă.
  • Referințele slabe au nevoie ca __weakref__ să fie listat explicit, iar din doi părinți cu sloturi nevide nu se poate moșteni împreună.

Чтобы получить доступ к облачной лаборатории, необходимо войти в систему.

Войти