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__。

