Python 教學系列: 變數的本質與 id() 函式

Python
程式語言
一個看似簡單的內建函數,卻藏著理解 Python 變數行為的關鍵
作者

Anthony

發佈於

2026年4月14日

先來看一段會讓很多人愣住的程式碼:

a = 256
b = 256
print(a is b)  # 預期輸出: True

x = 257
y = 257
print(x is y)  # 預期輸出: 可能是 True 或 False
True
False

同樣是「兩個變數被賦值為相同的整數字面量」,為什麼 256 這組會是 True,換成 257 之後卻可能變成 False?更弔詭的是,把這段程式放在 .py 檔裡執行,第二個結果常常又會變回 True;但若在 REPL 一行一行敲進去,則幾乎都是 False1。這種看似違反直覺的行為,其實背後都有一個合理的解釋——而解開這個謎團的鑰匙,正是本文的主角:id() 函式。

id() 語法概說

根據 Python 官方文件id(object) 回傳一個整數,該整數在該物件的生命週期 (lifetime) 內是唯一且不變的。換句話說,它滿足兩個性質:

  1. 只要物件還活著,id() 不會變。
  2. 同一時間活著的兩個不同物件,不會有相同的 id()

CPython (也就是我們最常使用的 Python 實作版本) 中,這個整數通常就是物件在記憶體中的位址。

警告

id() 在 CPython 常常等於記憶體位址,這是 CPython 的實作細節,並非 Python 語言規範所保證。像 PyPyJython 等其他實作可能採用不同策略,所得到的結果也不一定相同。

在正式進入進階議題之前,我們先透過一個簡單的範例,觀察 id() 的基本用法:

s = "python"
print(id(s))            # 預期輸出: 一個整數
print(id(s) == id(s))   # 預期輸出: True
139646949392272
True

細心的讀者肯定有發現,id() 接受一個參數,回傳一個整數。這個整數本身沒有什麼意義,真正有意義的是「兩個 id() 是否相等」,因為這代表了兩個名稱是否指向同一個物件。

Python 甚至為這個概念提供了一個專屬的關鍵字 is,其本質上等價於比較兩個物件的 id()

a = [1, 2]
b = a
print(a is b)            # 預期輸出: True
print(id(a) == id(b))    # 預期輸出: True
True
True

生命週期內唯一的陷阱

前面提到 id() 具有「唯一且不變」的特性,但這句話有一個容易被忽略的前提:只有在該物件活著時才成立。當物件被回收後,舊的 id() 有可能被新物件重用2

print(id(object()))
print(id(object()))
print(id(object()))  # 有可能與上方重複
139646949225920
139646949226016
139646949225920

上述程式每次都建立一個臨時物件,印完就沒有任何名稱指向它,於是馬上可以被回收。CPython 可能很快重用同一塊記憶體,因此才會出現 id() 重複的現象。

若想安全地比較兩個物件是不是同一個,應該先把它們用變數留住:

a = object()
b = object()
print(id(a), id(b))  # 預期輸出: 兩個不同的整數
print(a is b)        # 預期輸出: False
139646949226000 139646949225920
False

小整數快取

回到一開始的 256257 範例,或許已經可以猜到答案的輪廓。在 CPython 中,直譯器啟動時會預先建立一段常用整數物件,通常是 -5256,放在所謂的小整數快取 (small int cache) 裡。當程式中寫出這個範圍內的整數,Python 會直接重用既有物件,不會再新建一個。

a = 256
b = 256
print(a is b)  # 預期輸出: True

c = -5
d = -5
print(c is d)  # 預期輸出: True
True
True

超出這個範圍,就不再保證:

x = 257
y = 257
print(x is y)  # 預期輸出: 可能是 True 或 False

相關的 _PyLong_SMALL_INTS 定義可以在 CPython 原始碼的 Objects/longobject.c 中找到。不過要注意的是,這個範圍屬於版本相關的實作細節,並非語言規範保證的行為3

常數折疊

既然 257 不在小整數快取內,為什麼有時候 x is y 的結果還是 True?答案就在於常數折疊 (constant folding) 與同一個常數池 (code object) 的重用機制。

先看同一個 .py 檔中的範例:

x = 257
y = 257
print(x is y)  # 預期輸出: 在 .py 檔中常見 True
False

再看在 REPL 分兩行輸入的情境:

>>> x = 257
>>> y = 257
>>> print(x is y)  # 預期輸出: 常見 False

原因其實是:.py 編譯時,編譯器可能把相同字面量放進同一個常數池,兩次載入都拿到同一個物件;而 REPL 每一次輸入通常是獨立的編譯單位,常數池不共享。可以用 dis 模組來親自驗證:

