[譯] 使用多重賦值與元組解包提升 Python 程式碼的可讀性

lsvih發表於2018-07-04

無論是教導新手還是資深 Python 程式設計師,我都發現 很多 Python 程式設計師沒有充分利用多重賦值這一特性

多重賦值(也經常被稱為元組解包或者可迭代物件解包)能讓你在一行程式碼內同時對多個變數進行賦值。這種特性在學習時看起來很簡單,但在真正需要使用時再去回想它可能會比較麻煩。

在本文中,將介紹什麼是多重賦值,舉一些常用的多重賦值的樣例,並瞭解一些較少用、常被忽視的多重賦值用法。

請注意在本文中會用到 f-strings 這種 Python 3.6 以上版本才有的特性,如果你的 Python 版本較老,可以使用字串的 format 方法來代替這種特性。

多重賦值的實現原理

在本文中,我將使用“多重賦值”、“元組解包”、“迭代物件解包”等不同的詞,但他們其實表示的是同一個東西。

Python 的多重賦值如下所示:

>>> x, y = 10, 20
複製程式碼

在這兒我們將 x 設為了 10y 設為了 20

從更底層的角度看,我們其實是建立了一個 10, 20 的元組,然後遍歷這個元組,將拿到的兩個數字按照順序分別賦給 xy

寫成下面這種語法應該更容易理解:

>>> (x, y) = (10, 20)
複製程式碼

在 Python 中,元組周圍的括號是可以忽略的,因此在“多重賦值”(寫成上面這種元組形式的語法)時也可以省去。下面幾行程式碼都是等價的:

>>> x, y = 10, 20
>>> x, y = (10, 20)
>>> (x, y) = 10, 20
>>> (x, y) = (10, 20)
複製程式碼

多重賦值常被直接稱為“元組解包”,因為它在大多數情況下都是用於元組。但其實我們可以用除了元組之外的任何可迭代物件進行多重賦值。下面是使用列表(list)的結果:

>>> x, y = [10, 20]
>>> x
10
>>> y
20
複製程式碼

下面是使用字串(string)的結果:

>>> x, y = 'hi'
>>> x
'h'
>>> y
'i'
複製程式碼

任何可以用於迴圈的東西都能和元組解包、多重賦值一樣被“解開”。

下面是另一個可以證明多重賦值能用於任何數量、任何變數(甚至是我們自己建立的物件)的例子:

>>> point = 10, 20, 30
>>> x, y, z = point
>>> print(x, y, z)
10 20 30
>>> (x, y, z) = (z, y, x)
>>> print(x, y, z)
30 20 10
複製程式碼

請注意,在上面例子中的最後一行我們僅交換了變數的名稱。多重賦值可以讓我們輕鬆地實現這種情形。

下面我們將討論如何使用多重賦值。

在 for 迴圈中解包

你會經常在 for 迴圈中看到多重賦值。下面舉例說明:

先建立一個字典(dict):

>>> person_dictionary = {'name': "Trey", 'company': "Truthful Technology LLC"}
複製程式碼

下面這種迴圈遍歷字典的方法比較少見:

for item in person_dictionary.items():
    print(f"Key {item[0]} has value {item[1]}")
複製程式碼

但你會經常看到 Python 程式設計師通過多重賦值來這麼寫:

for key, value in person_dictionary.items():
    print(f"Key {key} has value {value}")
複製程式碼

當你在 for 迴圈中寫 for X in Y 時,其實是告訴 Python 在迴圈的每次遍歷時都對 X 做一次賦值。與用 = 符號賦值一樣,這兒也可以使用多重賦值。

這種寫法:

for key, value in person_dictionary.items():
    print(f"Key {key} has value {value}")
複製程式碼

在本質上與這種寫法是一致的:

for item in person_dictionary.items():
    key, value = item
    print(f"Key {key} has value {value}")
複製程式碼

與前一個例子相比,我們其實就是去掉了一個沒有必要的額外賦值。

因此,多重賦值在用於將字典元素解包為鍵值對時十分有用。此外,它還在其它地方也可以使用:

在內建函式 enumerate 的值拆分成對時,也是多重賦值的一個很有用的場景:

for i, line in enumerate(my_file):
    print(f"Line {i}: {line}")
複製程式碼

還有 zip 函式:

