寫過 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

concatenating list 1

這時我們如果執行這個指令:Lt[0] = 9 的話會怎麼樣呢?Python 會把 Lt[0] 貼到 9 這個球上,然後這個球會放入 Lt 這個箱子;那麼,原本的 1 號球呢?他會被移到只被 Lt3 裝到而沒有被 Lt 裝到,大概如圖二。

這裡的把球移動並不是真的改變他的記憶體位址,而只為了方便說明和理解而使用的動作。

concatenating list 2

這看起來沒什麼大問題,因為整數物件是不可變的,同一個位址上的那個整數基本上不會變成另一個整數,但如果是像 list 一樣的可變物件呢?

比如我們寫了以下的程式:

Lt4 = [[1]]
Lt5 = [[2]]
Lt6 = Lt4 + Lt5

應該會是像圖三這樣:

mutable object in list

然後我們來使用 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 相關的兩種寫法。

就這樣了!感謝大家的閱讀,如果有疑慮或指正歡迎留言提出。


參考資料:

  1. Python 3.5 技術手冊(林信良著,2016)
  2. Python docs: copy — Shallow and deep copy operations
  3. Python docs: Sequence Types

後記:
  希望引進 N 次貼的概念後不會把問題更加複雜化,其實這個想法(N 次貼)從我一開始知道有這個情況的時候就開始構築了,但都沒有拿他來解釋其他的現象,所以有點擔心會不會變得太複雜XD