import dis

def f():
    a = 257
    b = 257
    return a is b

dis.dis(f)
  3           RESUME                   0

  4           LOAD_CONST               0 (257)
              STORE_FAST               0 (a)

  5           LOAD_CONST               0 (257)
              STORE_FAST               1 (b)

  6           LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
              IS_OP                    0 (is)
              RETURN_VALUE

兩次都是 LOAD_CONST 1,代表使用同一個常數槽位。這就是為什麼看似一樣的程式,在不同的執行脈絡下,is 的結果會有差異。

提示小叮嚀

此處所看到的是編譯器與執行環境的策略,而不是 Python 語言規範所要求的行為。把它當作效能調校與除錯的知識即可,千萬不要當成語意保證,更不要拿來作為商業邏輯的判斷依據。

字串駐留

除了整數,字串也有類似的重用機制,稱為字串駐留 (string interning)。常見情況下,符合識別字規則 (identifier-like) 的短字串會被 Python 自動 intern。簡單來說,如果兩個字串一模一樣,Python 有機會只在記憶體中留一份,讓多個變數一起指向它,如此便可省下空間,比較起來也更快。

a = "hello"
b = "hello"
print(a is b)  # 預期輸出: 常見 True
True

但帶有空格或特殊符號的字串通常不保證:

s1 = "hello world"
s2 = "hello world"
print(s1 is s2)  # 預期輸出: 可能是 True 或 False
False

若想明確要求共用,可以使用 sys.intern()

import sys

raw1 = "user_id"
raw2 = "user_" + "id"

i1 = sys.intern(raw1)
i2 = sys.intern(raw2)

print(i1 is i2)  # 預期輸出: True
True

實務上,若你有大量重複的小字串 (例如 CSV 欄位名稱、狀態碼),適度地 intern 可以減少記憶體占用與比較成本:

import sys

rows = ["OK", "OK", "FAIL", "OK", "FAIL"]
rows = [sys.intern(x) for x in rows]

print(rows[0] is rows[1])  # 預期輸出: True
print(rows[2] is rows[4])  # 預期輸出: True
True
True

進階變數議題

在掌握 id() 的基本語意之後,接下來要面對的是一系列看似違反直覺、卻又反覆出現在日常程式中的現象。為什麼同樣的字面量有時共用、有時不共用?為什麼 is== 偶爾會給出不同答案?為什麼物件被回收後,新物件竟可能「繼承」舊的身份編號?這些問題的背後,牽涉到 CPython 的快取策略、編譯器的常數折疊,以及記憶體管理的底層細節。

is==

在 Python 中,is== 經常被初學者混用,但兩者其實有著本質上的差異:

is== 的差異對照
運算子 比較對象 底層機制
is 物件身份 (identity) 本質上等價於比較 id()
== 物件的值 (value) 呼叫物件的 __eq__ 魔術方法
a = [1, 2]
b = [1, 2]

print(a == b)  # 預期輸出: True
print(a is b)  # 預期輸出: False
True
False

最重要的實務準則是:判斷 None 請一律使用 is

x = None
print(x is None)   # 預期輸出: True (推薦寫法)
print(x == None)   # 預期輸出: True (不建議)

那麼,為什麼 is None 會比 == None 好呢?

  1. None 是一個單例 (singleton),全程式中只會有一個 None 物件,用身份比較語意最精準。
  2. == 會呼叫對方的 __eq__,可能產生非預期的副作用,甚至回傳非布林值。

函式、類別、模組的 id()

很多初學者會誤以為 id() 只能用在整數、字串這類基本型別上。事實上,在 Python 裡一切皆為物件,函式、類別、甚至模組本身也都是貨真價實的物件,因此它們也都能被 id() 觀察:

import sys

def foo():
    pass

class Bar:
    pass

print(id(foo))
print(id(Bar))
print(id(sys))
139646907984256
959276176
139646949349056

特別值得一提的是「模組」這個物件。模組常被視為單例,這是因為 Python 的 import 機制會透過 sys.modules 來快取載入過的模組:

import math
import math as m2

print(math is m2)  # 預期輸出: True
True

這個機制也是 Python 生態系大量使用「模組層級狀態」的基礎之一。

常見陷阱:別把 id() 當成永久的追蹤鍵

理解 id() 與 intern,對於效能調校與除錯有直接的價值。例如在資料分析世界裡,pandascategory dtype,底層概念也是把重複值映射成共享字典與代碼,藉此減少重複儲存。

但一個最常見的誤用是:id() 當作長期追蹤物件的鍵值

obj = object()
tracking = {id(obj): "alive"}

del obj
# 之後如果新物件重用了舊的 id,tracking 可能誤判

