Python __slots__ 介紹與教學

Posted on  Oct 16, 2023  in  Python 程式設計 - 中階  by  Amo Chen  ‐ 4 min read

__slots__ 是 Python 類別必須認識的屬性,這個屬性為我們帶來節省記憶體資源以及增加存取(access)類別屬性(attribute)效率的好處,但相對地,它也犧牲原本方便擴充類別屬性的易用性。

總的來說,它是寫出高效率 Python 程式碼的一環,平常用不到它沒有關係,但如果要榨出更多記憶體資源以及效能的話, __slots__ 是一定能派上用場。

本文將透過各種範例認識 __slots__ 並學會如何運用它。

本文環境

$ pip install pympler

__slots__ 簡介

This class variable can be assigned a string, iterable, or sequence of strings with variable names used by instances.

__slots__ 是 1 個類別變數,它可以接受 string, iterable 或是多個 string, 而這些值會被作為類別實例化之後可存取的屬性名。

舉例子看最快:

class Point(object):
    __slots__ = "x", "y"

當我們實例化 Point 類別之後,就只會有 xy 屬性可以存取,如果要存取 x , y 以外的屬性就會有 AttributeError 產生:

p = Point()
p.x = 0
p.y = 0
p.z = 0  # <--- AttributeError: 'Point' object has no attribute 'z'

除了 __slots__ = "x", "y" 這種指定方式,以下也是 __slots__ 可以接受的方式:

__slots__ = ("x", "y", )
__slots__ = ["x", "y", ]
__slots__ = {"x", "y", }

為什麼要使用 __slots__ ?

Python 類別的實作是預設使用 dictionary 存成員(屬性、方法等),因此帶來記憶體的用量較多以及屬性的存取較慢的問題(因為屬性的存取必須先算 hash 再查找)。

然而,使用 __slots__ 的類別則是使用陣列的方式存屬性,因此在記憶體的用量上以及存取效率上都有所改善:

__slots__ allow us to explicitly declare data members (like properties) and deny the creation of __dict__ and __weakref__. The space saved over using __dict__ can be significant. Attribute lookup speed can be significantly improved as well.

以下用 Pympler 測量一般類別以及使用 __slots__ 類別所佔的記憶體大小,下列定義 1 個屬性只有 x , yPoint 類別,並用 asizeof.asizeof() 測量 Point 實例所佔的記憶體大小:

from pympler import asizeof

class Point(object):
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(0, 0)
asizeof.asizeof(p)

上述執行結果如下,可以看到 Point 實例約佔 288 bytes:

288 #bytes

同時我們也可以用 dir() 函式查看 Point 實例的成員:

>>> print(dir(Point(0, 0)))

從結果可以看到成員中確實有包含 x, y, __dict__ 以及 __weakref__

['__class__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 '__weakref__',
 'x',
 'y']

如果我們進一步在 Point 類別中宣告 __slots__ 並指定只有 xy 2 個屬性,並同樣查看 Point 實例所佔的記憶體大小:

from pympler import asizeof

class Point(object):
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(0, 0)
asizeof.asizeof(p)

上述執行結果如下,可以看到 Point 實例約佔 72 bytes, 相較於不使用 __slots__ 的類別而言,省下不少記憶體空間:

72 #bytes

接著用 dir() 函式查看使用 __slots__Point 實例的成員,查看是否真如官方文件所說 __dict__ 以及 __weakref__ 會消失:

>>> print(dir(Point(0, 0)))

從結果可以看到成員中確實已經沒有 __dict__ 以及 __weakref__ ,取而代之的是 __slots__ :

['__class__',
 '__delattr__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__slots__',
 '__str__',
 '__subclasshook__',
 'x',
 'y']

這就是使用 __slots__ 所帶來的變化。

使用 __slots__ 應注意的事項

雖然使用 __slots__ 帶來記憶體用量以及存取屬性的效能優化,但 __slots__ 仍有其不便之處。

無法自由存取額外屬性

