[Python]不再用錯Python 的 list

Daniel Chiang
12 min readMar 4, 2021

--

在我們開始之前你能正確地回答以下這三個問題,並且解釋它嗎?

ex1:
l1 = [[]] * 3
l1[0].append(1)
l1[1].append(2)
l1[2].append(3)
ex2:
l2 = [[]] * 3
l2[1] = l2[1] + [2]
l2[0] += [1]
l2[2].append(3)
ex3:
l3 = [[] for _ in range(3)]
l3[0].append(1)
l3[1].append(2)
l3[2].append(3)

以下為解答:

l1
>>> [[1, 2, 3], [1, 2, 3], [1, 2, 3]]
l2
>>> [[1, 3], [2], [1, 3]]
l3
>>> [[1], [2], [3]]

如果你都能答對並且清楚原理了話,你可以跳到進階看看。如果你感到有些錯愕了話那就太好了,這篇文章會讓你以後不再搞錯list用法。

什麼是list

list是一種容器序列,容器序列能存放不同類型的數據。除了list以外還有誰是容器序列,ex: tuple。那有誰不是容器序列,str、bytes這些屬於扁平序列。兩者序列差異在於,容器存放的是任意類型參照(reference),而扁平存放的是

我們還能再從可不可以修改區分出可變序列(mutable sequence)、不可變序列(immutable sequence)。
list屬於可變序列,str、tuple就屬於不可變序列。我舉個例子你一定能秒懂。像是你能對list某一個特定index賦值:list[0] = 1,但你不能夠對str做一樣的操作str[0] = ‘a’。

我相信在寫程式時對於list語法上不會有太多遲疑,但大家容易忽略這個類型最基礎的概念,變成硬背某些語法能成立而有些不行。

序列使用運算符號

我們都很熟習的使用+,*對序列操作。像是使用+號時,是將兩個相同類型的序列加在一起,在相加的過程中,這兩個序列都不會被修改,而python會創建一個新的相同類型的序列來當作結果。
在python +,*都是一樣的規則,在不修改原本操作對象建立新的序列。

例一
a = [1]
print(id(a))
>>> 2093826222024
b = [2]
a = a + b
print(id(a), id(b), a)
>>> 2093826221320 2093827856712 [1, 2]

我們可以用查看id的方式確認他們是不是相同的對象,可以看到當a + b的結果賦值給a時,這個a已經不是以前的那個a了

我們在更深入一點,我們知道python 中的每個類型其實都是一個類別(你可以print(list) 它的return <class ‘list’>),list因為能使用增量符號(+)是因為list 類別裡定義了__add__方法(不信了話你可以使用dir(list)就可以看到list類別內部定義的方法)。
在可變序列會實現較特殊的方法__iadd__,你可以稱它為就地加法。那什麼時候會使用到它呢?就是當你使用增量賦值符號(+=),對於可變序列來說值是被就地改動的,變數並不會被關連到新的對象。
下面這個例子會讓你更清楚:

例二
a = [1]
print(id(a))
>>> 2093828050120
b = [2]
a += b
print(id(a), id(b), a)
>>> 2093828050120 2093826223304 [1, 2]

從結果來看,例一、例二的a得到了相同的結果,因為結果的值是我們所期待的很常讓我誤以為我們了解它。但當你查看id實就會發現,例一的a是一個新的被建立的對象,而例二的a仍然是原本的那個a,只是把b的值加進來。

你可能會注意到我在上面說到"可變序列會實現較特殊的方法__iadd__",但是為什麼像是str為不可變序列仍然能使用增量賦值符號(+=)呢?
那是因為當這個類別沒有實現__iadd__的方法時,python會退一步調用__add__。也就是說,對於str,其實你使用a+=b其實跟a = a + b一樣,因此你也可以從此得知,不可變序列作完運算都是獲得一個新的對象
所以你不能把對於字串(str)做增量符號(+=)實作細節當成是跟list是一樣的。

你還得額外注意如果你要使用*,當你使用 l * n這個語句,如果l裡面的元素有可變對象的參照了話,你所得到的結果可能並非你預期的。
下面使用一個例子來讓你更清楚你得到的東西是什麼:

# 當你想要初始化一個由多個列表組成的列表
element = []
l = [element]
n_l = l * 3
print(n_l, element, id(n_l[0]), id(n_l[1]), id(n_l[2]))
>>> [[], [], []] [] 2093824409416 2093824409416 2093824409416

我相信你也看到了這個可怕的事實,就是n_l列表中的每個元素都參照了相同的列表。因此當你對可變序列做像是append、+=這類型的就地更新操作時,因為所有元素都參照了同一個列表,因此不管對哪個index操作,其實都是對同一個列表做操作,只要這個列表值改變了,所有的參照也會跟著改變。如果你上一段已經了解了話,你就會知道如果我是對某一個index做增值操作(+)了話就不會有這樣的問題,因為運算結果會賦予一個新的對象。
你可以執行這兩個操作就會更清楚:

n_l = [[]] * 3
n_l[0] += [1] # or n_l[0].append(1)
print(n_l, id(n_l[0]), id(n_l[1]))
>>> [[1], [1], [1]] 2093826064520 2093826064520
n_l = [[]] * 3
n_l[0] = n_l[0] + [1]
print(n_l, id(n_l[0]), id(n_l[1]))
>>> [[1], [], []] 2093826064520 2093828342024

會遇到這個問題是因為list裡面是可變對象,如果是不可變對象就完全不會有這個問題。

n_l = [‘1’] * 3
n_l[0] += ‘234’
print(n_l, id(n_l[0]), id(n_l[1]), id(n_l[2]))
>>> ['1234', '1', '1'] 2093828725168 2095882712816 2095882712816

