Effective Python 中文版Effective Python 中文版

這是《Effective Python 中文版|寫出良好 Python 程式的 90 個具體做法》筆記的第 1 篇,你可以當作是一則重點整理,加上我個人的開發經驗與心得。

毫無疑問,這又是一本關於「Clean Code in Python」的書——而且很可能是評價最高的一本。

本書由 90 個具體做法所組成,搭配大量實例,助你寫出更 Pythonic 的程式碼。

內容有初階也有進階(比如 metaclass),我會挑選書中我認為「重要且實用」的部分作為分享準則。簡言之,不會太深——但很可能是常常被忽略或低估的部分。

本文整理自書中的「做法 6:優先選用多重指定的拆分而非索引」。

附帶一提,作者已經把書中的程式碼範例都放在 effectivepython 這個 repo,這對於我這種要寫筆記、心得文章的人,真是再方便不過。


本文主旨

本文的主旨只有一個:試圖說服你,不要在程式碼中使用索引取值——盡可能改用拆分(unpacking)來代替索引。

這裡的索引取值,指的是像items[2]這樣,以序列(sequence)的數字索引來取值的情況。

如果程式碼只有你一個人撰寫與維護,且日後也是如此,或許未必要堅持這個原則。

但如果涉及多人協作,未來還有交接與維護需求,那麼避免索引取值,肯定能提升程式的可讀性,同時更容易維護。

索引取值的問題

索引取值的根本問題,正是基於上述考量:

  1. 可讀性差:光憑items[2],我們不知道 2 代表什麼。
  2. 容易出錯:索引值的表意性弱,容易誤取,這可能導致程式崩潰。
  3. 維護困難:今天你還記得 2 是什麼意思,半年後就不一定了。

就我的經驗,索引取值很常和 for 迴圈一起使用,因為它們都是爬取資料的常見手段,而且是很「手刻」的那種——即完全不通用。

一般而言,爬取資料的程式碼,對於非實際開發者,往往已經很不容易閱讀。

如果再加上索引取值,容易讓人倍感困惑

為索引值寫註解,是一個折衷辦法,但依舊不是最好的那個。


拆分(unpacking)

使用拆分(unpacking)才是更好的做法。

拆分是指將 sequence、iterable 中的多個元素,一次性賦值給多個變數的操作

這個方式能提高可讀性,避免使用神秘的數字索引,讓你的程式碼更加清晰明瞭。

拆分的基本用法如下:

1
2
a, b, c = [1, 2, 3]  # sequence unpacking
x, y, z = {4, 5, 6} # iterable unpacking

索引 vs 拆分

拆分相對於索引取值,對可讀性有著明顯的進步。

我們看書中的例子,這是索引取值:

1
2
3
4
5
# Example 2
item = ('Peanut butter', 'Jelly')
first = item[0]
second = item[1]
print(first, 'and', second)

這是拆分:

1
2
3
4
# Example 4
item = ('Peanut butter', 'Jelly')
first, second = item # Unpacking
print(first, 'and', second)

拆分顯然更簡潔、更直接。

雖然最後都是賦值給了變數firstsecond,但少了中間的索引取值,程式碼中的「視覺雜訊」也減少了——這很重要。


拆分在協作中的價值

平心而論,索引取值對於撰寫程式的人而言,往往是比較輕鬆的做法,所以我們更偏愛用索引。

但對於閱讀程式碼的人,就有點辛苦了。

推薦使用拆分的目的很簡單——避免透過索引取值再賦值給變數,而是直接賦值給多個變數,減少中間流程,也減少視覺雜訊。

更別說,有些索引取值「並不會」再重新賦值給新變數,這類情況更應該使用拆分。(詳見最後「我的實作」)

明確的意圖

除此之外,拆分還可以讓你的「程式意圖」更加明確!比如:

1
member_names = [member[1] for member in members]

這樣的寫法容易讓我有兩個疑問(這是我自己寫過的程式碼):

  1. 索引 0 是什麼?完全不需要嗎?我可以忽略嗎?
  2. 索引有 2 或 3 嗎?(即member有更多元素嗎?——我們無法確定)

這些不確定因素,讓你的程式意圖變得模糊。雖然自己清楚,但對於程式碼的閱讀者,則是一種不必要的「認知負擔」。