這種寫法在長時間執行的程序中特別危險。更穩健的做法是使用 weakref

import weakref

class Node:
    pass

n = Node()
meta = weakref.WeakKeyDictionary()
meta[n] = "alive"

print(len(meta))  # 預期輸出: 1

del n
# 垃圾回收後,對應項目會自動消失

weakref 追蹤的是物件關聯,而不是可能被重用的數字身份,語意上正確得多。

練習

關於 id() 函式的特性,以下敘述何者正確?

  • 在物件的生命週期內,id() 回傳的整數唯一且不變
  • id() 回傳的整數在程式執行期間永不重複
  • id() 一定等於物件在記憶體中的位址
  • id() 只能用於整數與字串等基本型別

正確答案是第一項。根據 Python 官方文件,id() 的保證是「在該物件的生命週期內唯一且不變」。當物件被回收後,舊的 id() 可能被新物件重用,因此第二項錯誤。第三項只是 CPython 的實作細節,並非語言規範;第四項則完全錯誤,函式、類別、模組等任何物件都可以使用 id()

在 CPython 中執行以下程式碼,最有可能的輸出是什麼?

a = 256
b = 256
x = 1000
y = 1000
print(a is b, x is y)
  • True True
  • True 與 (TrueFalse) 皆可能
  • False False
  • False True

256 落在 CPython 的小整數快取範圍 (通常是 -5256) 內,因此 a is b 必定為 True。而 1000 不在此範圍,但若放在同一個 .py 檔中,常數折疊可能讓 x is y 也變成 True;若在 REPL 分行輸入則常為 False。因此第二項「True 與 (TrueFalse) 皆可能」最為精確。

判斷一個變數是否為 None 時,以下哪一種寫法最為推薦?

  • x == None
  • x.equals(None)
  • x is None
  • id(x) == 0

推薦使用 x is None。原因有二:其一,None 是全程式中唯一的單例物件,用身份比較 (is) 的語意最為精準;其二,== 會呼叫對方的 __eq__ 魔術方法,可能產生副作用或回傳非預期的結果。

請說明下列兩段程式在 .py 檔中執行時,為什麼 print(a is b) 的結果可能不同?

# 情境一
a = "hello"
b = "hello"
print(a is b)

# 情境二
a = "hello world"
b = "hello world"
print(a is b)

情境一的 "hello" 符合識別字規則 (identifier-like),CPython 會自動進行字串駐留 (string interning),讓 ab 指向同一個物件,因此 a is b 常為 True。情境二的 "hello world" 因為包含空格,通常不會被自動 intern,ab 可能指向不同物件,結果可能是 True 也可能是 False。若想強制共用,可使用 sys.intern() 明確要求。

本章小結

在這一章中,我們從 id() 這個看似簡單的內建函式出發,揭開了 Python 變數行為的神秘面紗。以下是幾個關鍵筆記:

核心觀念:變數是標籤,不是盒子

  • Python 的變數是指向物件的名稱綁定,並不是裝值的容器。
  • 所有的行為 (賦值、比較、傳參) 都圍繞這個核心事實展開。

id() 的使用準則

  • 生命週期內唯一:物件被回收後,id() 可能被新物件重用。
  • 不要當作追蹤鍵:長期追蹤物件請使用 weakref,而非 id()
  • 一切皆物件:函式、類別、模組都可以用 id() 觀察。

身份比較的最佳實踐

  • is vs ==is 比身份、== 比值;判斷 None 一律用 is
  • 小整數快取:CPython 預先建立 -5256 的整數物件。
  • 常數折疊.py 檔中同一字面量可能共用常數池,REPL 則不會。
  • 字串駐留:符合識別字規則的短字串可能被自動 intern,必要時用 sys.intern() 明確要求。
注意警語:區分語言規範與實作細節

本文所討論的許多現象 (如小整數快取、常數折疊、字串駐留等) 都是 CPython 的實作細節,並非 Python 語言規範所保證的行為。理解這些細節可以寫出更省記憶體、更易除錯的程式碼,但千萬不要把它們當成商業邏輯的硬依賴

回到頂端

腳註

  1. 若尚未親自試過,強烈建議現在就打開終端機,分別在 REPL 與 .py 檔執行一次。同樣的程式碼在兩種環境下得到不同結果,正是本文後續章節要解答的核心謎題。↩︎

  2. 這也是為什麼在多執行緒或長時間執行的程式中,直接儲存 id() 來追蹤物件會是一個危險的做法,後文會再詳細說明。↩︎

  3. 常見說法是 -5..256,但 CPython 的最新開發分支已經可以看到不同的常數設定。若對實作細節有興趣,可直接參考 CPython 原始碼↩︎