如果你已經了解+、+=、*對於可變序列、不可變序列的影響了話,你就更加清楚自己寫的code細節是在做什麼。並且對於開頭的3個example你都有能力解釋了。

ex1:
# l1 內部的三個元素都參照了相同的列表,因此做append其實都是同一個列表不斷疊加
l1 = [[]] * 3
l1[0].append(1)
l1[1].append(2)
l1[2].append(3)
ex2:
# l2 內部雖然三個元素都參照同個列表,但當我們對l2[1]賦予l2[1]+[2]的值時,l2[1]就已經是新的序列了。l2 index 0、2仍然參照同一個列表,使用+=、append操作不會為它們獲得新的序列,而是對同一個列表操作。
l2 = [[]] * 3
l2[1] = l2[1] + [2]
l2[0] += [1]
l2[2].append(3)
ex3:
# 如果我們希望內部元素都有不同參照可以用迭代的方式,這樣每個元素都是新的對象
l3 = [[] for _ in range(3)]
l3[0].append(1)
l3[1].append(2)
l3[2].append(3)

進階

我們一樣先來幾個題目,來判斷這一章節有沒有你可以學到的內容。

ex1:
a = [1]
b = a
b.append(3)
print(a, b)
ex2:
a = [1]
a_pro = [[1]]
b = a.copy()
b.append(3)
b_pro = a_pro.copy()
b_pro[0].append(3)
print(a, b, a_pro, b_pro)
ex3:
class ListPlayer():
def __init__(self, df=[]):
self.df = df
def add(self, value):
self.df.append(value)

lp1 = ListPlayer()
lp1.add(1)
lp2 = ListPlayer()
lp2.add(3)
print(lp1.df, lp2.df)

以下為解答:

ex1:
>>> [1, 3], [1, 3]
ex2:
>>> [1], [1, 3], [[1, 3]], [[1, 3]]
ex3:
>>> [1, 3], [1, 3]

第一題應該對大部分來說還很好理解,應該從第二題開始就有點讓人困惑,當初我看到最後一題晚上做了很可怕的惡夢。如果以上你都答對了,請收下小弟的膝蓋,如果這上面有你未能解釋的結果,就一起往下看看吧。

複製

在我當初剛學python的時候就有人告訴我,如果你要讓a等於一個類型為list的b了話(a=b),建議使用a = b.copy()。它們給我的解釋通常都是:因為python的等於是參照的意思,並不是新建立一個相等於b的變數。但是這段解說無法解釋為什麼list 要做copy 但str 卻不用,我相信在前半部你已經能真的了解其中原因了。

但是到了ex2情況又詭異了起來,我明明在建立b_pro時對a_pro做了copy的動作,但為什麼b_pro[0]跟a_pro[0]還是參照了同一個列表。
原來當你下copy()時其實只是做了淺複製。淺複製是指複製了最外層的容器,但內部得元素是原本容器中元素的參照。

a = [1, []]
b = a.copy()
print('a:', id(a), id(a[0]), id(a[1]))
print('b:', id(b), id(b[0]), id(b[1]))
>>> a: 2093818815112 140736813244816 2093823080456
b: 2093822999496 140736813244816 2093823080456

我相信比較下來就很清楚了,b、a的確是不同對象,但內部元素參照都沒變。如果前半段你就已經有足夠的了解,看到這邊就知道ex2問題。ex2 剛好a_pro內部元素是可變序列list,因此當你弱複製給b_pro時,兩者的第一個元素仍然參照同一個list,如果這時候你還是使用+=、append這類型的操作就可能產生非你預期的結果。
BTW:使用python類型構造方法其實跟copy是同樣的效果:b = list(a) 等同於 b = a.copy()

使用深複製

可以使用copy模塊裡的deepcopy就能解決淺複製內部可變序列的元素相同參照的問題。
這裡只做簡單示例:

import copy
a = [1, []]
b = copy.deepcopy(a)
print('a:', id(a), id(a[0]), id(a[1]))
print('b:', id(b), id(b[0]), id(b[1]))
>>> a: 2093826710024 140736813244816 2093817269000
b: 2093826707656 140736813244816 2093826709768

別使用可變序列當作參數的默認值

我們在把ex3的範例拿進來看看問題在哪。

ex3:
class ListPlayer():
def __init__(self, df=[]):
self.df = df
def add(self, value):
self.df.append(value)

lp1 = ListPlayer()
lp1.add(1)
lp2 = ListPlayer()
lp2.add(3)
print(lp1.df, lp2.df)
>>> [1, 3], [1, 3]

經過了前面多次的教訓,我們很快就能發現lp1, lp2兩者的df參照了相同的列表,而那位列表就是我們的默認值df=[]。
這個問題在於,默認值會在定義函數時就計算進去。當你實體化ListPlayer卻沒傳入參數了話,self.df就會參照默認值(df=[]),即使lp1, lp2分別是不同的物件,但兩者df屬性都參照了可變序列的默認值,所以做append也是在對同一個list 操作。
解決辦法可以傳入參數或是別使用可變序列當作默認值,程式可以這樣修改:

class ListPlayer():
def __init__(self, df=None):
self.df = df or []
def add(self, value):
self.df.append(value)

lp1 = ListPlayer()
lp1.add(1)
lp2 = ListPlayer()
lp2.add(3)
print(lp1.df, lp2.df)
>>> [1] [3]

結語

有些人會認為這是使用python list上的一些陷阱,但你只要理解過它的概念,也就不會害怕產出非你預期的結果。
只要你有足夠理解python型態的細節,在寫程式的過程中就能更加清楚的掌握每一 行程式碼執行的結果。

如果這篇文章對你有幫助,記得幫我留下掌聲~

Reference:
Fluent Python

--

--