Skip to content

Python 物件駐留

學習 Python 時,你是否遇過一些看似違反直覺的行為?例如,比較兩個相同的整數或字串時,如果用 is 用來檢查兩個變數是否指向同一 Object,它有時回傳 True,有時卻是 False

這並非錯誤,而是 Python 一個精巧的性能優化設計:物件駐留 (Object Interning)

本文將深入探討物件駐留背後的雙重機制,解釋它如何運作。但僅記,學習物件駐留只是為了更好地理解程式,千萬不要依賴這些特性。

⚙️ 什麼是物件駐留 (Object Interning)?

物件駐留是一種記憶體管理和優化技術。其核心思想是:對於某些不可變 (immutable) 的 Object,如果它們被頻繁創建和使用,系統會維護一個全域的快取池 (cache)。當程式需要一個新的、值已經存在於快取池中的 Object 時,系統會直接回傳快取池中的現有 Object 參考,而不是在記憶體中重新創建一個全新的 Object。

這個機制主要應用於不可變類型,如整數 (integers)、短字串 (short strings) 和元組 (tuples),因為它們的值一旦創建就不能被修改,所以可以安全地被共用。

  1. 節省記憶體:避免在記憶體中產生大量重複 Object。
  2. 提升效能:重用現有 Object 比每次都創建新 Object 更快。

🔢 整數駐留的雙重機制

在 CPython 中,最廣為人知的物件駐留規則是針對小整數的。

CPython 保證會快取 [-5, 256] 範圍內的整數。

這是一個在程式執行期間永遠有效的快取機制。這意味著,任何時候你的程式碼需要一個在這個範圍內的整數,Python 都會給你同一個預先創建好的 Object。我們可以透過 id() 函數來驗證,id() 會回傳一個 Object 在記憶體中的唯一地址。如果兩個變數的 id() 相同,它們就指向同一個 Object。你也可以用 is 檢查兩個變數是否指向同一 Object。

python
# 1 在快取範圍內
a = 1
b = 1
print(f"id(a) = {id(a)}, id(b) = {id(b)}")
print(f"a is b: {a is b}") # True

但如果我們用一個超出範圍的數字,例如 257,會怎樣呢?這取決於你的執行方式

情況一:當你執行一個 .py 檔案

如果將以下程式碼儲存成 .py 檔案並執行:

python
# 257 超出快取範圍
c = 257
d = 257
print(f"id(c) = {id(c)}, id(d) = {id(d)}")
print(f"c is d: {c is d}")

你很可能會驚訝地發現結果依然是 True

id(c) = 2293375866448, id(d) = 2293375866448
c is d: True

原因在於編譯期優化。當你執行一個完整的腳本檔案時,Python 會先編譯整個檔案成 bytecode。在編譯過程中,Python 會進行優化,當它發現同一個不可變常數(如 257)在程式碼中出現了多次,它只會創建一個 Object,並讓所有引用都指向它。這就是為什麼 cd 指向了同一個 Object。

情況二:當你在互動式解釋器 (REPL) 中執行

如果在互動模式下逐行輸入程式碼,結果就完全不同了:

python
>>> c = 257  # 第一行被視為獨立單元進行解析與執行,創建一個 257 物件
>>> d = 257  # 第二行是全新的單元,其編譯過程無法得知先前已創建的常數,故再次創建一個 257 物件
>>> c is d
False

原因在於缺乏跨編譯單元的優化。在互動模式下,每一行都是一個獨立的編譯單元。當 Python 的編譯器處理 d = 257 這一行時,它在當前的作用域內,無法得知在之前的單元中已經創建過一個 257 物件。由於 257 不在 [-5, 256] 的全域整數快取池範圍內,Python 只能創建一個新的物件。