for color, ratio in zip(colors, ratios):
    print(f"It's {ratio*100}% {color}.")
複製程式碼
for (product, price, color) in zip(products, prices, colors):
    print(f"{product} is {color} and costs ${price:.2f}")
複製程式碼

如果你還對 enumeratezip 不熟悉,請參閱作者之前的文章 looping with indexes in Python

有些 Python 新手經常在 for 迴圈中看到多重賦值,然後就認為它只能與迴圈一起使用。但其實,多重賦值不僅可以用在迴圈賦值時,還可以用在其它任何需要賦值的地方。

替代硬編碼索引

很少有人在程式碼中對索引進行硬編碼(比如 point[0]items[1]vals[-1]):

print(f"The first item is {items[0]} and the last item is {items[-1]}")
複製程式碼

當你在 Python 程式碼中看到有硬編碼索引時,一般都可以設法使用多重賦值來讓你的程式碼更具可讀性

下面是一些使用了硬編碼索引的程式碼:

def reformat_date(mdy_date_string):
    """Reformat MM/DD/YYYY string into YYYY-MM-DD string."""
    date = mdy_date_string.split('/')
    return f"{date[2]}-{date[0]}-{date[1]}"
複製程式碼

我們可以通過多重賦值,分別對月、天、年三個變數進行賦值,讓程式碼更具可讀性:

def reformat_date(mdy_date_string):
    """Reformat MM/DD/YYYY string into YYYY-MM-DD string."""
    month, day, year = mdy_date_string.split('/')
    return f"{year}-{month}-{day}"
複製程式碼

因此當你準備對索引進行硬編碼時,請停下來想一想是不是應該用多重賦值來改善程式碼的可讀性。

多重賦值是十分嚴格的

在我們對可迭代物件進行解包時,多重賦值的條件是非常嚴格的。

如果將一個較大的可迭代物件解包到一組數量更小的物件中,會報下面的錯誤:

>>> x, y = (10, 20, 30)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ValueError: too many values to unpack (expected 2)
複製程式碼

如果將一個較小的可迭代物件解包到一組數量更多的物件中,會報下面的錯誤:

>>> x, y, z = (10, 20)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ValueError: not enough values to unpack (expected 3, got 2)
複製程式碼

這種嚴格的限制其實很棒,如果我們在處理元素時出現了非預期的物件數量,多重賦值會直接報錯,這樣我們就能發現一些還沒有被發現的 bug。

舉個例子。假設我們有一個簡單的命令列程式,通過原始的方式接受引數,如下所示:

import sys

new_file = sys.argv[1]
old_file = sys.argv[2]
print(f"Copying {new_file} to {old_file}")
複製程式碼

這個程式希望接受兩個引數,如下所示:

$ my_program.py file1.txt file2.txt
Copying file1.txt to file2.txt
複製程式碼

但如果在執行程式時輸入了三個引數,也不會有任何報錯:

$ my_program.py file1.txt file2.txt file3.txt
Copying file1.txt to file2.txt
複製程式碼

由於我們沒有驗證接收到的引數是否為 2 個,因此不會報錯。

如果使用多重賦值來代替硬編碼索引,在賦值時將會驗證程式是否真的接收到了期望個數的引數:

import sys

_, new_file, old_file = sys.argv
print(f"Copying {new_file} to {old_file}")
複製程式碼

注意: 我們用了一個名為 _ 的變數,意思是我們不想關注 sys.argv[0](對應的是我們程式的名稱)。用 _ 物件來忽略不需關注的變數是一種常用的語法。

替代陣列拆分

根據上文,我們一直多重賦值可以用來代替硬編碼的索引,並且它嚴格的條件可以確保我們處理的元組或可迭代物件的大小是正確的。

此外,多重賦值還能用於代替硬編碼的陣列拆分。

“拆分”是一種手動將 list 或其它序列中的部分元素取出的方法、

下面是一種用數字索引進行“硬編碼”拆分的方法:

all_after_first = items[1:]
all_but_last_two = items[:-2]
items_with_ends_removed = items[1:-1]
複製程式碼

當你在拆分時發現沒有在拆分索引中用到變數,那麼就能用多重賦值來替代它。為了實現多重賦值拆分陣列,我們將用到一個之前沒提過的特性:* 符號。

