[Python] 關於變數與參考的二三事

寫過 Python 的人大概都知道,在複製 list 的時候最好不要直接指定,而要使用 copy 函式,但可能有些時候,我們還是會不小心觸發這個黑魔法,所以今天我們要來破解這個魔咒,看看到底背後藏了什麼祕密!
  • 變數
  首先我們要來看一下 Python 的變數到底是如何運作的,假設我們輸入了:
x = 1
  就代表我們把 x 的值指定為 1 了,是嗎?事實上並不是這樣的,而是讓 x 這個變數參考到了 1 這個物件,我們可以用 id() 函式來看看這個物件在記憶體中的位址究竟在哪:
print(id(x))  # 2011157552
  我們可以看到,有一個奇怪的數字被印出來了。而如果我們將 x 加上 1,也就是 x += 1 之後,再對 x 使用 id() 的話,我們會發現,輸出結果,也就是物件的位址變得不同了,這似乎和 C++ 這類的靜態語言很不一樣。
  這個差別很重要嗎?可能在處理數值型態如整數之類的型態不太需要去注意,但在處理 list -也就是我們今天的主角時就很重要了。

  進到下一個小節前,我們先來整理一下,不論你是不是已經完全懂了,我想拿個東西來比喻 Python 的變數:N次貼。變數就像 N次貼一樣,上面寫著變數的名字,然後我們讓他參考到不同的物件時,就像拿著這個 N次貼到處黏貼一樣,因此只要是黏到(參考)不同的東西(物件),就一定是在不同的位置上(記憶體位址)。
  在這裡我們也可以看到為什麼 Python 的變數可以一下儲存整數,一下又是字串,因為就如同剛剛所說的,我們終究只是拿著 N次貼在到處黏而已,而這 N次貼上又沒有規定我們一定要黏在什麼東西上不然就會爆炸什麼的(呃,所以我們可以把他黏到各種型態上面都沒問題。


  • List 的陷阱 (1)-指定「=」
  接下來的小節中都會以上面 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 的陷阱 (2)-串接「+」
  再來看看如果我們有兩個 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 的陷阱 (3)-重複「*」
  好的,這一小節是最後一個陷阱了,也是我不久前才踩進的陷阱。當初我想製造一個二維的 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 8.10. copy
  3) Python docs 4.6. Sequence Types

後記:
  希望引進 N次貼的概念後不會把問題更加複雜化,其實這個想法(N次貼)從我一開始知道有這個情況的時候就開始構築了,但都沒有拿他來解釋其他的現象,所以有點擔心會不會變得太複雜XD
  
  然後之前那個每天來點字串用法... 寫得好累又很快膩XD 所以可能會暫時休刊一陣子(其實早就了),另外很神奇的是每天都會有 1~5 次的點閱數,明明就不是什麼很有內容的部落格啊哪能看這麼多次,我真的很懷疑是不是有人用瀏覽器開了就忘了關然後每天都重新載入XD

留言

張貼留言

這個網誌中的熱門文章

[C] 每天來點字串用法 (2) - strcpy()、strncpy()

[Python] *args 和 **kwargs 是什麼?一次搞懂它們!

[C] 每天來點字串用法 (5) - strcat()、strncat()