現在,讓我們看一個在互動式解釋器中更特別的例子。如果我們用分號 (😉 將兩個語句放在同一行,會發生什麼?

python
>>> e = 257; f = 257  # 將整行作為一個單元處理
>>> e is f
True

當你用分號將多個語句寫在同一行時,Python 的互動式解釋器會將這整個輸入行視為一個單一的程式碼區塊來進行解析與編譯,因此編譯期優化得以生效。

所以,Python 其實有兩層優化:一層是永遠生效的全域整數快取池,另一層是僅在單一編譯單元內生效的常數實例複用

💥 動態計算的例外情況

如果數值是動態計算出來的,而不是直接寫在程式碼裡的字面量,但卻在整數快取池範圍內,會發生什麼?這正是區分上述兩種機制的關鍵。

規則是:只有「預先快取的整數池 (-5 to 256)」對動態計算的結果有效。

讓我們看一個例子:

python
# Case 1: 計算結果落在快取範圍內
x = 1
a = x + 1  # 動態計算,結果為 256

in_range_result = 2

# 'a' 的值是在執行期計算出來的,但因為 2 在 [-5, 256] 範圍內
# Python 會從快取池中提取現有的 Object
print(f"a is 2: {a is in_range_result}") # True

# Case 2: 計算結果超出快取範圍
b = x + 300  # 動態計算,結果為 301

out_of_range_result = 301
# 'b' 的值 301 超出了快取範圍,Python 會創建一個全新的 Object
# 編譯期優化對此無能為力,因為 'b' 的值在編譯時是未知的
print(f"b is 301: {b is out_of_range_result}") # False

📜 字串駐留:與整數如出一轍的優化

字串駐留的行為與大於 256 的整數幾乎完全相同,它主要依賴於編譯期優化,而非像小整數一樣有預先分配的快取池。

情況一:在 .py 檔案中

當你將以下程式碼儲存為 .py 檔案並執行時,結果總是 True,無論字串內容是什麼。

python
# 即使包含空格,在單一編譯單元中也會被重用
s1 = "hello world!"
s2 = "hello world!"
print(f"s1 is s2: {s1 is s2}")  # True

這背後的原理和整數 257 的例子完全一樣:Python 編譯器在處理整個檔案時,將 "hello world!" 視為一個不可變常數。它只會創建一個該字串的 Object,然後讓 s1s2 都指向它。

情況二:在互動式解釋器 (REPL) 中

在 REPL 中,每一行都是一個獨立的編譯單元,因此結果變得不同:

python
>>> s1 = "hello world!"
>>> s2 = "hello world!"
>>> s1 is s2
False

因為第二行在編譯時無法得知第一行已經創建了一個相同的字串,所以它會創建一個全新的 Object。

REPL 中的特例:識別符字串

然而,在 REPL 中你可能會觀察到一個特例:

python
>>> s3 = "hello_world"
>>> s4 = "hello_world"
>>> s3 is s4
True

這次結果又是 True!這是一個更深層的優化細節。CPython 會對某些特定格式的字串進行隱式的、更積極的駐留。規則大致是:任何看起來像 Python 識別符 (identifier) 的字串(即只包含字母、數字或底線),都可能被自動加入到一個全域的駐留池中。

這樣做的原因是,這類字串在程式內部被大量重用(例如作為物件的屬性名、字典的鍵等),提前駐留它們可以提升效能和節省記憶體。而像 "hello world!" 這樣包含空格的字串,不符合識別符的格式,因此在 REPL 中就不會觸發這個隱式的駐留機制。

這個行為是 CPython 的一個實作細節,非常不可靠,在不同 Python 版本間可能會有變化,千萬不要依賴這些特性。

手動駐留字串:sys.intern()

當你需要保證字串被駐留時(尤其是在處理動態生成的字串時),Python 提供了 sys.intern() 函數。

sys.intern() 會維護一個全域的字串駐留池。當你傳遞一個字串給它時:

  1. 如果該字串已經在池中,它會回傳池中的現有 Object。
  2. 如果不在,它會將該字串加入池中,然後回傳它。

這對於處理大量重複的、動態生成的字串(例如,從檔案或網路讀取的標籤)非常有用,可以顯著減少記憶體用量。

python
import sys

# 動態生成的字串預設不會被駐留
s5 = "".join(['h', 'e', 'l', 'l', 'o'])
s6 = "".join(['h', 'e', 'l', 'l', 'o'])
print(f"s5 is s6 (before intern): {s5 is s6}") # False

# 使用 sys.intern() 強制駐留
s5_interned = sys.intern(s5)
s6_interned = sys.intern(s6)
print(f"s5_interned is s6_interned (after intern): {s5_interned is s6_interned}") # True

⚠️ is vs ==

使用 == 來比較值是否相等。

使用 is 來確認兩個變數是否指向同一個 Object。

物件駐留是 Python 的一個內部優化,它是一個實作細節 (implementation detail)。千萬不要依賴它的行為來設計程式,這會讓你的程式變得難以預測。 唯一推薦使用 is 的情境是與已知的單例 (Singleton) Object 比較,最常見的例子就是 None, TrueFalse

python
my_var = some_function()
if my_var is None:
    print("沒有回傳")

✅ 總結

物件駐留是 Python 中一個巧妙的設計,它在幕後默默地為我們優化了記憶體和效能。透過本文,我們了解到:

  1. 物件駐留 透過快取和重用不可變 Object 來節省資源。
  2. CPython 有多層優化機制:
    • 一個是永遠生效的 [-5, 256] 整數快取池(執行期快取)。
    • 另一個是編譯整個程式碼區塊時的常數優化(編譯期優化),適用於所有不可變常數,如整數和字串。這解釋了為何在 .py 檔案中 257 is 257"a b" is "a b" 都是 True
    • 還有一個更隱晦的窺孔優化,會自動駐留識別符類型的字串,這是在 REPL 中 is 行為不一致的原因之一。
  3. 對於字串,還可以使用 sys.intern() 進行手動駐留
  4. 動態計算的結果只會受到執行期快取池的影響(如小整數池)。
  5. 這些複雜且分層的機制,正是 is 運算子行為多變的原因,也更突顯了永遠用 == 比較值的重要性。

深入理解這些底層機制,能讓你更清晰地認識 Python 的運作方式,從而寫出更可靠、更高效的程式碼。

📚 參考資料

KF Software House