你是數字,我是數字,我們不一樣!

你是數字,我是數字,我們不一樣!

在 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 = 300LOAD_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() 函數的實作。

好,到這裡,res1res2res4 都是同一個常數 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)

這個行為明顯跟 res1res2 不一樣了,這個 BINARY_OP 會拿前面堆疊上的 ab 以及加號 + 進行運算。如果再深入追原始碼的話(有興趣可搜尋原始碼的 _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。