= 不是運算器 (operator), := 才是--談指派敘述
codemee
Posted on February 25, 2022
熟悉 C 家族語言的人可能會把 Python 的 =
當成是運算器 (operator), 不過實際上它並不是運算器, 所以不會有運算結果, =
是指派敘述 (assigment statement) 語法的一部分, 因此, 不能像是這樣把指派敘述放在運算式內:
>>> a = (b = 2)
File "<stdin>", line 1
a = (b = 2)
^
SyntaxError: invalid syntax
>>>
如果是 C 語言, 這會把變數 b
設為 2, 再把 a
設定為 b = 2
的運算結果 2。但是在 Python 中, 因為 b = 2
並不是運算式, 不符合指派敘述等號右邊的語法規定, 所以只會噴出語法錯誤的訊息。
多重指派
在指派敘述中, 等號左邊稱為標的清單 (target list), 當標的清單只有單一標的時, 就是非常直覺的命名並綁定, 我們在可變、不可變的真相--凡事皆物件的 Python一文中解釋過, 這裡我們要談的是指派敘述的各種變化, 也就是標的清單內有多個標的的情況。
如果標的清單中有多個標的, 那麼在等號右側也必須要有對應數量的物件, 實際上就會一一對應進行綁定, 例如:
>>> a, b = 1, 2
>>> a
1
>>> b
2
如果數量不對, 就會噴出錯誤訊息:
>>> a, b, c = 1, 2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: not enough values to unpack (expected 3, got 2)
>>>
錯誤訊息告訴我們物件數量不足, 需要 3 個, 但是只有提供 2 個。或是反過來, 物件數量比標的多:
>>> a, b = 1, 2, 3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: too many values to unpack (expected 2)
>>>
也一樣會噴出錯誤。如果等號右邊只有一個物件時, 你會看到噴出的訊息不一樣:
>>> a, b = 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: cannot unpack non-iterable int object
>>>
這裡的訊息是說無法對不可走訪 (non-iterable) 的 int 物件進行 unpack, 我們分兩段來說明。
對於標的清單中有多個標的的情況, 等號右邊必須要是一個可走訪的物件, 所謂可走訪的物件就是可以循序一一取出內含物件的容器物件, 像是串列、元組、字典都是, 也就是可以放到 for
敘述的物件, 要分辨物件是否可走訪, 只要看它是否具有 __iter__
方法即可, 例如:
>>> (2).__iter__
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'int' object has no attribute '__iter__'. Did you mean: '__str__'?
>>> (1, 2).__iter__
<method-wrapper '__iter__' of tuple object at 0x00000211F4B1EFC0>
>>>
你可以看到整數沒有 __iter__
方法, 而元組有, 所以元組是可走訪的物件。多重指派的運作方式就是一一從可走訪的物件中取出內含的物件, 指派給對應的標的, 這個動作就稱為拆箱 (unpack)。
回過頭來看剛剛等號右側只有 1 的例子, 因為它是 int
物件, 不能走訪, 當然就無法拆箱, 所以錯誤訊息會說無法對不可走訪 (non-iterable) 的 int
物件進行 unpack。我們可以在 1 的後面加上逗號, Python 就會認為這是一個只有單一項目的元組:
>>> a, b = 1,
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: not enough values to unpack (expected 2, got 1)
>>>
錯誤訊息就變成是物件數量不足了。
由於元組實際上是以逗號區隔項目來表示, 常見的小括號並不是元組的一部分, 只是為了和其他物件隔開避免混淆, 因此底下兩種寫法都可以:
>>> a, b = 1, 2
>>> a, b = (1, 2)
由於只要是可走訪的物件都可以用來設定多重標的, 所以也可以使用串列、字典, 甚至字串, 例如:
>>> a, b = [1, 2]
>>> a
1
>>> b
2
>>> a, b = {10:1, 20:2}
>>> a
10
>>> b
20
>>> a, b = "AB"
>>> a
'A'
>>> b
'B'
>>>
要注意的是走訪字典時取出的是索引鍵, 而走訪字串取出的是個別的字元。
強制拆箱
如果等號右邊的物件內含容器物件, 我們希望能取出其中的物件指派給標的, 可以在物件前加上星號 *, 強制拆箱, 例如:
>>> a, b, c = 1, *(2, 3)
>>> a
1
>>> b
2
>>> c
3
>>>
本來等號右側只有 2 個項目, 但因為加了星號在第 2 個項目前, 所以會強制拆箱, 就變成總共 3 個項目, 與標的清單內的數量相符:
a, b, c = 1, *(2, 3)
|
v
a, b, c = 1, 2, 3
如此即可一一對應指派了。
加星號 (starred) 的指派標的
如果希望將物件中的部分項目自動組成串列指派給標的, 可以在標的前面加上星號 '*', 這樣會先從頭對應項目到加星號的標的之前, 然後再從尾端對應項目到加星號的標的之後, 再將剩下尚未對應到的項目組成一個串列後指派給加星號的標的, 例如:
>>> a, *b, c = 1, 2, 3, 4
>>> a
1
>>> b
[2, 3]
>>> c
4
>>>
實際對應如下:
a, *b, c
| |
v v
1, 2, 3, 4
從頭對應, 1 -> a;從尾端對應, 4 -> c;最夠剩下 2, 3 組成串列對應到加了星號的 b。這個和拆箱相反的動作一般稱為裝箱 (pack), 雖然在官方文件中好像沒有正式的名稱。
由於剩下未對應到的項目會組成一個串列, 只會對應到單一個標的, 因此在標的清單中只能有一個加星號的標的, 否則會出現錯誤:
>>> a, *b, *c = 1, 2, 3, 4
File "<stdin>", line 1
SyntaxError: multiple starred expressions in assignment
>>>
指派敘述允許未對應的項目是 0 個, 這時會組成空的串列, 例如:
>>> a, *b, c = 1, 2
>>> a
1
>>> b
[]
>>> c
2
>>>
所以等號右邊的項目數量可以比標的清單中的標的數量少一個。
用小括號或中括號幫標的分組
如果等號右邊是結構複雜的容器, 我們也可以使用小括號或是中括號建構具有階層結構的標的清單, 實際對應時會以遞迴的方式一層層完成指派。例如:
>>> a, (b, c) = 1, (2, 3)
>>> a
1
>>> b
2
>>> c
3
>>>
一開始的對應關係如下:
a, (b, c)
| |
v v
1, (2, 3)
遞迴變成 2 個指派敘述:
a = 1
b, c = 2, 3
同樣的道理, 即使多層也沒有問題:
>>> (a, (b, c)), d = (1, (2, 3)), 4
>>> a
1
>>> b
2
>>> c
3
>>> d
4
>>>
原始對應關係如下:
(a, (b, c)), d
| |
v v
(1, (2, 3)), 4
遞回成 2 個指派敘述:
a, (b, c) = 1, (2, 3)
d = 4
再遞迴成 3 個指派敘述:
a = 1
b, c = 2, 3
d = 4
你也可以使用中括號來幫標的建立階層:
>>> a, [b, c] = 1, (2, 3)
>>> a
1
>>> b
2
>>> c
3
>>>
甚至混用兩種括號也沒問題:
>>> (a, [b, c]), d = (1, (2, 3)), 4
>>> a
1
>>> b
2
>>> c
3
>>> d
4
>>>
在個別階層內一樣可以使用加上星號的標的, 例如:
>>> a, *b, (c, *d), e = 1, 2, 3, (4, ), 5
>>> a
1
>>> b
[2, 3]
>>> c
4
>>> d
[]
>>> e
5
>>>
原始對應關係如下:
a, *b, (c, *d), e
| | |
v v v
1, 2, 3, (4, ), 5
所以:
a = 1
b = [2, 3]
c, *d = 4,
e = 5
最後:
c = 4
d = []
你可能會想說前面不是提到標的清單中只能有一個加上星號的標的, 但這個例子裡卻有兩個標的加上星號?其實我們如果看一開始的指派敘述, 它的標的清單裡只有 4 個標的, 分別如下:
a
*b
(c, *d)
e
其中只有一個有加星號。要遞迴拆解到下一層的指派敘述時, 才會看到另一個加星號的標的:
c, *d = 4,
在這個指派敘述中, 一樣符合只有一個加上星號的標的。
利用多重指派交換內容
我們可以利用多重指派的方式完成內容互換的工作, 像是:
>>> a = 1
>>> b = 2
>>> a, b = b, a
>>> a
2
>>> b
1
>>>
這看起來好像很神奇, 但只要你了解 Python 的綁定觀念, 就不會覺得驚訝。上面的過程可以分成以下步驟:
-
一開始綁定
a
、b
:
>>> a = 1 >>> b = 2 ___ ___ | a | b | | | v v 1 2
-
接著依照等號右邊產生一個內含 2 個項目的元組, 內含項目依序綁定到
b
與a
綁定的物件:
>>> a, b = b, a ___ ___ | a | b | | | ___ ___ v v | . | . | 1 2 | | ^ ^ | | | |_______| | |_______________|
-
再將
a
循元組索引 0 的項目綁訂到新的物件、b
循元組索引 1 的項目綁訂到新的物件:
>>> a, b = b, a ___ ___ | a | b | | |___________ |___________ | | | 1 2 | | ^ ^ | | | |_______| | |_______________|
最後
a
與b
就互換綁定的物件了。
注意指派時的順序
如果在標的清單中有某個標的會使用到其他標的來當成容器的索引或是取得切片, 就要注意指派時會從清單中由左至右進行, 例如:
>>> b = [1, 2]
>>> a = 0
>>> a, b[a] = 1, 4
>>> a
1
>>> b
[1, 4]
>>>
a
與 b[a]
的指派並不是同時完成的, 而是由左至右的順序, 因此在指派 b[a]
的時候, a
已經指派為 1, 所以 b[1]
會指派為 4。
串接多組標的清單
指派敘述中開頭的標的清單與等號可以多組串接, 每組標的清單可以不一樣, 例如以下最簡單的範例:
>>> a = b = c = 1, 2
就相當於是三個指派敘述:
>>> a = 1, 2
>>> b = 1, 2
>>> c = 1, 2
如果需要, 個別的標的清單也可以是不同的結構, 例如:
>>> a = b, c = d, *e = 1, 2
就相當於以下三個指派敘述:
>>> a = 1, 2
>>> b, c = 1, 2
>>> d, *e = 1, 2
所以最後的指派結果如下:
>>> a
(1, 2)
>>> b
1
>>> c
2
>>> d
1
>>> e
[2]
>>>
要特別注意的是雖然可以把串接看成是多個指派敘述, 但最右邊等號後面的運算式只會執行一次, 例如:
>>> a = [1, 2, 3]
>>> b = c = a.pop()
>>> b
3
>>> c
3
>>> a
[1, 2]
>>>
由於 a.pop()
只會執行一次, 得到 a
串列尾端的 3, 所以 b
、c
都被指派成 3, 而 a
的內容是彈出 3 之後的 [1, 2]
。
使用 _
耗掉不想要的項目
如果等號右邊的物件內含一些後續不會用到的項目, 為了符合語法的規定, 就必須在標的清單中列出對等數量的標的, 例如:
>>> a, b, c = 1, 2, 3
其中若後續的程式不會用到 b
、c
, 閱讀程式時可能會很疑惑他們到底用在哪裡?在這種情況下, 慣例上會用特別的名稱 _
來取代 b
、c
:
>>> a, _, _ = 1, 2, 3
>>> a
1
>>> _
3
>>>
_
其實並不特別, 是合於 Python 識別字語法規定的名稱, 只是它不具一般人認知的語義, 所以慣例上就用它來綁定語法上需要、但是程式不會再用到的物件。由於它就是個正常的名稱, 所以也可以加上星號, 例如:
>>> a, *_ = 1, 2, 3
>>> a
1
>>> _
[2, 3]
>>>
如此, 就可以用 _
來耗掉我們不需要的物件。
:=
運算器
如果你真的想要像是 C 語言那樣指派完後直接加入運算式運算, 可以使用 Python 3.8 才新增的 :=
運算器, 例如:
>>> a = (b := 4) + 1
>>> a
5
>>> b
4
>>>
但是運算器左邊一定要是單一個識別名稱, 而不能是容器內的項目, 例如以下會出錯:
>>> b = [1, 2]
>>> a = (b[1] := 5)
File "<stdin>", line 1
a = (b[1] := 5)
^^^^
SyntaxError: cannot use assignment expressions with subscript
>>>
運算器右邊則必須是一個運算式。
運算器右邊則必須是一個運算式。:=
的外型很像是露出常長尖牙的海象 (walrus), 所以也戲稱指派運算式為海象。
指派運算式一定要出現在其他運算式中
要注意的是, 為了要和指派敘述明確區分, 指派運算式不能像是一般的運算式那樣單獨存在, 例如:
>>> a := 20
Syntax Error: invalid syntax (<input>, line 1)
會出現語法錯誤, 如果真的需要, 可以加上額外的圓括號, 讓指派運算式變成是運算式的一部分, 像是這樣:
>>> (a := 20)
20
>>> a
20
你會看到因為這是一個運算式, 所以在互動模式下會自動顯示運算式的運算結果, 也就是 20。如果使用指派敘述, 就什麼都不會顯示:
>>> a = 20
>>>
指派運算式無法進行多重指派
如果想要像是指派敘述那樣多重指派, 會出現語法錯誤:
>>> (a, b := 20, 30)
(20, 20, 30)
這也是因為指派運算式必須出現在其他運算式中才行。如果額外加上圓括號, 像是這樣:
>>> (a, b := 40, 30)
(20, 40, 30)
>>> a
20
>>> b
40
這時會被解譯成是一個有 3 個元素的元組, 其中第二個元素是一個運算式, 運算結果是 40, 所以顯示的元組就是 (20, 40, 30)
, 由於不是多重指派, 所以 a
維持之前指派的結果 20 不變。
增強型指派敘述 (augmented assignment statement)
你可能已經想到, 既然 =
不是運算器, 那麼 +=
、-=
等等這一些一定也不是運算器, 沒錯, 他們都是所謂『增強型指派敘述 (augmented assignment statement)』語法的組成份子。指派的標的只能是識別名稱、物件的屬性、容器內的索引項目或是切片, 例如:
>>> a = 10
>>> a += 10
>>> a
20
它會先計算右側的運算式, 再以指派的標的及右側運算式的運算結果當運算元進行指定的運算, 再將運算結果指派回標的。如果標的是切片, 就會置換切片位置的內容:
>>> a = [1, 2, 3]
>>> a[1:] += 4, 5
>>> a
[1, 2, 3, 4, 5]
>>>
小結
Python 的指派敘述看似簡單, 但是變化多樣, 而且在許多套件中也會利用各種複雜結構傳回結果, 了解指派敘述的不同型式, 就可以用最簡潔有效的方式取得傳回結果的細部資料, 而不需要使用多列程式拆解內容。舉例來說, time
模組的 localtime()
會傳回一個內含日期時間細部資料的可走訪物件, 如果只想取得年份, 可以這樣寫:
>>> import time
>>> year, *_ = time.localtime()
>>> year
2022
>>>
Posted on February 25, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.