先來看一段會讓很多人愣住的程式碼:
1 | a = 256 |
同樣是「兩個變數被賦值為相同的整數字面量」,為什麼 256 這組會是 True,換成 257 之後卻可能變成 False?更弔詭的是,把這段程式放在 .py 檔裡執行,第二個結果常常又會變回 True;但若在 REPL 一行一行敲進去,則幾乎都是 False1。這種看似違反直覺的行為,其實背後都有一個合理的解釋——而解開這個謎團的鑰匙,正是本文的主角:id() 函式。
id() 語法概說
根據 Python 官方文件,id(object) 回傳一個整數,該整數在該物件的生命週期 (lifetime) 內是唯一且不變的。換句話說,它滿足兩個性質:
- 只要物件還活著,
id()不會變。 - 同一時間活著的兩個不同物件,不會有相同的
id()。
在 CPython (也就是我們最常使用的 Python 實作版本) 中,這個整數通常就是物件在記憶體中的位址。
警告
在正式進入進階議題之前,我們先透過一個簡單的範例,觀察 id() 的基本用法:
1 | s = "python" |
細心的讀者肯定有發現,id() 接受一個參數,回傳一個整數。這個整數本身沒有什麼意義,真正有意義的是「兩個 id() 是否相等」,因為這代表了兩個名稱是否指向同一個物件。
Python 甚至為這個概念提供了一個專屬的關鍵字 is,其本質上等價於比較兩個物件的 id():
1 | a = [1, 2] |
生命週期內唯一的陷阱
前面提到 id() 具有「唯一且不變」的特性,但這句話有一個容易被忽略的前提:只有在該物件活著時才成立。當物件被回收後,舊的 id() 有可能被新物件重用2。
1 | print(id(object())) |
上述程式每次都建立一個臨時物件,印完就沒有任何名稱指向它,於是馬上可以被回收。CPython 可能很快重用同一塊記憶體,因此才會出現 id() 重複的現象。
若想安全地比較兩個物件是不是同一個,應該先把它們用變數留住:
1 | a = object() |
小整數快取
回到一開始的 256 與 257 範例,或許已經可以猜到答案的輪廓。在 CPython 中,直譯器啟動時會預先建立一段常用整數物件,通常是 -5 到 256,放在所謂的小整數快取 (small int cache) 裡。當程式中寫出這個範圍內的整數,Python 會直接重用既有物件,不會再新建一個。
1 | a = 256 |
超出這個範圍,就不再保證:
1 | x = 257 |
相關的 _PyLong_SMALL_INTS 定義可以在 CPython 原始碼的 Objects/longobject.c 中找到。不過要注意的是,這個範圍屬於版本相關的實作細節,並非語言規範保證的行為3。
常數折疊
既然 257 不在小整數快取內,為什麼有時候 x is y 的結果還是 True?答案就在於常數折疊 (constant folding) 與同一個常數池 (code object) 的重用機制。
先看同一個 .py 檔中的範例:
1 | x = 257 |
再看在 REPL 分兩行輸入的情境:
1 | x = 257 |
原因其實是:.py 編譯時,編譯器可能把相同字面量放進同一個常數池,兩次載入都拿到同一個物件;而 REPL 每一次輸入通常是獨立的編譯單位,常數池不共享。可以用 dis 模組來親自驗證:
1 | import dis |
兩次都是 LOAD_CONST 1,代表使用同一個常數槽位。這就是為什麼看似一樣的程式,在不同的執行脈絡下,is 的結果會有差異。
小叮嚀
此處所看到的是編譯器與執行環境的策略,而不是 Python 語言規範所要求的行為。把它當作效能調校與除錯的知識即可,千萬不要當成語意保證,更不要拿來作為商業邏輯的判斷依據。
字串駐留
除了整數,字串也有類似的重用機制,稱為字串駐留 (string interning)。常見情況下,符合識別字規則 (identifier-like) 的短字串會被 Python 自動 intern。簡單來說,如果兩個字串一模一樣,Python 有機會只在記憶體中留一份,讓多個變數一起指向它,如此便可省下空間,比較起來也更快。
1 | a = "hello" |
但帶有空格或特殊符號的字串通常不保證:
1 | s1 = "hello world" |
若想明確要求共用,可以使用 sys.intern():
1 | import sys |
實務上,若你有大量重複的小字串 (例如 CSV 欄位名稱、狀態碼),適度地 intern 可以減少記憶體占用與比較成本:
1 | import sys |
進階變數議題
在掌握 id() 的基本語意之後,接下來要面對的是一系列看似違反直覺、卻又反覆出現在日常程式中的現象。為什麼同樣的字面量有時共用、有時不共用?為什麼 is 與 == 偶爾會給出不同答案?為什麼物件被回收後,新物件竟可能「繼承」舊的身份編號?這些問題的背後,牽涉到 CPython 的快取策略、編譯器的常數折疊,以及記憶體管理的底層細節。
is 與 ==
在 Python 中,is 與 == 經常被初學者混用,但兩者其實有著本質上的差異:
| 運算子 | 比較對象 | 底層機制 |
|---|---|---|
is |
物件身份 (identity) | 本質上等價於比較 id() |
== |
物件的值 (value) | 呼叫物件的 __eq__ 魔術方法 |
`is` 與 `==` 的差異對照
1 | a = [1, 2] |
最重要的實務準則是:判斷 None 請一律使用 is。
1 | x = None |
那麼,為什麼 is None 會比 == None 好呢?
None是一個單例 (singleton),全程式中只會有一個None物件,用身份比較語意最精準。==會呼叫對方的__eq__,可能產生非預期的副作用,甚至回傳非布林值。
函式、類別、模組的 id()
很多初學者會誤以為 id() 只能用在整數、字串這類基本型別上。事實上,在 Python 裡一切皆為物件,函式、類別、甚至模組本身也都是貨真價實的物件,因此它們也都能被 id() 觀察:
1 | import sys |
特別值得一提的是「模組」這個物件。模組常被視為單例,這是因為 Python 的 import 機制會透過 sys.modules 來快取載入過的模組:
1 | import math |
這個機制也是 Python 生態系大量使用「模組層級狀態」的基礎之一。
常見陷阱
理解 id() 與 intern,對於效能調校與除錯有直接的價值。例如在資料分析世界裡,pandas 的 category dtype,底層概念也是把重複值映射成共享字典與代碼,藉此減少重複儲存。
但一個最常見的誤用是:拿 id() 當作長期追蹤物件的鍵值。
1 | obj = object() |
這種寫法在長時間執行的程序中特別危險。更穩健的做法是使用 weakref:
1 | import weakref |
weakref 追蹤的是物件關聯,而不是可能被重用的數字身份,語意上正確得多。
練習
關於 id() 函式的特性,以下敘述何者正確?
- 在物件的生命週期內,
id()回傳的整數唯一且不變 id()回傳的整數在程式執行期間永不重複id()一定等於物件在記憶體中的位址id()只能用於整數與字串等基本型別
正確答案是第一項。根據 Python 官方文件,id() 的保證是「在該物件的生命週期內唯一且不變」。當物件被回收後,舊的 id() 可能被新物件重用,因此第二項錯誤。第三項只是 CPython 的實作細節,並非語言規範;第四項則完全錯誤,函式、類別、模組等任何物件都可以使用 id()。
在 CPython 中執行以下程式碼,最有可能的輸出是什麼?
1 | a = 256 |
True TrueTrue與 (True或False) 皆可能False FalseFalse True
256 落在 CPython 的小整數快取範圍 (通常是 -5 到 256) 內,因此 a is b 必定為 True。而 1000 不在此範圍,但若放在同一個 .py 檔中,常數折疊可能讓 x is y 也變成 True;若在 REPL 分行輸入則常為 False。因此第二項「True 與 (True 或 False) 皆可能」最為精確。
判斷一個變數是否為 None 時,以下哪一種寫法最為推薦?
x == Nonex.equals(None)x is Noneid(x) == 0
推薦使用 x is None。原因有二:其一,None 是全程式中唯一的單例物件,用身份比較 (is) 的語意最為精準;其二,== 會呼叫對方的 __eq__ 魔術方法,可能產生副作用或回傳非預期的結果。
請說明下列兩段程式在 .py 檔中執行時,為什麼 print(a is b) 的結果可能不同?
1 | # 情境一 |
情境一的 "hello" 符合識別字規則 (identifier-like),CPython 會自動進行字串駐留 (string interning),讓 a 與 b 指向同一個物件,因此 a is b 常為 True。情境二的 "hello world" 因為包含空格,通常不會被自動 intern,a 與 b 可能指向不同物件,結果可能是 True 也可能是 False。若想強制共用,可使用 sys.intern() 明確要求。
本章小結
在這一章中,我們從 id() 這個看似簡單的內建函式出發,揭開了 Python 變數行為的神秘面紗。以下是幾個關鍵筆記:
核心觀念:變數是標籤,不是盒子
- Python 的變數是指向物件的名稱綁定,並不是裝值的容器。
- 所有的行為 (賦值、比較、傳參) 都圍繞這個核心事實展開。
id() 的使用準則
- 生命週期內唯一:物件被回收後,
id()可能被新物件重用。 - 不要當作追蹤鍵:長期追蹤物件請使用
weakref,而非id()。 - 一切皆物件:函式、類別、模組都可以用
id()觀察。
身份比較的最佳實踐
isvs==:is比身份、==比值;判斷None一律用is。- 小整數快取:CPython 預先建立
-5到256的整數物件。 - 常數折疊:
.py檔中同一字面量可能共用常數池,REPL 則不會。 - 字串駐留:符合識別字規則的短字串可能被自動 intern,必要時用
sys.intern()明確要求。
警語:區分語言規範與實作細節
本文所討論的許多現象 (如小整數快取、常數折疊、字串駐留等) 都是 CPython 的實作細節,並非 Python 語言規範所保證的行為。理解這些細節可以寫出更省記憶體、更易除錯的程式碼,但千萬不要把它們當成商業邏輯的硬依賴。
1. 若尚未親自試過,強烈建議現在就打開終端機,分別在 REPL 與 .py 檔執行一次。同樣的程式碼在兩種環境下得到不同結果,正是本文後續章節要解答的核心謎題。 ↩
2. 這也是為什麼在多執行緒或長時間執行的程式中,直接儲存 id() 來追蹤物件會是一個危險的做法,後文會再詳細說明。 ↩
3. 常見說法是 -5..256,但 CPython 的最新開發分支已經可以看到不同的常數設定。若對實作細節有興趣,可直接參考 CPython 原始碼。 ↩