這些認知負擔不斷累積,將導致人們需要花更多時間,才能讀懂你的程式碼。

而拆分的寫法,可以讓情況明確許多:

1
member_names = [name for _, name in members]

我們透過拆分與變數大聲宣布:member 總共只有兩個元素,而且我只要第二個!


進階拆分

但我就是只需要其中一個變數,怎麼辦?就像前述的items[2]

以拆分取代索引,我們需要一些「配套措拖」,才能夠用得方便、滑順。

其中最常用的,就是_*

善用_來進行拆分

很簡單,把不要的內容丟給_變數。

1
_, _, last = ["apple", "banana", "cherry"]

這樣寫最大的好處,是明確告訴讀者:我不需要前兩個元素,所以直接丟給_

善用*進行拆分

如果你想要的元素在第一個最後一個,而且拆分的元素較多,那用*會更加方便。

使用星號(*收集其餘的值:

1
2
3
first, *middle, last = [1, 2, 3, 4, 5]

print(middle) # [2, 3, 4]

其中middle的變數內容,會是一個擁有 0 到多個元素list

結合_*

第一個例子只有 3 個元素,而第二個例子有 5 個元素。

如果有更多,比如 10 個,而我們依舊只需要最後一個,難道要寫一堆_

當然不!這時可以結合兩者,重構第一個例子:

1
*_, last = ["apple", "banana", "cherry"]

是不是非常簡潔?

我的看法

_很常用,而*拆分我比較少用(拆分 3 個以上的情況對我相對少見)。

無論如何,只要有數字索引出現,我們第一時間就要想到「能否用拆分來取代?」——通常可以!

這是一個很好的習慣,相信你的同事會感謝你。


書中範例欣賞

我們看一段書中的程式碼,比較索引取值和拆分(其實還用了enumerate),兩者的差別究竟能有多大

1
2
3
4
5
6
7
8
9
10
11
12
snacks = [('bacon', 350), ('donut', 240), ('muffin', 190)]

# Example 8 使用索引取值
for i in range(len(snacks)):
item = snacks[i]
name = item[0]
calories = item[1]
print(f'#{i+1}: {name} has {calories} calories')

# Example 9 使用拆分(加上 enumerate)
for rank, (name, calories) in enumerate(snacks, 1):
print(f'#{rank}: {name} has {calories} calories')

不過,不可否認,上面的拆分寫法需要你認真閱讀才能讀懂,因為它有點複雜。

但即使不了解其中的細節,也能輕易看出,後者更加優雅。

而且大部分需要你拆分的情境,都比這個例子單純。例如以下的實作。

我的實作

在剛發表的〈31,打造新版「熱門文章排名」〉一文的專案實作中,我用拆分重構了原有的程式碼(內容已略有不同,以下為早前版本)。

這段程式把從 GA4 整理出的網站流量資料,以一定格式寫入 Markdown 文件中。

舊版,使用索引取值:

1
2
3
4
5
6
7
8
rank = 1
for row in page_views:
if row[0] in ignored_paths:
continue
f.write(f'{rank}. [{row[1]}]({row[0]})\n')
rank += 1
if rank > 10:
break

如前所述,索引取值對於程式撰寫者來說,可能是最直覺的做法,也相對輕鬆。所以我一開始也是這樣寫的,但真的不太好讀XD

row[0]row[1]到底是什麼?不清楚——有認知負擔。

以拆分重構

改用拆分重構,並以_省略不需要的變數

1
2
3
4
5
6
7
8
rank = 1
for path, title, _ in page_views:
if path in ignored_paths:
continue
f.write(f'{rank}. [{title}]({path})\n')
rank += 1
if rank > 10:
break

這個例子,比上述所有例子都更能凸顯拆分的價值。

因為row[0]row[1]最終都沒有再被重新賦值給另一個變數,而是直接寫入文件。

使用索引,你幾乎看不出row[0]row[1]究竟代表什麼——只能靠猜。

改用拆分,我們能知曉被寫入的是titlepath,而不是神秘的索引取值


結語

總的來說,我覺得拆分是一個大大被低估異常實用的技巧。

善用拆分能讓你的程式碼更加 Pythonic。

從今天開始,在 for 迴圈中,就別再索引取值了吧!