Python `__slots__` 進(jìn)階指南:不止于節(jié)省內(nèi)存,從原理到實踐
相信不少 Python 開發(fā)者都聽說過 __slots__,知道它可以幫助節(jié)省內(nèi)存。但你是否思考過它背后的原理,以及在實際開發(fā)中的其他妙用?讓我們一起深入探討。
從一個性能問題說起
假設(shè)你的一個系統(tǒng)需要處理大量的訂單對象:
class Order:
def __init__(self, order_id, symbol, price, quantity):
self.order_id = order_id
self.symbol = symbol
self.price = price
self.quantity = quantity
# 創(chuàng)建100萬個訂單對象
orders = [Order(i, "BTC", 30000, 1) for i in range(1_000_000)]
看起來很普通的代碼,但當(dāng)你用內(nèi)存分析工具一看,這些對象占用的內(nèi)存可能遠(yuǎn)超預(yù)期。為什么?
__dict__ 的開銷
在 Python 中,普通類的實例屬性都存儲在 __dict__ 字典中。這種設(shè)計非常靈活,允許我們動態(tài)添加屬性:
order = Order(1, "BTC", 30000, 1)
order.new_field = "動態(tài)添加的字段" # 完全合法
但這種靈活性是有代價的:
- 每個實例都要維護(hù)一個字典
- 字典本身為了支持快速查找,會預(yù)分配一定的空間
- 字典的開銷在對象數(shù)量大時會累積成可觀的內(nèi)存消耗
__slots__ 登場
讓我們改造一下 Order 類:
class Order:
__slots__ = ['order_id', 'symbol', 'price', 'quantity']
def __init__(self, order_id, symbol, price, quantity):
self.order_id = order_id
self.symbol = symbol
self.price = price
self.quantity = quantity
這個改動帶來了什么變化?
- 內(nèi)存占用顯著降低(通??梢怨?jié)省 30% 到 50% 的內(nèi)存)
- 屬性訪問速度提升(因為不需要字典查找)
- 代碼更加"顯式",所有可能的屬性一目了然
__slots__ 的工作原理
當(dāng)我們使用 __slots__ 時,Python 會:
- 在類級別創(chuàng)建一個固定的內(nèi)存布局,類似 C 語言中的結(jié)構(gòu)體
- 不再為實例創(chuàng)建 __dict__ 和 __weakref__ 屬性(除非顯式添加到 __slots__ 中)
- 將屬性直接存儲在預(yù)分配的固定大小的數(shù)組中,而不是字典里
這帶來了兩個直接的好處:
- 屬性訪問更快:直接通過數(shù)組偏移量訪問,不需要哈希查找
- 內(nèi)存占用更少:
沒有 __dict__ 的開銷(每個實例至少節(jié)省一個字典的內(nèi)存)
屬性存儲更緊湊(類似 C 結(jié)構(gòu)體)
沒有哈希表的空間預(yù)留
讓我們用代碼驗證這些優(yōu)勢:
import sys
import time
import tracemalloc
class OrderWithDict:
def __init__(self, order_id, symbol, price, quantity):
self.order_id = order_id
self.symbol = symbol
self.price = price
self.quantity = quantity
class OrderWithSlots:
__slots__ = ['order_id', 'symbol', 'price', 'quantity']
def __init__(self, order_id, symbol, price, quantity):
self.order_id = order_id
self.symbol = symbol
self.price = price
self.quantity = quantity
def measure_memory_and_speed(cls, n_objects=1_000_000):
# 啟動內(nèi)存跟蹤
tracemalloc.start()
# 創(chuàng)建對象
start_time = time.time()
objects = [cls(i, "BTC", 30000, 1) for i in range(n_objects)]
creation_time = time.time() - start_time
# 測量內(nèi)存
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
# 測試屬性訪問速度
start_time = time.time()
for obj in objects:
_ = obj.order_id
_ = obj.symbol
_ = obj.price
_ = obj.quantity
access_time = time.time() - start_time
return {
"內(nèi)存占用(MB)": peak / 1024 / 1024,
"對象創(chuàng)建時間(秒)": creation_time,
"屬性訪問時間(秒)": access_time
}
def main():
# 測試普通類
print("測試普通類:")
dict_results = measure_memory_and_speed(OrderWithDict)
for k, v in dict_results.items():
print(f"{k}: {v:.2f}")
print("\n測試使用 __slots__ 的類:")
slots_results = measure_memory_and_speed(OrderWithSlots)
for k, v in slots_results.items():
print(f"{k}: {v:.2f}")
# 計算差異百分比
print("\n性能提升:")
for k in dict_results:
improvement = (dict_results[k] - slots_results[k]) / dict_results[k] * 100
print(f"{k}: 提升 {improvement:.1f}%")
# 展示單個對象的大小差異
normal_obj = OrderWithDict(1, "BTC", 30000, 1)
slots_obj = OrderWithSlots(1, "BTC", 30000, 1)
print(f"\n單個對象大小對比:")
print(f"普通對象: {sys.getsizeof(normal_obj)} bytes")
print(f"普通對象的__dict__: {sys.getsizeof(normal_obj.__dict__)} bytes")
print(f"普通對象總大小: {sys.getsizeof(normal_obj) + sys.getsizeof(normal_obj.__dict__)} bytes")
print(f"Slots對象: {sys.getsizeof(slots_obj)} bytes")
try:
print(f"Slots對象的__dict__: {sys.getsizeof(slots_obj.__dict__)} bytes")
except AttributeError as e:
print(f"Slots對象沒有__dict__屬性:{e}")
if __name__ == "__main__":
main()
輸出如下:
測試普通類:
內(nèi)存占用(MB): 179.71
對象創(chuàng)建時間(秒): 1.08
屬性訪問時間(秒): 0.08
測試使用 __slots__ 的類:
內(nèi)存占用(MB): 95.79
對象創(chuàng)建時間(秒): 0.67
屬性訪問時間(秒): 0.07
性能提升:
內(nèi)存占用(MB): 提升 46.7%
對象創(chuàng)建時間(秒): 提升 37.5%
屬性訪問時間(秒): 提升 4.8%
單個對象大小對比:
普通對象: 48 bytes
普通對象的__dict__: 104 bytes
普通對象總大小: 152 bytes
Slots對象: 64 bytes
Slots對象沒有__dict__屬性:'OrderWithSlots' object has no attribute '__dict__'
這里注意到,使用了 __slots__ 的類沒有 __dict__ 屬性,這是因為它的屬性是直接存儲在數(shù)組中的。此外,直接對對象進(jìn)行 sizeof 操作,是不包含其 __dict__ 的大小的。
當(dāng)我們使用 sys.getsizeof() 測量單個對象大小時,它只返回對象的直接內(nèi)存占用,而不包括其引用的其他對象(如 __dict__ 中存儲的值)的大小。
不止于節(jié)省內(nèi)存
__slots__ 除了優(yōu)化性能,還能幫助我們寫出更好的代碼:
1. 接口契約
__slots__ 實際上定義了一個隱式的接口契約,明確告訴其他開發(fā)者,“這個類就這些屬性,不多不少”:
class Position:
__slots__ = ['symbol', 'quantity']
def __init__(self, symbol, quantity):
self.symbol = symbol
self.quantity = quantity
這比寫文檔更有效 - 代碼本身就是最好的文檔。
2. 防止拼寫錯誤
position = Position("BTC", 100)
position.quantiy = 200 # 拼寫錯誤,會立即拋出 AttributeError
如果沒有 __slots__,這個錯誤可能潛伏很久才被發(fā)現(xiàn)。
3. 更好的封裝
__slots__ 天然地限制了屬性的隨意添加,這促使我們思考類的設(shè)計是否合理:
class Account:
__slots__ = ['id', 'balance', '_transactions']
def __init__(self, id):
self.id = id
self.balance = 0
self._transactions = []
def add_transaction(self, amount):
self._transactions.append(amount)
self.balance += amount
__slots__ vs @dataclass:該用誰?
既然都是用于數(shù)據(jù)類的定義,@dataclass 和 __slots__ 是什么關(guān)系?讓我們先看一個例子:
from dataclasses import dataclass
# 普通dataclass
@dataclass
class TradeNormal:
symbol: str
price: float
quantity: int
# 帶slots的dataclass
@dataclass
class TradeWithSlots:
__slots__ = ['symbol', 'price', 'quantity']
symbol: str
price: float
quantity: int
# 結(jié)合使用的推薦方式
@dataclass(slots=True) # Python 3.10+
class TradeModern:
symbol: str
price: float
quantity: int
關(guān)鍵點解析:
- 默認(rèn)情況:@dataclass 裝飾器默認(rèn)不會使用 __slots__,每個實例依然會創(chuàng)建 __dict__
- Python 3.10的改進(jìn):引入了 slots=True 參數(shù),可以自動為 dataclass 啟用 __slots__
- 動態(tài)添加屬性的陷阱:
@dataclass
class Trade:
symbol: str
price: float
trade = Trade("BTC", 30000)
trade.quantity = 1 # 可以,但會創(chuàng)建 __dict__
@dataclass(slots=True)
class TradeLocked:
symbol: str
price: float
trade_locked = TradeLocked("BTC", 30000)
trade_locked.quantity = 1 # AttributeError!
最佳實踐:@dataclass 和 __slots__ 的協(xié)同使用
- Python 3.10+ 的推薦用法:
@dataclass(slots=True, frozen=True)
class Position:
symbol: str
quantity: int
- 早期Python版本的替代方案:
@dataclass
class Position:
__slots__ = ['symbol', 'quantity']
symbol: str
quantity: int
如何選擇?
- 使用 @dataclass(slots=True) 的場景:
類的屬性在定義后不會改變
需要類型提示和自動生成方法
Python 3.10+環(huán)境
注重內(nèi)存效率
- 使用普通 @dataclass 的場景:
需要動態(tài)添加屬性
使用了某些需要 __dict__ 的庫(如某些ORM)
Python 3.10以下版本
開發(fā)階段,類的結(jié)構(gòu)還在調(diào)整
- 直接使用 __slots__ 的場景:
極致的性能要求
類的結(jié)構(gòu)非常簡單
不需要dataclass提供的額外功能
注意事項和提示
- 繼承關(guān)系:
@dataclass(slots=True)
class Parent:
x: int
@dataclass(slots=True)
class Child(Parent):
y: int
# Child會自動繼承Parent的slots
- 動態(tài)屬性檢查:
@dataclass(slots=True)
class Trade:
symbol: str
def __setattr__(self, name, value):
if name not in self.__slots__:
raise AttributeError(f"Cannot add new attribute '{name}'")
super().__setattr__(name, value)
此外,某些涉及動態(tài)屬性的特性會受限:
class Frozen:
__slots__ = ['x']
obj = Frozen()
# 以下操作將不可用:
# vars(obj) # TypeError: vars() argument must have __dict__ attribute
# setattr(obj, 'y', 1) # AttributeError
- 性能優(yōu)化建議:
如果確定類的結(jié)構(gòu)不會改變,優(yōu)先使用 @dataclass(slots=True)
在性能關(guān)鍵的代碼路徑上,考慮使用性能分析工具驗證收益
數(shù)據(jù)類(如 DTO)且實例數(shù)量大時,用 __slots__ 是個好選擇
如果類的屬性集合是確定的,使用 __slots__ 可以獲得更好的代碼質(zhì)量
記住:過早優(yōu)化是萬惡之源,先保證代碼正確性和可維護(hù)性
總結(jié)
__slots__ 不僅僅是一個性能優(yōu)化工具,它還能幫助我們寫出更清晰、更健壯的代碼。在設(shè)計數(shù)據(jù)密集型應(yīng)用時,合理使用 __slots__ 可以同時獲得性能和代碼質(zhì)量的提升。
實際工作中,可以先寫普通的類,當(dāng)發(fā)現(xiàn)性能瓶頸或需要更嚴(yán)格的屬性控制時,再考慮引入 __slots__。畢竟,過早優(yōu)化是萬惡之源,而 __slots__ 的使用也確實會帶來一些靈活性的損失。