可變、不可變的真相--凡事皆物件的 Python

codemee - Feb 13 '22 - - Dev Community

剛入門學習 Python 的人常常會被到底什麼是可變 (mutable)不可變 (immutable) 的資料搞混, 也會發生改了 a 卻讓 b 也變動內容的意外嚇到。本文就嘗試幫初學者解惑, 甚至可能許多有經驗的 Python 程式師也未必思考過原來背後的運作機制是這樣。接著就讓我們從 Python 的原點--物件--出發。

什麼都是物件的 Python

在 Python 中所有的東西都是物件, 直覺能夠理解的如數值字串這樣的資料, 不直覺的像是函式模組等等, 全部都是物件。我們可以使用內建的 type() 函式來得知物件所屬的型別 (type)

>>> type(1)
<class 'int'>
>>> type(True)
<class 'bool'>
>>> type('hi')
<class 'str'>
>>> type([1, 2, 3])
<class 'list'>
>>> def f():
...     pass
...
>>> type(f)
<class 'function'>
>>>
Enter fullscreen mode Exit fullscreen mode

因為是物件, 所以不同型別的物件會有各自可用的方法, 例如:

>>> (-3).__abs__()
3
Enter fullscreen mode Exit fullscreen mode

就是執行 int 物件計算絕對值的方法, 注意到這個方法傳回的是新的物件, 而不是修改原物件內儲存的值。其實當你叫用內建的 abs() 函式時, 就是轉為叫用所傳入物件的 __abs__() 方法, 利用這種方式, 就可以為自訂的型別客製化計算絕對值的方法, 這在 Python 中是很常運用的設計模式。

幫物件掛名牌--命名並綁定 (naming and binding)

當你執行指派敘述時, 實際上的動作是為等號右邊運算結果得到的物件取名字, 像是幫物件掛上名牌一樣, 稱為命名並綁定 (naming and binding)。Python 會自行記錄個別名字對應的物件, 例如:

>>> a = 20
>>> b = [1, 2, 3]
>>> c = b
>>>
Enter fullscreen mode Exit fullscreen mode

其中 c = b不是複製物件, 而是透過 b 取得綁定的物件後, 再將名字 c 綁定到該物件上, 所以 bc 這兩個名字現在都是指同一個物件。實際上在系統中會是這樣:

 ___ ___ ___
| a | b | c |
  |   |   |
  v   |___|
 20   |
      v
     ___ ___ ___ 
    | 1 | 2 | 3 |    
Enter fullscreen mode Exit fullscreen mode

由於 bc 都是綁定到同一個物件, 因此不論是透過 b 還是 c 更改串列的內容, 修改的都是同一個物件:

>>> b[2] = 4
>>> c
[1, 2, 4]
>>>
Enter fullscreen mode Exit fullscreen mode

實際系統中的對應會是這樣:

 ___ ___ ___ 
| a | b | c |
  |   |   |
  v   |___|
 20   |
      v
     ___ ___ ___ 
    | 1 | 2 | 4 |
Enter fullscreen mode Exit fullscreen mode

每個物件都有自己專屬的識別碼 (identifier), 可以透過內建的 id() 函式取得, 例如:

>>> id(a)
2656169388944
>>> id(b)
2656174662208
>>> id(c)
2656174662208
>>>
Enter fullscreen mode Exit fullscreen mode

從結果可以看到, bc 綁定的物件識別碼相同, 因此是同一個物件。在 Python 中, == 比較的是物件的內容, is 比較的則是物件的識別碼, 例如:

>>> d = [1, 2, 4]
>>> id(d)
2656174969216
>>> b == d
True
>>> b is d
False
>>>
Enter fullscreen mode Exit fullscreen mode

你可以看到 d 串列的內容和 b 一樣, 但識別碼不同, 是另一個物件, 因此雖然 == 的運算結果是 True, 但 is 的運算結果是 False

如果是想要複製串列, 而不是要為串列再綁定一個名字, 可以使用串列的 copy() 方法, 例如:

>>> e = b.copy()
>>> e
[1, 2, 4]
>>> id(e)
2656174579968
>>> id(b)
2656174662208
>>>
Enter fullscreen mode Exit fullscreen mode

copy() 會建立一個新串列, 內容和原串列一樣, 你可以從識別碼看出來複製得到的物件和原始的物件不是同一個。

容器物件內存放的其實是名牌

