Python__slots__詳解
摘要
當一個類需要建立大量例項時,可以通過__slots__
宣告例項所需要的屬性,
例如,class Foo(object): __slots__ = ['foo']
。這樣做帶來以下優點:
- 更快的屬性訪問速度
- 減少記憶體消耗
以下測試環境為Ubuntu16.04 Python2.7
Slots的實現
我們首先來看看用純Python是如何實現__slots__
(為了將以下實現的slots與原slots區分開來,程式碼中用單下劃線的_slots_
來代替)
class Member(object): # 定義描述器實現slots屬性的查詢 def __init__(self, i): self.i = i def __get__(self, obj, type=None): return obj._slotvalues[self.i] def __set__(self, obj, value): obj._slotvalues[self.i] = value class Type(type): # 使用元類實現slots def __new__(self, name, bases, namespace): slots = namespace.get('_slots_') if slots: for i, slot in enumerate(slots): namespace[slot] = Member(i) original_init = namespace.get('__init__') def __init__(self, *args, **kwargs): # 建立_slotvalues列表和呼叫原來的__init__ self._slotvalues = [None] * len(slots) if original_init(self, *args, **kwargs): original_init(self, *args, **kwargs) namespace['__init__'] = __init__ return type.__new__(self, name, bases, namespace) # Python2與Python3使用元類的區別 try: class Object(object): __metaclass__ = Type except: class Object(metaclass=Type): pass class A(Object): _slots_ = 'x', 'y' a = A() a.x = 10 print(a.x)
在CPython中,當一個A類定義了__slots__ = ('x', 'y')
,A.x
就是一個有__get__
和__set__
方法的member_descriptor
,並且在每個例項中可以通過直接訪問記憶體(direct memory access)獲得。(具體實現是用偏移地址來記錄描述器,通過公式可以直接計算出其在記憶體中的實際地址 ,訪問__dict__
也是用相同的方法,也就是說訪問A.__dict__
和A.x
描述器的速度是相近的)
在上面的例子中,我們用純Python實現了一個等價的slots。當一個元類看到_slots_
定義了x和y,它會建立兩個的類變數,x = Member(0)
y = Member(1)
。然後,裝飾__init__
方法讓新的例項建立一個_slotvalues
列表。
例子中的實現和CPython不同的是:
-
例子中
_slotvalues
是一個儲存在類物件外部的列表,而在CPython中它與例項物件儲存在一起,可以通過直接訪問記憶體獲得。相應地,member decriptor
也不是存在外部列表中,而同樣可以通過直接訪問記憶體獲得。 -
預設情況下,
__new__
方法會為每個例項建立一個字典__dict__
來儲存例項的屬性。但如果定義了__slots__
,__new__
方法就不會再建立這個字典。 -
由於不存在
__dict__
來儲存新的屬性,所以使用一個不在__slots__
>>> class A(object): __slots__ = ('x')
>>> a = A()
>>> a.y = 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
Attribute: 'A' object has no attribute 'y'
可以利用這種特性來限制例項的屬性。
更快的屬性訪問速度
預設情況下,訪問一個例項的屬性是通過訪問該例項的__dict__
來實現的。如訪問a.x
就相當於訪問a.__dict__['x']
。為了便於理解,我粗略地將它拆分為四步:
a.x
2.a.__dict__
3.a.__dict__['x']
4. 結果
從__slots__
的實現可以得知,定義了__slots__
的類會為每個屬性建立一個描述器。訪問屬性時就直接呼叫這個描述器。在這裡我將它拆分為三步:
b.x
2.member decriptor
3. 結果
我在上文提到,訪問__dict__
和描述器的速度是相近的,而通過__dict__
訪問屬性多了a.__dict__['x']
字典訪值一步(一個雜湊函式的消耗)。由此可以推斷出,使用了__slots__
的類的屬性訪問速度比沒有使用的要快。下面用一個例子驗證:
from timeit import repeat
class A(object): pass
class B(object): __slots__ = ('x')
def get_set_del_fn(obj):
def get_set_del():
obj.x = 1
obj.x
del obj.x
return get_set_del
a = A()
b = B()
ta = min(repeat(get_set_del_fn(a)))
tb = min(repeat(get_set_del_fn(b)))
print("%.2f%%" % ((ta/tb - 1)*100))
在本人電腦上測試速度有0-20%左右的提升。
減少記憶體消耗
Python內建的字典本質是一個雜湊表,它是一種用空間換時間的資料結構。為了解決衝突的問題,當字典使用量超過2/3時,Python會根據情況進行2-4倍的擴容。由此可預見,取消__dict__
的使用可以大幅減少例項的空間消耗。
下面用pympler
模組測試在不同屬性數目下,使用__slots__
前後單個例項佔用記憶體大小:
from string import ascii_letters
from pympler.asizeof import asizesof
def slots_memory(num=0):
attrs = list(ascii_letters[:num])
class Unslotted(object): pass
class Slotted(object): __slots__ = attrs
unslotted = Unslotted()
slotted = Slotter()
for attr in attrs:
unslotted.__dict__[attr] = 0
exec('slotted.%s = 0' % attr, globals(), locals())
memory_use = asizesof(slotted, unslotted, unslotted.__dict__)
return memory_use
def slots_test(nums):
return [slots_memory(num) for num in nums]
測試結果:(單位:位元組)
屬性數量 | slotted | unslotted(__dict__ ) |
---|---|---|
0 | 80 | 334(280) |
1 | 152 | 408(344) |
2 | 168 | 448(384) |
8 | 264 | 1456(1392) |
16 | 392 | 1776(1712) |
25 | 536 | 4440(4376) |
從上述結果可看到使用__slots__
能極大地減少記憶體空間的消耗,這也是最常見到的用法。
使用筆記
1. 只有非字串的迭代器可以賦值給__slots__
>>> class A(object): __slots__ = ('a', 'b', 'c')
>>> class B(object): __slots__ = 'abcd'
>>> B.__slots__
'abc'
若直接將字串賦值給它,就只有一個屬性。
2. 關於slots的繼承問題
在一般情況下,使用slots的類需要直接繼承object
,如class Foo(object): __slots__ = ()
在繼承自己建立的類時,我根據子類父類是否定義了__slots__
,將它細分為六種情況:
- 父類有,子類沒有:
子類的例項還是會自動建立__dict__
來儲存屬性,不過父類__slots__
已有的屬性不受影響。
>>> class Father(object): __slots__ = ('x')
>>> class Son(Base): pass
>>> son = Son()
>>> son.x, son.y = 1, 1
>>> son.__dict__
>>> {'y': 1}
- 父類沒有,子類有:
雖然子類取消了__dict__
,但繼承父類後它會繼續生成。同上面一樣,__slots__
已有的屬性不受影響。
>>> class Father(object): pass
>>> class Son(Father): __slots__ = ('x')
>>> son = Son()
>>> son.x, son.y = 1, 1
>>> son.__dict__
>>> {'y': 1}
- 父類有,子類有:
只有子類的__slots__
有效,訪問父類有子類沒有的屬性依然會報錯。
>>> class Father(object): __slots__ = ('x', 'y')
>>> class Son(Father): __slots__ = ('x', 'z')
>>> son = Son()
>>> son.x, son.y, son.z = 1, 1, 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Son' object has no attribute 'y'
- 多個擁有非空slots的父類:
由於__slots__
的實現不是簡單的列表或字典,多個父類的非空__slots__
不能直接合並,所以使用時會報錯(即使多個父類的非空__slots__
是相同的)。
>>> class Father(object): __slots__ = ('x')
>>> class Mother(object): __slots__ = ('x')
>>> class Son(Father, Mother): pass
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Error when calling the metaclass bases
multiple bases have instance lay-out conflict
-
多個空slots的父類:
這是關於slots使用多繼承唯一辦法。 -
某些父類有,某些父類沒有:
跟第一種情況類似。
小結:為了正確使用__slots__
,最好直接繼承object
。如有需要用到其他父類,則父類和子類都要定義slots,還要記得子類的slots會覆蓋父類的slots。
除非所有父類的slots都為空,否則不要使用多繼承。
3. 新增__dict__
獲取動態特性
在特殊情況下,可以在__slots__
裡新增__dict__
來獲取與普通例項同樣的動態特性。
>>> class A(object): __slots__ = ()
>>> class B(A): __slots__ = ('__dict__', 'x')
>>> b = B()
>>> b.x, b.y = 1, 1
>>> b.__dict__
{'y': 1}
4. 新增__weakref__
獲取弱引用功能
__slots__
的實現不僅取消了__dict__
的生成,也取消了__weakref__
的生成。同樣的,在__slots__
將其新增可以重新獲取弱引用這一功能。
5. 不能通過類屬性給例項設定預設值
定義了__slots__
後,這個類的類屬性都變為了描述器。如果給類屬性賦值,就會把描述器給覆蓋了。
6. namedtuple
利用內建的namedtuple不可變的特性,結合slots,能創建出一個輕量不可變的例項。(約等於一個元組的大小)
>>> from collections import namedtuple
>>> class MyNt(namedtupele('MyNt', 'bar baz')): __slots__ = ()
>>> nt = MyNt('r', 'z')
>>> nt.bar
'r'
>>> nt.baz
'z'
總結
當一個類需要建立大量例項時,可以使用__slots__
來減少記憶體消耗。如果對訪問屬性的速度有要求,也可以酌情使用。另外可以利用slots的特性來限制例項的屬性。而用在普通類身上時,使用__slots__
後會喪失動態新增屬性和弱引用的功能,進而引起其他錯誤,所以在一般情況下不要使用它。
參考資料:
本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線,否則保留追究法律責任的權利.