寫過 Python 的人大概都知道,在複製 list 的時候最好不要直接指定,而要使用 copy
函式,但可能有些時候,我們還是會不小心觸發這個黑魔法,所以今天我們要來破解這個魔咒,看看到底背後藏了什麼祕密!
變數
首先我們要來看一下 Python 的變數到底是如何運作的,假設我們輸入了:
x = 1
就代表我們把 x 的值指定為 1 了,是嗎?事實上並不是這樣的,而是讓 x 這個變數參考到了 1 這個物件,我們可以用 id()
函式來看看這個物件在記憶體中的位址究竟在哪:
print(id(x)) # 94526375507712
我們可以看到,有一個奇怪的數字被印出來了。而如果我們將 x 加上 1,也就是 x += 1
之後,再對 x 使用 id()
的話,我們會發現,輸出結果,也就是物件的位址變得不同了,這似乎和 C++ 這類的靜態語言很不一樣。
這個差別很重要嗎?可能在處理數值型態如整數之類的型態不太需要去注意,但在處理 list - 也就是我們今天的主角時就很重要了。
進到下一個小節前,我們先來整理一下,不論你是不是已經完全懂了,我想拿個東西來比喻 Python 的變數:N 次貼。變數就像 N 次貼一樣,上面寫著變數的名字,然後我們讓他參考到不同的物件時,就像拿著這個 N 次貼到處黏貼一樣,因此只要是黏到(參考)不同的東西(物件),就一定是在不同的位置上(記憶體位址)。
在這裡我們也可以看到為什麼 Python 的變數可以一下儲存整數,一下又是字串,因為就如同剛剛所說的,我們終究只是拿著 N 次貼在到處黏而已,而這 N 次貼上又沒有規定我們一定要黏在什麼東西上不然就會爆炸什麼的(呃,所以我們可以把他黏到各種型態上面都沒問題。
List 的陷阱
指定「=
」
接下來的小節中都會以上面 N 次貼的概念來講解。當我們把一個參考到 list 的變數指定給另一個變數時:
Lt = [1, 2, 3]
Lt2 = Lt
看起來好像是我們把 [1, 2, 3]
複製了一遍,再指定給 Lt2,但實際上只是把寫著「Lt2
」的 N 次貼也貼到那個 list 上而已,我們可以透過 id()
來驗證。所以當我們對其中一個 list 做變動的話... 就會兩個變數一起被更改!也就是踏入黑魔法的第一步。
若要解決這個問題,我們需要用到 list 本身的建構式、其底下的 copy()
這個方法、copy 模組的 copy()
函式,或是使用 slice 運算:
import copy
Lt = [1, 2, 3]
Lt2 = Lt
Lt3 = list(Lt)
Lt4 = Lt.copy()
Lt5 = copy.copy(Lt)
Lt6 = Lt[:]
Lt[1] = 9
print(Lt) # [1, 9, 3]
print(Lt2) # [1, 9, 3]
print(Lt3) # [1, 2, 3]
print(Lt4) # [1, 2, 3]
print(Lt5) # [1, 2, 3]
print(Lt6) # [1, 2, 3]
可以看到,透過 copy()
取得的 list 就會是確實的複製過一遍後再讓其他變數去參考,就不會造成一次修改到兩個變數的問題。
串接「+
」
再來看看如果我們有兩個 list 如下:
Lt = [1, 2, 3]
Lt2 = [4, 5, 6]
我們可以想像有一個箱子,裡面有編號 1~3 的球。箱子上貼著一個寫著 「Lt」 的 N 次貼,而 1、2、3 號球分別黏了上面寫著 「Lt[0]
」、「Lt[1]
」、「Lt[2]
」的 N 次貼;而 Lt2 亦是如此。
接下來的比較抽象,如果無法快速理解的話建議可以畫出來。當我們使用 +
號連接 Lt 和 Lt2 如下的指令時,Python 所做的是將寫著 「Lt3[0]
」、「Lt3[2]
」、...、「Lt3[5]
」的 N 次貼分別黏到原本的 1、2、...、6 號球上;另外有一個神奇的 U型箱倒扣著 1~6 號球但沒有裝到原本的兩個箱子,而這個 U 型箱上貼著「Lt3」(如圖一)。
Lt3 = Lt + Lt2
這時我們如果執行這個指令:Lt[0] = 9
的話會怎麼樣呢?Python 會把 Lt[0]
貼到 9 這個球上,然後這個球會放入 Lt 這個箱子;那麼,原本的 1 號球呢?他會被移到只被 Lt3 裝到而沒有被 Lt 裝到,大概如圖二。
這裡的把球移動並不是真的改變他的記憶體位址,而只為了方便說明和理解而使用的動作。
這看起來沒什麼大問題,因為整數物件是不可變的,同一個位址上的那個整數基本上不會變成另一個整數,但如果是像 list 一樣的可變物件呢?
比如我們寫了以下的程式:
Lt4 = [[1]]
Lt5 = [[2]]
Lt6 = Lt4 + Lt5
應該會是像圖三這樣:
然後我們來使用 append()
看看:
Lt4[0].append(7)
這會把一顆 7 號球放進 Lt4[0]
裡面,然後我們就會發現事情不對勁了:明明我們對 Lt4[0]
使用 append()
,但 Lt6[0]
也跟著改變了!原因是我們用 append()
的時候就好比在原本的箱子裡放進了新的球,所以在沒有更動任何變數的參考之下,就會造成一次修改多個變數。
大家也可以試試看分別執行以下兩條指令會發生什麼事:
Lt4[0][0] = 9
Lt5 = [[3]]
而如果我們想要解決這個問題的話,可不是區區一個 copy()
函式或 slice 運算就能應付的了,因為在這裡出問題的是裡面的 list,而一般的 copy()
和 slice 做的只是淺層複製,不會把 list 中的 list 也複製到,所以我們要使用到 copy 模組的 deepcopy()
函式:
from copy import deepcopy
Lt = [[1]]
Lt2 = [[2]]
Lt3 = Lt + Lt2
Lt4 = Lt[:] + Lt2[:]
Lt5 = deepcopy(Lt) + deepcopy(Lt2) # 或 deepcopy(Lt1 + Lt2)
Lt[0].append(9)
print(Lt3) # [[1, 9], [2]]
print(Lt4) # [[1, 9], [2]]
print(Lt5) # [[1], [2]]
這樣就可以了。
重複「*
」
好的,這一小節是最後一個陷阱了,也是我不久前才踩進的陷阱。當初我想製造一個二維的 list,然後每個元素都一樣,所以我寫了類似下面這行的東西:
Lt = [[0] * 3] * 3
結果我只是改了 Lt[0][0]
的值,就連同 Lt[1][0]
和 Lt[2][0]
一起改到了,因為使用 * 來重複 list 的話,事實上也只是 Lt[0]
、Lt[1]
、Lt[2]
都貼到(參考到)同一個箱子(list),並不會產生 3 個不一樣的 list。
解決的方法除了上面的 copy()
(這裡要用 copy()
的話好像也不太適合),我們可以使用 for 迴圈來一次次的把一個 list append()
進另一個空的 list:
Lt = []
for i in range(3)
Lt.append([0] * 3)
像上面這樣。甚至我們還可以搭配 list comprehension,寫成下面這樣:
Lt = [[0] * 3 for i in range(3)]
就可以完美化解我們遇到的問題了。
copy() 與總結
再稍微講一點點 copy 模組的細節:copy()
函式的運作就像是只複製了最外面的箱子,所以是淺層複製;而 deepcopy()
則會以遞迴的方式去複製箱子裡的任何東西,所以可以給出完全複製過的物件。
好了,到最後我們就可以來喘口氣、統整一下上面所講的內容:一開始說明了變數與物件的關係,就如同 N 次貼和物品的關係一般;然後提到了三個會呼喚黑魔法的途徑,分別是指定、串接和重複,也講到了各自的解法:copy() 函式、slice 運算,deepcopy() 函式,以及與 for 相關的兩種寫法。
就這樣了!感謝大家的閱讀,如果有疑慮或指正歡迎留言提出。
參考資料:
- Python 3.5 技術手冊(林信良著,2016)
- Python docs: copy — Shallow and deep copy operations
- Python docs: Sequence Types
後記:
希望引進 N 次貼的概念後不會把問題更加複雜化,其實這個想法(N 次貼)從我一開始知道有這個情況的時候就開始構築了,但都沒有拿他來解釋其他的現象,所以有點擔心會不會變得太複雜XD