Python 的物件屬性中,一般是在每個實例的字典 __dict__,寫 self.x = 1 等同於字典加入 'x': 1。如果在 class 裡宣告 __slots__,實例就不會有 __dict__。

兩個 class

先寫兩個 class,其中的差別在有沒有 __slots__:

class Plain:
    def __init__(self, x, y, z):
        self.x, self.y, self.z = x, y, z


class Slotted:
    __slots__ = ("x", "y", "z")

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

接下來,各建立一個實例來看看:

p = Plain(1, 2, 3)
s = Slotted(1, 2, 3)

hasattr(p, "__dict__")   # True
p.__dict__               # {'x': 1, 'y': 2, 'z': 3}

hasattr(s, "__dict__")   # False

Python 語言參考說 __slots__ 會替宣告的變數保留空間,並且不會替每個實例建立 __dict__ 和 __weakref__。

接下來我們測測看

光看有沒有字典,還看不出有甚麼差別,所以利用 tracemalloc 測量每個實例平均會佔多少記憶體:

import tracemalloc


def per_instance(cls, n=500_000):
    objs = [None] * n          # 清單先開好,不計入
    tracemalloc.start()
    for i in range(n):
        objs[i] = cls(1, 2, 3)
    current, _ = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return current / n

在 Windows 64 位元的 CPython 3.11.15 上跑,Plain 每個實例大約 96 bytes,Slotted 大約 56 bytes,差了 40 bytes;換到 3.14.3,也是一樣。因為屬性值都是 1、2、3 這種小整數,CPython 本來就會共用同一個整數物件(參考之前寫的Python 的 is 和 == 差在哪),所以這裡量測到的只有實例本身。

不過從 3.11 開始,一般物件的屬性值會先放在一個專門的陣列裡,要等到有程式真的去讀 __dict__,字典才會建立,CPython 的內部文件有說明這個設計(lazily created when the __dict__ attribute is specifically asked for)。也就是說,開始量測時,Plain 的實例內其實還沒有字典。

那如果真的有程式讀了呢?在迴圈裡加入一行,等待物件建好之後讀取 objs[i].__dict__,Plain 就從 96 變成大約 160 bytes,其中多出來的這些部分來自此時才建立的字典;若 Slotted 加上同一行則會直接拋出 AttributeError,因為它沒有 __dict__ 可以讀。

同段程式如果是在 3.10.20 上面跑,Plain 不管有沒有讀取 __dict__ 都是 152,Slotted 一樣是 56,因為當時只要設定第一個屬性(這裡是在 __init__ 裡),字典就會跟著建立。所以 __slots__ 到底能夠節省多少,也跟 Python 版本有關。

換來的限制

一開始字典本來是可以隨時加新屬性的地方,拿掉之後,等於這個能力也跟著沒了。對 Slotted 設定沒宣告過的屬性,會拋出錯誤:

s.w = 4
# AttributeError: 'Slotted' object has no attribute 'w'

在 3.13.12 和 3.14.3 上,錯誤訊息還會多 and no __dict__ for setting new attributes。另外,繼承 Slotted 的子類別如果自己沒有宣告 __slots__,實例又會有 __dict__,也就可以再加新屬性。

那什麼時候值得用

40 bytes 對幾個、幾十個物件來說沒什麼感覺,要一次建出幾十萬個同型的小物件,每個實例省下的部分總和就會很有感覺。所以要不要加 __slots__,主要也要看這個 class 會被建立多少次,使用情境和範圍,以及之後需不需要替實例補上新的屬性。而屬性數量、平台或版本不同,也會有點不一樣,所以如果想知道在自己的環境下差多少,要在什麼情況下使用,可以把自己的 class 交給上面的 per_instance 測量,也能順便看看程式裡有沒有地方會去讀 __dict__。