大部分教材都會說串列、元組 (tuple) 等等容器物件可以放置多項資料, 不過其實容器內存放的是綁定到個別物件的名牌, 而不是實際的物件。以串列為例, 你可以把它想成是放置名牌的活動組合櫃, 例如前面的 b 實際上應該這樣畫才對:

 ___ 
| b |
  |  
  v  
+___+___+___+
| . | . | . |
  |   |   |
  v   v   v
  1   2   4  
Enter fullscreen mode Exit fullscreen mode

我們以 '+' 號間隔容器中的個別項目, 代表可隨意增減項目, 就像是活動組合櫃一樣。容器中的名牌沒有名字, 在圖中我們以 '.' 表示, 要取得串列中對應的物件, 必須依照從 0 開始的序號從對應的櫃位查看名牌, 找到它所綁定的物件。

我們也可以將其中的名牌改綁定到其他的物件, 甚至是增加項目, 像是這樣:

>>> b[1] = [5, 6]
>>> b.append(7)
>>> b
[1, [5, 6], 4, 7]
>>>
Enter fullscreen mode Exit fullscreen mode

實際的結果會是這樣:

 ___ 
| b |
  |  
  v  
+___+___+___+___+
| . | . | . | . |
  |   |   |   |  
  v   |   v   v  
  1   |   4   7  
      v
    +___+___+
    | . | . |
      |   |  
      v   v  
      5   6  
Enter fullscreen mode Exit fullscreen mode

這樣串列就變成多層結構了。也正是這種可以重新綁定到不同物件、隨意增減項目的特性, 所以串列是可變的 (mutable) 物件。

反觀元組 (tuple), 則是建立後放好名牌就已經封死、黏死的櫃子, 什麼都不能動, 例如:

>>> t = (1, [2, 3], 4)
Enter fullscreen mode Exit fullscreen mode

實際上可畫成這樣:

 ___ 
| t |
  |  
  v  
 ___ ___ ___ 
| . | . | . |
 --- --- ---
  |   |   |    
  v   |   v    
  1   |   4    
      v
    +___+___+
    | . | . |
      |   |  
      v   v  
      2   3  
Enter fullscreen mode Exit fullscreen mode

我們用封口的櫃子表示無法更動裡面的名牌, 並把 '+' 改成空格, 表示無法變更櫃位組合, 雖然還是可以變更綁定到的串列, 例如:

>>> t[1][0] = 20
>>> t
(1, [20, 3], 4)
>>>
Enter fullscreen mode Exit fullscreen mode

但這修改的並不是元組本身, 實際狀況如下:

 ___ 
| t |
  |  
  v  
 ___ ___ ___ 
| . | . | . |
 --- --- ---
  |   |   |    
  v   |   v    
  1   |   4    
      v
    +___+___+
    | . | . |
      |   |  
      v   v  
      20  3  
Enter fullscreen mode Exit fullscreen mode

元組內個別項目綁定的物件並沒有變, 變的是所綁定物件的內容。也正因為如此, 元組是不可變 (immutable) 的物件。

容器切片是複製名牌而非綁定的物件

在做切片操作時, 其實是複製名牌到新建立的容器, 例如:

>>> b
[1, [5, 6], 4, 7]
>>> s = b[1:2]
>>> s
[[5, 6]]
Enter fullscreen mode Exit fullscreen mode

畫成圖如下:

 ___ ___ 
| b | s |
  |   |_____________
  v                |
+___+___+___+___+  |
| . | . | . | . |  |
  |   |   |   |    |
  v   |   v   v    |
  1   |   4   7    | 
      |            v
      |          +___+
      |          | . |
      |____________|  
      |
      v  
    +___+___+
    | . | . |
      |   |  
      v   v  
      5   6  
Enter fullscreen mode Exit fullscreen mode

由於 s[0] 是複製 b[1] 的名牌, 所以兩者綁定到同一個物件, 因此透過其中之一修改物件內容都是一樣的效果, 例如:

>>> s[0][1] = 60
>>> b
[1, [5, 60], 4, 7]
>>>
Enter fullscreen mode Exit fullscreen mode

淺層 (shallow) 與深層 (deep) 複製

當容器內的項目綁定到的物件也是容器時, 就需要特別注意, 像是前面提過的 copy() 只會進行淺層 (shallow) 複製, 亦即不會再循綁定的容器複製物件, 只會複製名牌, 例如:

>>> b
[1, [5, 60], 4, 7]
>>> sc = b.copy()
>>> sc
[1, [5, 60], 4, 7]
Enter fullscreen mode Exit fullscreen mode

實際如下圖:

 ___ ____ 