* 符號於 Python 3 中加入了多重賦值的語法中,它可以讓我們在解包時拿到“剩餘”的元素:

>>> numbers = [1, 2, 3, 4, 5, 6]
>>> first, *rest = numbers
>>> rest
[2, 3, 4, 5, 6]
>>> first
1
複製程式碼

因此,* 可以讓我們在取陣列末尾時替換硬編碼拆分。

下面兩行是等價的:

>>> beginning, last = numbers[:-1], numbers[-1]
>>> *beginning, last = numbers
複製程式碼

下面兩行也是等價的:

>>> head, middle, tail = numbers[0], numbers[1:-1], numbers[-1]
>>> head, *middle, tail = numbers
複製程式碼

有了 * 和多重賦值之後,你可以替換一切類似於下面這樣的程式碼:

main(sys.argv[0], sys.argv[1:])
複製程式碼

可以寫成下面這種更具自描述性的程式碼:

program_name, *arguments = sys.argv
main(program_name, arguments)
複製程式碼

總之,如果你寫了硬編碼的拆分程式碼,請考慮一下你可以用多重賦值來讓這些拆分的邏輯更加清晰。

深度解包

這個特性是 Python 程式設計師長期以來經常忽略的一個東西。它雖然不如我之前提到的幾種多重複值用法常用,但是當你用到它的時候會深刻體會到它的好處。

在前文,我們已經看到多重賦值用於解包元組或者其它的可迭代物件,但還沒看過它更進一步地進行深度解包。

下面例子中的多重賦值是淺度的,因為它只進行了一層的解包:

>>> color, point = ("red", (1, 2, 3))
>>> color
'red'
>>> point
(1, 2, 3)
複製程式碼

而下面這種多重賦值可以認為是深度的,因為它將 point 元組也進一步解包成了 xyz 變數:

>>> color, (x, y, z) = ("red", (1, 2, 3))
>>> color
'red'
>>> x
1
>>> y
2
複製程式碼

上面的例子可能比較讓人迷惑,所以我們在賦值語句兩端加上括號來讓這個例子更加明瞭:

>>> (color, (x, y, z)) = ("red", (1, 2, 3))
複製程式碼

可以看到在第一層解包時得到了兩個物件,但是這個語句將第二個物件再次解包,得到了另外的三個物件。然後將第一個物件及新解出的三個物件賦值給了新的物件(colorxyz)。

下面以這兩個 list 為例:

start_points = [(1, 2), (3, 4), (5, 6)]
end_points = [(-1, -2), (-3, 4), (-6, -5)]
複製程式碼

下面的程式碼是舉例用淺層解包來處理上面的兩個 list:

for start, end in zip(start_points, end_points):
    if start[0] == -end[0] and start[1] == -end[1]:
        print(f"Point {start[0]},{start[1]} was negated.")
複製程式碼

下面用深度解包來做同樣的事情:

for (x1, y1), (x2, y2) in zip(start_points, end_points):
    if x1 == -x2 and y1 == -y2:
        print(f"Point {x1},{y1} was negated.")
複製程式碼

請注意在第二個例子中,在處理物件時,物件的型別明顯更加清晰易懂。深度解包讓我們可以明顯的看到,在每次迴圈中我們都會收到兩個二元組。

深度解包通常會在每次得到多個元素的巢狀迴圈中使用。例如,你能在同時使用 enumeratezip 時應用深度多重賦值:

items = [1, 2, 3, 4, 2, 1]
for i, (first, last) in enumerate(zip(items, reversed(items))):
    if first != last:
        raise ValueError(f"Item {i} doesn't match: {first} != {last}")
複製程式碼

前面我提到過多重賦值對於可迭代物件的大小以及解包的大小是非常嚴格的,在復讀解包中我們也可以利用這點嚴格控制可迭代物件的大小

這麼寫可以正常執行:

>>> points = ((1, 2), (-1, -2))
>>> points[0][0] == -points[1][0] and points[0][1] == -point[1][1]
True
複製程式碼

這種看起來 bug 的程式碼也能正常執行:

>>> points = ((1, 2, 4), (-1, -2, 3), (6, 4, 5))
>>> points[0][0] == -points[1][0] and points[0][1] == -point[1][1]
True
複製程式碼

