This content originally appeared on DEV Community and was authored by codemee
剛入門學習 Python 的人常常會被到底什麼是可變 (mutable)、不可變 (immutable) 的資料搞混, 也會發生改了 a
卻讓 b
也變動內容的意外嚇到。本文就嘗試幫初學者解惑, 甚至可能許多有經驗的 Python 程式師也未必思考過原來背後的運作機制是這樣。接著就讓我們從 Python 的原點--物件--出發。
什麼都是物件的 Python
在 Python 中所有的東西都是物件, 直覺能夠理解的如數值、字串這樣的資料, 不直覺的像是函式、模組等等, 全部都是物件。我們可以使用內建的 type()
函式來得知物件所屬的型別 (type), 或者也可以稱為類別 (class), 例如:
>>> 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'>
>>>
因為是物件, 所以不同型別的物件會有可用的方法, 例如:
>>> (-3).__abs__()
3
就是執行 int
物件計算絕對值的方法, 注意到這些方法傳回的是新的物件, 而不是修改原物件儲存的值。其實當你叫用內建的 abs()
函式時, 就是轉為叫用所傳入物件的 __abs__()
方法, 利用這種方式, 就可以為自訂的型別客製化計算絕對值的方法, 這在 Python 中是很常運用的設計模式。
幫物件掛名牌--綁定 (binding)
當你執行指派敘述時, 實際上的動作是為等號右邊運算結果得到的物件取名字, 像是幫物件掛上名牌一樣, 稱為綁定 (binding)。Python 會自行記錄個別名字對應的物件, 例如:
>>> a = 20
>>> b = [1, 2, 3]
>>> c = b
>>>
其中 c = b
並不是複製物件, 而是透過 b
取得綁定的物件後, 再將名字 c
綁定到該物件上, 所以 b
和 c
這兩個名字現在都是指同一個物件。實際上在系統中會是這樣:
.___.___.___.
| a | b | c |
| | |
↓ | |
20 ↓ ↙
.___.___.___.
| 1 | 2 | 3 |
由於 b
和 c
都是綁定到同一個物件, 因此不論是透過 b
還是 c
更改串列的內容, 修改的都是同一個物件:
>>> b[2] = 4
>>> c
[1, 2, 4]
>>>
實際系統中的對應會是這樣:
.___.___.___.
| a | b | c |
| | |
↓ | |
20 ↓ ↙
.___.___.___.
| 1 | 2 | 4 |
每個物件都有自己專屬的識別碼 (identifier), 可以透過內建的 id()
函式取得, 例如:
>>> id(a)
2656169388944
>>> id(b)
2656174662208
>>> id(c)
2656174662208
>>>
從結果可以看到, b
和 c
綁定的物件識別碼相同, 因此是同一個物件。在 Python 中, ==
比較的是物件的內容, is
比較的則是物件的識別碼, 例如:
>>> d = [1, 2, 4]
>>> id(d)
2656174969216
>>> b == d
True
>>> b is d
False
>>>
你可以看到 d
的串列內容和 b
一樣, 但識別碼不同, 是另一個物件, 因此 ==
的運算結果是 True
, 但 is
的運算結果是 False
。
如果是想要複製串列, 而不是要為串列再綁定一個名字, 可以使用串列的 copy()
方法, 例如:
>>> e = b.copy()
>>> e
[1, 2, 4]
>>> id(e)
2656174579968
>>> id(b)
2656174662208
>>>
copy()
會建立一個新串列, 內容和原串列一樣, 你可以從識別碼看出來複製得到的物件和原始的物件不是同一個。
容器物件內存放的其實是名牌
大部分教材都會說串列、元組 (tuple) 等等容器物件可以放置多項資料, 不過其實容器內存放的是綁定到個別物件的名牌, 而不是實際的物件。以串列為例, 你可以把它想成是放置名牌的活動組合櫃, 例如前面的 b
實際上應該這樣畫才對:
.___.
| b |
|
↓
+___+___+___+
| . | . | . |
| | |
↓ ↓ ↓
1 2 4
我們以 '+' 號間隔容器中的個別項目, 代表可隨意增減項目, 就像是活動組合櫃一樣。容器中的名牌沒有名字, 在圖中我們以 '.' 表示, 要取得串列中對應的物件, 必須依照從 0 開始的序號從對應的櫃位查看名牌, 找到它所綁定的物件。
我們也可以將其中的名牌改綁定到其他的物件, 甚至是增加項目, 像是這樣:
>>> b[1] = [5, 6]
>>> b.append(7)
>>> b
[1, [5, 6], 4, 7]
>>>
實際的結果會是這樣:
.___.
| b |
|
↓
+___+___+___+___+
| . | . | . | . |
| | | |
↓ | ↓ ↓
1 | 4 7
↓
+___+___+
| . | . |
| |
↓ ↓
5 6
這樣串列就變成多層結構了。也正是這種可以重新綁定到不同物件、隨意增減項目的特性, 所以串列是可變的 (mutable) 物件。
反觀元組 (tuple), 則是建立後放好名牌就已經封死、黏死的櫃子, 什麼都不能動, 例如:
>>> t = (1, [2, 3], 4)
實際上可畫成這樣:
.___.
| t |
|
↓
.___.___.___.
| . | . | . |
--- --- ---
| | |
↓ | ↓
1 | 4
↓
+___+___+
| . | . |
| |
↓ ↓
2 3
我們用封口的櫃子表示無法更動裡面的名牌, 並把 '+' 改成 '.' 表示無法變更組合, 雖然還是可以變更綁定到的串列, 例如:
>>> t[1][0] = 20
>>> t
(1, [20, 3], 4)
>>>
但這修改的並不是元組本身, 實際狀況如下:
.___.
| t |
|
↓
.___.___.___.
| . | . | . |
--- --- ---
| | |
↓ | ↓
1 | 4
↓
+___+___+
| . | . |
| |
↓ ↓
20 3
元組內個別項目綁定的物件並沒有變, 變的是這個綁定的物件內容。也正因為如此, 元組是不可變 (immutable) 的物件。
容器切片是複製名牌而非綁定的物件
在做切片操作時, 其實是複製名牌到新建立的容器, 例如:
>>> b
[1, [5, 6], 4, 7]
>>> s = b[1:2]
>>> s
[[5, 6]]
畫成圖如下:
.___.___.
| b | s |
| |_____________
↓ |
+___+___+___+___+ |
| . | . | . | . | |
| | | | |
↓ | ↓ ↓ |
1 | 4 7 |
| ↓
| +___+
| | . |
| _________|
| |
↓ ↙
+___+___+
| . | . |
| |
↓ ↓
5 6
由於 s[1]
是複製 b[0]
, 所以兩者綁定到同一個物件, 因此透過其中之一修改物件內容都是一樣的效果, 例如:
>>> s[0][1] = 60
>>> b
[1, [5, 60], 4, 7]
>>>
淺層 (shallow) 與深層 (deep) 複製
當容器內的項目綁定到的物件也是容器時, 就需要特別注意, 像是前面提過的 copy()
只會進行淺層 (shallow) 複製, 亦即不會再循綁定的容器複製物件, 只會複製這個容器的名牌, 例如:
>>> b
[1, [5, 60], 4, 7]
>>> sc = b.copy()
>>> sc
[1, [5, 60], 4, 7]
>>> sc[0] = 10
>>> sc[1][0] = 50
>>> sc
[10, [50, 60], 4, 7]
>>> b
[1, [50, 60], 4, 7]
>>>
你可以看到雖然修改 sc[0]
不會影響 b
, 但是 sc[1]
是複製 b[1]
的名牌, 所以是指向同一個物件, 因此變更的都是同一個物件。
為了解決這個問題, Python 提供有 copy
模組, 內含 deepcopy()
函式可進行深層複製, 也就是會依循名牌一層層複製綁定的物件。例如:
>>> import copy
>>> dc = copy.deepcopy(b)
>>> b
[1, [50, 60], 4, 7]
>>> dc
[1, [50, 60], 4, 7]
>>> dc[1][0] = 500
>>> dc
[1, [500, 60], 4, 7]
>>> b
[1, [50, 60], 4, 7]
>>>
如此一來, dc
就和原本的 b
不相干了。
字典的索引鍵
學過字典物件都知道, 字典物件只能用不可變的物件當索引鍵, 你可能會想說既然元組是不可變的物件, 那是不是只要使用元組就一定沒問題呢?請看以下範例:
>>> d = {(1, [2, 3]):10}
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>>
這裡因為元組內含綁定到串列的項目, 所以會出錯, 其實正確的說法是字典的索引鍵不能使用到任何含有可變物件的物件, 也就是每一層項目綁定的都要是不可變的物件。
咦, 那剛剛錯誤訊息中的 "unhashable type" 是什麼意思呢?由於字典是依靠索引鍵來找尋項目, 如果索引鍵是多層的複雜容器, 要一層層比對比較耗時, 因此會在建立索引鍵時就依據所謂的雜湊 (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
>>>
你可以看到 a
和 b
雖然是不同的物件, 但因為綁定的物件內容相同, 計算出來的雜湊值是一樣的。
要特別注意的是, 雜湊值相同不代表物件內容一定相同, 但雜湊值不相同的物件內容一定不會相同, 因此可以使用雜湊值快速排除比對不成功的項目。
小結
雖然大多數情況下, 你並不一定需要用這麼細部的觀點來看物件, 但是了解實際的運作架構有助於釐清許多表面看起來無法理解的意外。 希望本文能起個頭, 讓大家能夠更注意 Python 的核心概念。
This content originally appeared on DEV Community and was authored by codemee
codemee | Sciencx (2022-02-13T03:10:57+00:00) 可變、不可變的真相–凡事皆物件的 Python. Retrieved from https://www.scien.cx/2022/02/13/%e5%8f%af%e8%ae%8a%e3%80%81%e4%b8%8d%e5%8f%af%e8%ae%8a%e7%9a%84%e7%9c%9f%e7%9b%b8-%e5%87%a1%e4%ba%8b%e7%9a%86%e7%89%a9%e4%bb%b6%e7%9a%84-python/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.