你是數字,我是數字,我們不一樣!
在 Python 裡的 id()
函數可以算出某個值或物件在 Python 世界裡的「編號」,如果有兩個值算出來的編號是一樣的,表示這兩個值不只相等,而且還是一顆物件。
那麼問題來了,我寫了一個程式叫做 hello.py
,內容如下:
a = 100
b = 200
def hey():
return 300
res1 = 300
res2 = 100 + 200
res3 = a + b
res4 = hey()
print(id(res1))
print(id(res2))
print(id(res3))
print(id(res4))
當我執行 python hello.py
的時候,最後四行印出來的 id,有一個跟其它三個是不一樣的,猜猜看是哪一個。
答案是 res3
,它算出來的數字跟其它三個不同。
先聲明,我使用的 Python 版本是 3.12
,也許在不同的版本會有不同的結果。
為什麼這些看起來答案都是一樣的數值,卻有其中一個不一樣?在 Python 為了讓效能變的更好有做了不少調整,像是針對數字的效能最佳化,在 -5 ~ 256 之間的「小整數」,因為這些數字很常被使用,所以這些數值都直接編譯到 Python 直譯器本體,每次需要用到的時候就不用重新產生一個數字物件,就是因為這樣才會有以下這個看起來有點奇妙的現象:
>>> a = 256
>>> b = 256
>>> a is b
True
>>> a = 257
>>> b = 257
>>> a is b
False
這應該也不是什麼秘密了,如果想知道更多細節,可參考整數的前世今生文章介紹。
不過這題我有刻意避開了小整數的範圍,因為重點不在這裡。如果想要知道 Python 是怎麼執行我們寫的程式,就得看看 hello.py
編譯成 Bytecode 變成什麼樣子。因為內容有點長而且混在一起,所以我一段一段整理給大家看看。首先是 res1 = 300
這行:
7 16 LOAD_CONST 3 (300)
18 STORE_NAME 3 (res1)
這相當簡單,就是載入一個常數 300,然後指定給 res1
變數。再看看 res2 = 100 + 200
:
8 20 LOAD_CONST 3 (300)
22 STORE_NAME 4 (res2)
咦?有發現奇怪的地方嗎?原本我們的程式碼是寫 100 + 200
,但 Python 直接幫我們算好,然後把結果指定給 res2
,看起來跟 res1
是一樣的。
Python 為了讓效能更好一點,在編譯成 Bytecode 之前會先進行「常數折疊」(Constant Folding) 的操作,試著計算那些可以提前得知結果的運算。例如這裡的 100 + 200
被先計算成 300
,然後直接作為常數編譯到 Bytecode 中,這樣可以減少在執行階段的不必要計算量。
這裡還有個小細節,在 LOAD_CONST 3
後面的 3
,這跟剛才 res1 = 300
的 LOAD_CONST 3
是一樣的,表示這是從同一格堆疊上取得並載入的值,所以這會是同一個常數 300。
我們暫時跳過第三個,先看最後一個 res4 = hey()
:
4 10 LOAD_CONST 2 (<code object hey>)
12 MAKE_FUNCTION 0
14 STORE_NAME 2 (hey)
10 34 PUSH_NULL
36 LOAD_NAME 2 (hey)
38 CALL 0
46 STORE_NAME 6 (res4)
Disassembly of <code object hey>:
4 0 RESUME 0
5 2 RETURN_CONST 1 (300)
上半段是定義 hey()
函數,中段的 CALL
也很好猜,就是在呼叫 hey()
這個函數。最後一段的 RETURN_CONST
回傳一個常數 300。但是,這裡的 300 跟前面的 300 是同一個數字物件嗎?這裡就是細節了...
Python 在建立函數的時候,更精準的說是在建立函數裡面的 Code Object 的時候,裡面有個叫做 const_cache
的東西,這東西從名字看起來大概可以猜到是常數的暫存。簡單的說,就是在函數裡需要用到常數的時候,會先檢查是不是已經有人用過同樣的常數,如果有,就會直接取用同一個值。對這部份實作有興趣的話,可搜尋原始碼 makecode()
函數的實作。
好,到這裡,res1
、res2
跟 res4
都是同一個常數 300,所以使用 id()
函數計算出來的結果都是一樣的。
那 res3 = a + b
呢?來看看它的操作:
1 2 LOAD_CONST 0 (100)
4 STORE_NAME 0 (a)
2 6 LOAD_CONST 1 (200)
8 STORE_NAME 1 (b)
9 24 LOAD_NAME 0 (a)
26 LOAD_NAME 1 (b)
28 BINARY_OP 0 (+)
32 STORE_NAME 5 (res3)
這個行為明顯跟 res1
或 res2
不一樣了,這個 BINARY_OP
會拿前面堆疊上的 a
跟 b
以及加號 +
進行運算。如果再深入追原始碼的話(有興趣可搜尋原始碼的 _PyLong_Add()
函數),會發現 a + b
的過程會產生並回傳一顆新的數字物件,雖然它也是數字 300,但這個 300 跟前面用 LOAD_CONST
載入的 300 就不是同一個物件了。
當然,如果把這裡的數字改成 -5 ~ 256 之間的小整數的話,就會發現這四行的答案都是一樣的了。
另外,不知道大家有沒發現我刻意而且還有強調把這些程式碼寫在一個 .py
檔裡然後執行它,雖然 300 這個數字並不在小整數的範圍內,所以沒辦法享受當伸手牌拿現成數字來用的待遇,但因為執行整個檔案的時候,這個 300 就是一個被定義過的常數,在同一次的編譯裡可以被重複使用。
不過,如果你進到 Python 的 REPL 環境就不一樣了:
>>> a = 100
>>> b = 200
>>> def hey():
... return 300
...
>>> res1 = 300
>>> res2 = 100 + 200
>>> res3 = a + b
>>> res4 = hey()
>>> id(res1)
4298043664
>>> id(res2)
4296275504
>>> id(res3)
4298043920
>>> id(res4)
4296275632
在 REPL 不會有「常數折疊」的機制,而且每次的執行,都是一個全新的開始,每次的執行都會進行一次編譯,所以除非是 -5 ~ 256 之間的小整數,否則這四個 300 可能都會是不同的 300。