這種寫法也能執行:

>>> points = ((1, 2), (-1, -2))
>>> (x1, y1), (x2, y2) = points
>>> x1 == -x2 and y1 == -y2
True
複製程式碼

但是這樣不行:

>>> points = ((1, 2, 4), (-1, -2, 3), (6, 4, 5))
>>> (x1, y1), (x2, y2) = points
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ValueError: too many values to unpack (expected 2)
複製程式碼

在給變數多重賦值時我們其實也對可迭代物件做了一次特殊的斷言(assert)。因此多重賦值既能讓別人更容易理清你的程式碼(因為有著更好的程式碼可讀性),也能讓電腦更好地理解你的程式碼(因為對程式碼進行了確認保證了正確性)。

使用 list 型別語法

在前文我提到的多重賦值都用的是元組型別的語法(tuple-like),但其實多重賦值可以用於任何可迭代物件。而這種類似元組的語法也使得多重賦值常被稱為“元組解包”。而更準確地來說,多重賦值應該叫做“可迭代物件解包”。

前文中我還沒有提到過,多重賦值可以寫成 list 型別的語法(list-like)。

下面是一個應用 list 語法的最簡單多重賦值示例:

>>> [x, y, z] = 1, 2, 3
>>> x
1
複製程式碼

這種寫法看起來很奇怪。為什麼在元組語法之外還要允許這種 list 語法呢?

我也很少使用這種特性,但它在一些特殊情況下能讓程式碼更加簡潔

舉例,假設我有下面這種程式碼:

def most_common(items):
    return Counter(items).most_common(1)[0][0]
複製程式碼

我們用心良苦的同事決定用深度多重賦值將程式碼重構成下面這樣:

def most_common(items):
    (value, times_seen), = Counter(items).most_common(1)
    return value
複製程式碼

看到賦值語句左側的最後一個逗號了嗎?很容易會將它漏掉,而且這個逗號讓程式碼看起來不倫不類。這個逗號在這段程式碼中是做什麼事的呢?

此處的尾部逗號其實是構造了一個單元素的元組,然後對此處進行深度解包。

可以將上面的程式碼換種寫法:

def most_common(items):
    ((value, times_seen),) = Counter(items).most_common(1)
    return value
複製程式碼

這種寫法讓深度解包的語法更加明顯了。但我更喜歡下面這種寫法:

def most_common(items):
    [(value, times_seen)] = Counter(items).most_common(1)
    return value
複製程式碼

賦值中的 list 語法讓它更加的清晰,可以明確看出我們將一個單元素可迭代物件進行了解包,並將單元素又解包並賦值給 valuetimes_seen 物件。

當我看到這種程式碼時,可以非常確定我們解包的是一個單元組 list(事實上程式碼做的也正是這個)。我們在此處用了 collections 模組中的 Counter 物件。Counter 物件的 most_common 方法可以讓我們指定返回 list 的長度。在此處我們將 list 限制為僅返回一個元素。

當你在解包有很多的值的結構(比如說 list)或者有確定個數值的結構(比如說元組)時,可以考慮用 list 語法來對這些類似 list 的結構進行解包,這樣能讓程式碼更加具有“語法正確性”。

如果你樂意,還可以用對類 list 結構使用 list 語法解包時應用一些 list 的語法(常見的例子為在多重賦值時使用 * 符號):

>>> [first, *rest] = numbers
複製程式碼

我自己其實不常用這種寫法,因為我沒有這個習慣。但如果你覺得這種寫法有用,可以考慮在你自己的程式碼中用上它。

結論:當你在程式碼中用多重賦值時,可以考慮在何時的時候用 list 語法來讓你的程式碼更具自解釋性並更加簡潔。這有時也能提升程式碼的可讀性。

不要忘記這些多重賦值的用法

多重賦值可以提高程式碼的可讀性與正確性。它能使你程式碼更具自描述性,同時也可以對正在進行解包的可迭代物件的大小進行隱式斷言。

據我觀察,人們經常忘記多重賦值可以替換硬編碼索引,以及替換硬編碼拆分(用 * 語法)。深度多重賦值,以及同時使用元組語法和 list 語法也常被忽視。

認清並記住所有多重賦值的用例是很麻煩的。請隨意使用本文作為你使用多重賦值的參考指南。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章