| b | sc |
  |   |__________________
  |                     |
  v                     v
+___+___+___+___+     +___+___+___+___+
| . | . | . | . |     | . | . | . | . |
  |   |   |   |         |   |   |   |
  v   |   v   v         v   |   v   v 
  1   |   4   7         1   |   4   7
      |                     |
      |_____________________|  
      |
      v  
    +___+___+
    | . | . |
      |   |  
      v   v  
      5   60  
Enter fullscreen mode Exit fullscreen mode

若進行以下操作:

>>> sc[0] = 10
>>> sc[1][0] = 50
>>> sc
[10, [50, 60], 4, 7]
>>> b
[1, [50, 60], 4, 7]
>>>
Enter fullscreen mode Exit fullscreen mode

就可以看到雖然修改 sc[0] 不會影響 b, 但是 sc[1] 是複製 b[1] 的名牌, 所以是指向同一個物件, 因此變更的都是同一個物件, 如下圖:

 ___ ____ 
| b | sc |
  |   |__________________
  |                     |
  v                     v
+___+___+___+___+     +___+___+___+___+
| . | . | . | . |     | . | . | . | . |
  |   |   |   |         |   |   |   |
  v   |   v   v         v   |   v   v 
  1   |   4   7        10   |   4   7
      |                     |
      |_____________________|  
      |
      v  
    +___+___+
    | . | . |
      |   |  
      v   v  
     50   60  
Enter fullscreen mode Exit fullscreen mode

為了解決這個問題, Python 提供有 copy 模組, 內含 deepcopy() 函式可進行深層複製, 也就是會依循名牌一層層複製綁定的物件內容。例如:

>>> import copy
>>> dc = copy.deepcopy(b)
>>> b
[1, [50, 60], 4, 7]
>>> dc
[1, [50, 60], 4, 7]
Enter fullscreen mode Exit fullscreen mode

實際對應如下圖:

 ___ ____ 
| b | dc |
  |   |__________________
  |                     |
  v                     v
+___+___+___+___+     +___+___+___+___+
| . | . | . | . |     | . | . | . | . |
  |   |   |   |         |   |   |   |
  v   |   v   v         v   |   v   v 
  1   |   4   7        10   |   4   7
      |                     |
      v                     v  
    +___+___+             +___+___+
    | . | . |             | . | . |
      |   |                 |   |  
      v   v                 v   v  
     50   60               50   60  
Enter fullscreen mode Exit fullscreen mode

如此一來, dc 就和原本的 b 不相干了。即使變更內容, 也不會有任何影響:

>>> dc[1][0] = 500
>>> dc
[1, [500, 60], 4, 7]
>>> b
[1, [50, 60], 4, 7]
>>>
Enter fullscreen mode Exit fullscreen mode

結果如下圖:

 ___ ____ 
| b | dc |
  |   |__________________
  |                     |
  v                     v
+___+___+___+___+     +___+___+___+___+
| . | . | . | . |     | . | . | . | . |
  |   |   |   |         |   |   |   |
  v   |   v   v         v   |   v   v 
  1   |   4   7        10   |   4   7
      |                     |
      v                     v  
    +___+___+             +___+___+
    | . | . |             | . | . |
      |   |                 |   |  
      v   v                 v   v  
     50   60               500  60  
Enter fullscreen mode Exit fullscreen mode

字典的索引鍵

學過字典物件都知道, 字典物件只能用不可變的物件當索引鍵, 例如:

>>> d = {10:"ten", "one":1}
>>> d
{10: 'ten', 'one': 1}
>>>
Enter fullscreen mode Exit fullscreen mode

你可以把字典當成是和串列類似的雙面組合櫃, 每個櫃位都可以放置一對名牌, 其中之一綁定到作為索引鍵的物件、另一個綁定到項目的資料, 取得項目時不是靠序號, 而是靠與個別項目配對的索引鍵, 實際對應的樣子如下圖:

 ___                  ___ ___ ___     
| d |  10            |'o'|'n'|'e'| 
  |     ^             --- --- ---  
  |     |              ^
  |     |   ___________|
  |     |   | 
  |    ___ ___
  |   | . | . |
  |-> +---+---+
      | . | . |
        |   |__________
        |              |
        |              |
        v              V
       ___ ___ ___ 
      |'t'|'e'|'n'|    1
       --- --- ---    
Enter fullscreen mode Exit fullscreen mode

索引鍵以封閉的櫃子表示其無法更動名牌, 而項目資料的櫃位則和串列一樣, 可以隨意更動名牌, 綁定到不同的物件。