例如當我們試圖新增 1 個私有屬性 _dictPoint 類別之中,用以計算 Point 與原點 (0, 0) 的距離時,如下列範例:

import math

class Point(object):
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def dist(self):
        self._dist = math.sqrt(self.x**2 + self.y**2)
        return self._dist

執行時就會出現 AttributeError 找不到屬性 _dist :

      8   def dist(self):
----> 9     self._dist = math.sqrt(self.x**2 + self.y**2)

AttributeError: 'Point' object has no attribute '_dist'

這是由於 __slots__ 未定義 _dict 屬性之故,要修好這問題,就必須在 __slots__ 中加入 _dist 字串:

import math

class Point(object):
    __slots__ = ('x', 'y', '_dist')
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def dist(self):
        self._dist = math.sqrt(self.x**2 + self.y**2)
        return self._dist

也就是說,使用 __slots__ 會喪失自由存取額外屬性的方便性。

不過 Python 還是有提供使用 __slots__ 能自由存取額外屬性的方法,也就是在 __slots__ 中加入 __dict__ 字串,如下列範例:

class ArbitraryPoint(object):
    __slots__ = ('x', 'y', '__dict__')

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

    def dist(self):
        self._dist = math.sqrt(self.x**2 + self.y**2)
        return self._dist

print(ArbitraryPoint(3, 4).dist())

如此一來, x, y 以外的屬性,都還是用 __dict__ 存,但相對的,我們就喪失想使用 __slots__ 節省記憶體的意義,因此這種使用方式相當少見。

另外,如果有需要使用 __weakref__, 也可以在 __slots__ 中加入 __weakref__ 字串:

class ArbitraryPoint(object):
    __slots__ = ('x', 'y', '__dict__', '__weakref__')

__slots__ 會與其他同名類別屬性(class variable)產生衝突

下列範例試圖為 y 屬性建立預設值:

class Point(object):
    __slots__ = ('x', 'y')
    y = 1

一旦試圖執行上述範例,就會出現 ValueError 並顯示 __slots__ 中的 y 與類別屬性 y 產生衝突:

----> 1 class Point(object):
      2     __slots__ = ('x', 'y')
      3     y = 1
      4
      5

ValueError: 'y' in __slots__ conflicts with class variable

這是因為 __slots__ 中定義的屬性也會以類別屬性的方式存在,下列範例就能夠看到即使我們不實例化 Point 類別,也會有 xy 屬性存在:

class Point(object):
    __slots__ = ('x', 'y')

print(dir(Point))

上述範例執行結果如下:

['__class__',
 '...skipped...',
 'x',
 'y']

因此使用 __slots__ 要注意同名類別屬性的存在,否則會導致 ValueError , 如果要為 y 設定預設值,可以在 __init__() 方法中設定:

class Point(object):
    __slots__ = ('x', 'y')
    def __init__(self, x, y=1):
        self.x = x
        self.y = y

繼承問題

由於 __slots__ 中定義的屬性是以類別屬性的方式存在,因此假設有類別想繼承 Point 類別,並想覆寫 __slots__ 設定,是無法產生作用的,屬性會一併從父類別繼承下來,例如下列類別 PointZ 看起來想要覆寫 Point 類別的 __slots__ 變成只有 z 屬性:

class Point(object):
    __slots__ = ('x', 'y')


class PointZ(Point):
    __slots__ = ('z', )

但實際上,並無法覆寫。

可以用 dir(PointZ) 檢查看看:

>>> print(dir(PointZ))
['__class__',
 '...skipped...',
 'x',
 'y',
 'z']

所以一但我們更改父類別的 __slots__ , 所有的子類別都會受到影響!

這就是使用 __slots__ 要注意的繼承問題。

總結

__slots__ 是 1 個相當有用的工具,特別是在需要優化大量相同類別的記憶體用量的情況下。

就算平常用不到,可以先記起來,等到你遇到瓶頸時,它自然就會在你腦海中浮現!

以上!

Enjoy!

References

Data model - slots

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!