你可能會想說既然元組是不可變的物件, 那是不是只要使用元組當索引鍵就一定沒問題呢?請看以下範例:

>>> d = {(1, [2, 3]):10}
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>>
Enter fullscreen mode Exit fullscreen mode

上例中的索引鍵雖然是元組, 但因為內含可變的串列而引發錯誤。這是因為字典是依靠比對索引鍵的內容來找尋項目, 如果索引鍵的內容包含可變的物件, 就可能會有兩個原本內容不同的索引鍵後來變成內容相同的情況, 這樣就無法判定到底是要取得哪一個索引鍵的對應項目。其實正確的說法是字典的索引鍵不能使用到任何含有可變物件的物件, 也就是每一層項目綁定的都要是不可變的物件。

在字典中查找項目實際上並不會真的一一比對索引鍵內容, 而是在加入新項目到字典時就依據所謂的雜湊 (hash) 演算法計算出一個可以代表對應該項目索引鍵內容的數值, 稱為雜湊值。雜湊演算法保證使用 == 會得到 True 的兩個物件, 會算出相同的雜湊值。因此, 雜湊值相同不代表物件內容一定相同, 但雜湊值不相同的物件, 內容一定不會相同

之後指定索引鍵找尋項目時, 就只要先計算出指定索引鍵的雜湊值, 再跟字典內各個索引鍵預先計算好的雜湊值比較, 只有當雜湊值相等時, 才會進一步比較索引鍵內容是否相同, 排除與指定索引鍵雜湊值不同的項目, 減少比對工作, 加快搜尋速度。

要達到上述要求, 就必須依賴索引鍵內容不能改變, 否則預先計算出的雜湊值就會和變更內容後的雜湊值不一樣了。前面錯誤訊息中的 "unhashable type" 就是指傳入的物件含有會變化的內容, 不能拿來計算雜湊值。

如果想知道特定物件的雜湊值, 可以使用內建的 hash() 函式, 例如:

>>> a = (1, 2, 3)
>>> b = (1, 2, 3)
>>> a is b
False
>>> id(a)
2656174899328
>>> id(b)
2656175603840
>>> a == b
True
>>> hash(a)
529344067295497451
>>> hash(b)
529344067295497451
>>>
Enter fullscreen mode Exit fullscreen mode

你可以看到 ab 雖然是不同的物件, 但因為綁定的物件內容相同, 計算出來的雜湊值是一樣的。

del 刪除的是名牌不是物件

當使用 del 時, 看起來好像是刪除物件, 但其實刪除的是名牌, 例如:

>>> a = [1, 2]
>>> b = [0, a, 3]
>>> b
[0, [1, 2], 3]
>>>
Enter fullscreen mode Exit fullscreen mode

這時的綁定狀況如下:

 ___ ___ 
| a | b |
  |   |  
  |   v
  | +___+___+___+
  | | . | . | . |
  |   |   |   |
  |   v   |   v
  |   0   |   3
  |_______|
  | 
  v
+___+___+
| 1 | 2 |
Enter fullscreen mode Exit fullscreen mode

如果使用 del 刪除 a, 就是將 a 這個名字刪除, 而 a 與原綁定物件的關係就不存在了, 所以再次存取 a 就會出錯:

>>> del a
>>> a
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'a' is not defined
Enter fullscreen mode Exit fullscreen mode

從錯誤訊息可以看到, 是 a 名稱沒有定義, 現況如下圖:

 ___ ___ 
|   | b |
      |  
      v
    +___+___+___+
    | 0 | . | 3 |
    | . | . | . |
      |   |   |
      v   |   v
      0   |   3
   _______|
  | 
  v
+___+___+
| 1 | 2 |
Enter fullscreen mode Exit fullscreen mode

a 雖然被刪掉了, 但是原本 a 綁定的物件還在, 從 b 內容就可以看出來:

>>> b
[0, [1, 2], 3]
>>>
Enter fullscreen mode Exit fullscreen mode

在大部分的 Python 實作中, 採用的是參照計數 (reference counting) 機制來判斷當一個物件沒有任何綁定關係時, 就會納入可回收的物件清單, 並在適當時機才真的刪除物件。

小結

雖然大多數情況下, 你並不一定需要用這麼細部的觀點來看物件, 但是了解實際的運作架構有助於釐清許多表面看起來無法理解的意外。 希望本文能起個頭, 讓大家能夠更注意 Python 的核心概念。

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .