Python 功力提升的樂趣Python 功力提升的樂趣

這是《Python 功力提升的樂趣:寫出乾淨程式碼的最佳實務》筆記的第 1 篇,你可以把它當作是一則重點整理,加上(大量)我個人的開發經驗與心得。

系列:Python 功力提升的樂趣

  1. 《Python 功力提升的樂趣》筆記(一)Black、命名、壞味道
  2. 《Python 功力提升的樂趣》筆記(二)Pythonic、行話、陷阱
  3. 《Python 功力提升的樂趣》筆記(三)函式、註解、docstring

本文目錄

  1. 系列緣起
  2. 只講重點
  3. 第 3 章:使用 Black 進行程式碼格式化
  4. Why Black?
  5. 第 4 章:選用易懂的命名
  6. 史上最糟的變數名稱——data
  7. 史上「第二糟」的變數名稱?
  8. 3 個代表性案例
  9. 第 5 章:找出程式碼的異味
  10. 程式碼異味的迷思
  11. 註解是不需要的?

系列緣起

我會用數篇文章,把本書的重點一一勾勒出來。之所以願意這麼做,主要有兩個原因。

要真正落實 Clean Code,著實不易

雖然任何一種程式語言都能夠寫出堪稱簡潔的程式碼,但 Python 可能是最接近「自然語言」的那個,從這個角度看,Python 的 Clean Code 或許是最美的。

但因為 Python 的自由度,往往實際上的程式碼品質都不是那麼理想——你甚至希望它不要那麼自由。

我認為這本書被低估了

意思是它似乎不那麼有名。

當然,講 Clean Code Python 的書也不只這本,但我認為本書是最適合起手的一本。儘管它面向的是 Python 新鮮人,但不得不承認,即使已經寫了一、兩年 Python 的開發者,對於書中建議的落實,往往都還有所不足。

儘管 Python 是自由的,但 Clean Code 卻沒有那麼自由——我們需要這本書,而且要好好實踐它。

只講重點

雖然是偏筆記的形式,但我也不想重複那些基本知識,所以只講重點,視需要給予書中的細節。

重要的是,我們「知道」有這件事。這是一種「嗅覺」,你也可以說,是對於「程式碼壞味道」的嗅覺。

儘管在不久的將來,我們產出的程式碼,可能有相當部分是 AI 幫我們寫的,但那依舊不妨礙我們對於 Clean Code 的追求——畢竟你還是要審視 AI 的產出,並適時修正與完善。


本文整理書中第 3-5 章重點,前 2 章我覺得重要性普通就略過了。

第 3 章:使用 Black 進行程式碼格式化

Formatter——格式化器——想必大家都很熟悉了,我也有不少文章提過(雖然主要講 yapf),有興趣可以查看「Code Formatting」這個 tag。與下列兩篇與 Black 有關的文章:

提 formatter 肯定要提 PEP 8,畢竟所有的 Python formatter 都是以 PEP 8 為基礎,再加上一些慣例或額外規則。

所以這章有一定的篇幅與範例都是在講 PEP 8,這裡只提一個我覺得比較容易被忽略(但我認為重要)的點——垂直間距(空行)。

至於要用哪個格式化器,本書推薦 Black。我認為 yapf 也不錯。

垂直間距

這名稱好學術,講白了就是程式碼之間的「空行」。

空行是一種藝術。

就跟文章的分段一樣,程式碼的空行太多會流於鬆散,而太少則過度緊迫。如何拿捏,需要你對美感與程式可讀性有幾分「直覺」才行。

所幸,PEP 8 有基本規則:

  1. 函式之間:2 行空行。
  2. 類別之間:2 行空行。
  3. 類別的方法之間:1 行空行。

這 3 種空行,格式化器能幫你自動完成。另外,空 3 行或在上述情境「以外」的地方空 2 行,都是不允許的😷

格式化器會一併修正(linter 也會提醒),不會讓你自由發揮。

這麼說來,真正難的,還是什麼時候要空 1 行。我想這就是空行藝術的所在了。

Why Black?

本書推薦 Black 的理由,就跟許多大型 Python 開源專案採用 Black 的理由一樣——因為它的規則最硬,大家只能乖乖遵守。書中是這麼說的:

如果您正在與其他人一起進行某個程式專案,只要使用 Black 工具就能立即解決所有關於格式化程式碼的許多爭論與協調。

真的,只要協作的人愈多,我們對程式碼「一致性」的需求就愈高,優先權甚至高過了典型的慣例與規則。比如 Google 要求內部 Python 專案中的程式碼縮排為 2 個空格而不是常見的 4 個。

2 空格的縮排既不符合 PEP 8,也並非常見慣例,是基於團隊與專案的一致性要求

而一致性的本質就是——這沒得商量

一致性的好處

沒得商量,就沒必要爭執,而且誰也不能怪誰,因為這是 Black 規定的!

不過,如同我之前的文章介紹,大多數時候我還是會變更下列兩個 Black 預設值:

  1. 單行字元上限。
  2. 停用強制雙引號。

「一致性」有在團隊協作中多重要?書上的這段話,無疑是很好的註解:

Python 的語法在風格樣式上更有彈性。如果您編寫的程式別人看不到,那怎麼樣寫都可以,但是軟體開發工作大都是共同協作的,無論與其他人一起在專案上工作,或是請經驗豐富的開發專家來審查您完成的程式,把程式碼格式化為大家公認的風格樣式是很重要的。

可不是嗎?


第 4 章:選用易懂的命名

命名之難,是因為你總是要用很心才能命出好名字。而且情境千變萬化,命名有原則,卻沒有太多捷徑。

檢查風格樣式,機器可以代勞,但命名就不行了——那是以前!

現在有了 AI,只要你願意讓它看看你的程式碼,要求它「請幫我找出不恰當的命名」並接受它的建言,我相信肯定很有幫助。

雖然我們通常不想面對批評——哪怕是來自 AI 的批評。我懂,這是人之常情。包括我自己,也會覺得有點難受。

但你的 code 會因此變得更 robust,這是值得的。

兩種不佳的命名模式

一、prefix,命名的前綴

為類別中的屬性命名,重複類別名稱作為前綴,比如在 Cat 類別中,屬性 weight 又命名為 cat_weight。這樣做是冗餘的。

先別說你不會XD,很多時候人都是健忘的。尤其當這個類別是 ORM 中的 Model 時,特別容易!

而且冗餘的前綴不一定只發生在類別,有時模組中的變數命名,也會再度重複模組名稱作為前綴。

二、在名稱中以「循序數字」當作後綴

比如payment1payment2payment3

我真的很討厭!因為這樣的命名缺乏表意性

有時候會在 pytest fixture 看到同事這樣寫,我會建議改成比較「能表現個體特質」的命名。

比如payment1改成payment_with_credit_cardpayment2改成invalid_payment等等。除非它們之間真的沒有任何區別(通常都有區別,但懶得一一想名字),就只是需要複數個同類型的物件

第三種不佳的命名模式——型別後綴

除了書中的兩種,還有一種我個人也覺得不妥的命名模式——型別後綴

也就是在變數的最後加上型別,比如name_strage_int

你未必很常看到這樣的命名,因為大部分時候並不需要。但是,在某些場合,當開發者想要強調某個變數的型別時,就會這麼做。

比如 code review 時,我最常見到的「型別後綴」命名就是:

1
response_dict = response.json()

這樣寫似乎有其道理,但我仍不推薦,理由有二:

  1. 型別後綴會讓語意變得混亂:比如本來很清楚的is_valid,加上後綴變成is_valid_bool,反而讓人不知所云。
  2. 現在已經有了 type hints,可以用來強調型別。前述例子,你想強調它是一個布林值,就可以這樣寫:is_valid: bool

所以,我們很難再找到充分理由,來使用型別後綴。

不要使用內建名稱

意即不要使用 Python 的保留字。理由可想而知,因為它會覆蓋原來的內建物件,造成程式的錯誤與混亂。

那真的需要怎麼辦?一個常見的做法是加上_作為後綴。比如type_


史上最糟的變數名稱——data

不瞞您說,萬事萬物都是「data」。

在實務中,把變數命名為 data 的情況還真不算罕見——但這是一種極為糟糕的命名,因為它缺乏表意性

書中的這段話說得非常好(我稍重組了一下原句):

將變數命名為 data,就像把你的狗命名為 Dog。

簡言之,執意將變數命名為 data,需要非常堅實的理由——通常不存在這樣的理由。

少數的例外

好啦,我能理解將變數命名為 data 的情境,就是當呼叫的函式參數名稱為 data 時,比如常見的:

1
2
3
4
5
6
7
data = {
'name': 'John',
'age': 18,
}
# 使用requests套件發送POST請求
url = 'https://example.com/api'
response = requests.post(url, data=data)

總來的說,我覺得下面三種情境,使用 data 作為變數名稱都尚可接受

  1. 函式參數名稱就是 data。所以引數叫作 data,也算合理。
  2. 該變數的生命週期很短——我絕對不會將任何長生命週期的變數命名為 data。上述例子中,data 的生命週期就只有寥寥幾行,所以我覺得還可以。
  3. HTTP response 的欄位名稱就是 data。這種情況下,我也會考慮使用 data 作為變數名稱,因為這樣可以保持一致性

但一般而言,我還是會盡可能避免使用 data 命名變數,這是為了養成好習慣


史上「第二糟」的變數名稱?

這部分不是書中的內容,而是我自己的心得。

當我遵守了好習慣,盡可能避免在程式碼中使用 data 命名後,本以為能從此過上幸福快樂的生活,但我錯了!

因為沒多久後我就發現,還有一個比 data 更駭人聽聞的變數名稱——result

和 data 相比,其糟糕的程度,簡直有過之而無不及。畢竟萬事萬物都有「result」,因果循環、生生不息,而且這個命名更加抽象,令人不禁想問:

到底是什麼結果?

result 命名的濫用,在「儲存呼叫函式的結果」時最為常見,難怪到處都是 result。

3 個代表性案例

下面我們就舉 3 個代表性案例,來看看 result 到底有多糟糕。以及應該要用什麼名稱來取代它——基本上就是更直接、具體、精確的名稱。

一、從資料庫查詢訂單

1
2
3
4
5
6
7
# 不好的寫法
result = query_orders_from_database(customer_id)
...

# 更好的寫法
orders = query_orders_from_database(customer_id)
...

第一種典型的情況是,明明有更具體的名稱可以使用,卻偏偏用了籠統的 result。

這樣的寫法,讓人看了一頭霧水,不確定 result 到底是指什麼。類似的例子還有求平均值、性別、數量等等,太多了,明顯都可以用更具體的名稱來取代。

事實上,無論是這 3 種情況中的哪一種,會叫 result,通常是因為懶得想名字

二、檢查用戶是否有管理員權限

1
2
3
4
5
6
7
8
9
# 不好的寫法
result = check_user_is_admin(user_id)
if result:
...

# 更好的寫法
is_admin = check_user_is_admin(user_id)
if is_admin:
...

我認為這是最重要的一種:當函式的回傳是一個布林值,代表狀態成功與否

這個時候,我們應該要使用充滿了「布林味」的變數名稱,比如has_permissionis_validis_success等等。而不是result

三、使用 requests 發出 HTTP 請求

終於來你最常見到的情境了,使用requests(或httpx)發出 HTTP 請求,並將回傳的結果存入 result。

1
2
3
4
5
6
7
8
9
10
11
import requests

# 不好的寫法
result = requests.get("https://api.example.com/data")
if result.status_code == 200:
process_data(result.json())

# 更好的寫法
response = requests.get("https://api.example.com/data")
if response.status_code == 200:
process_data(response.json())

在這個例子中,使用 response 作為變數名稱會比使用 result 更加清晰和具描述性。

當其他開發者(或是你自己)看到這段程式碼時,會立即明白這個變數存儲的是一個 HTTP response,而不是某個抽象的「結果」。

這個常見例子還有一個常見的「變形」:

1
2
3
res = requests.get("https://api.example.com/data")
if res.status_code == 200:
process_data(res.json())

這麼巧!response 和 result 兩者恰好都是以 res 為前綴,只要命名為res就天下太平了——才怪。

有什麼比 result 更讓人想握緊拳頭的變數名稱嗎?恐怕就是 res 了。

小結:no result

說真的,data 至少還有上述的例外情況,但關於 result,我真的想不到。

我非常確信,打算我工作的第二年起(第一年可能還懵懂無知),再就也沒有使用過 result 這個變數名稱。

一次也沒有。

所以,請原諒我,這一段的標題是「騙人」的,result 壓根不是什麼「史上第二糟」的變數名稱,它就是「全銀河系最爛」的變數名稱。

除了一口氣把它塞入人馬座 A*超大質量黑洞,我想不到更好的處理方式。


第 5 章:找出程式碼的異味

程式碼異味(code smell)指的就是程式中看起來「不對勁」,且有可能在後續引發問題的地方。

如果可以,我們當然要盡可能避免異味,至少要給予一定關注才行。

實務上,我會在覺得有明顯異味的地方留下XXX註解,闡述異味的理由,並留意後續發展。

1
2
3
# XXX PATCH 做的事情太多了,很容易發生狀態殘留問題
if request.method == 'PATCH':
...

畢竟異味不一定都會造成問題,也不是所有的異味都要被修正(因為太多了),甚至隨著後續開發,原本看起來的異味可能隨之消散或減緩——但這屬於少見的情況。大部分都是愈來愈糟,不然怎麼會有「技術債」這個詞呢?

書中所舉的異味種類很多,也都很經典,值一一細讀。不過限於篇幅,只舉其中幾個我個人認為比較有代表性的異味。

一、重複的程式碼

重複程式碼肯定不是一個錯誤(error),卻很可能造成後續維護上的困擾。典型的困境就是改一個地方結果多處也要跟著一起改時,我稱之為程式碼的「同步」問題。

需要手動同步,就是壞味道。

通常我們會將這部分抽取出來,讓程式只需要改動一處,就能在所有引用處生效。

不過,書中也說了,重複一、兩次通常沒有關係;而重複三、四次時,就要好好考慮了是否重構或刪除。

而且有些時候,重複反而是比較簡潔的做法——無腦但直觀,可讀性最高。

確實,單憑形式上的「是否重複」來判斷要不要修改程式碼,往往是不足夠的。還要看使用的場景。比如前面說的那種會發生同步問題的重複,哪怕只有一次,我也會特地抽出來。

顯然,這需要一些經驗,與一次又一次的思考。

二、魔術數字

有些數字我們很容易知曉其含意,比如 3.14,而有些則否。

1
expiration = time.time() + 604800

這 604800 究竟代表什麼?我們肯定需要停下來思考,可能需要花一些時間才能想出來。這樣的寫法顯然太不直觀。

重點是,你縱使猜得到,也無法 100% 確定

一種改進方式是加上註解

1
expiration = time.time() + 604800  # 一星期後失效

原來 604800 代表的是一週的秒數!

這樣寫好很多,但書中建議我們採取更經典的做法——使用常數

1
2
3
4
5
6
SECONDS_PER_MINUTE = 60
SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR
SECONDS_PER_WEEK = 7 * SECONDS_PER_DAY

expiration = time.time() + SECONDS_PER_WEEK # 一星期後失效

常數是在程式中使用的固定值,原則上是不能被改變的。在使用常數時,我們應該採取有描述性的命名,這樣有助於提高程式的可讀性和可維護性。

常數 + 有描述性的命名,就是應對魔術數字的絕佳手段。

比如 Django REST Framework(DRF)中的status

1
return JsonResponse(context, status=status.HTTP_200_OK)

除了 200,常見的還有:

  • status.HTTP_400_BAD_REQUEST
  • status.HTTP_404_NOT_FOUND

我們當然可以直接寫status=200400404,看起來也很直觀,但對於那些我們沒那麼熟悉的 HTTP 狀態碼,比如 509,則又需要陷入停頓與思考。

而使用常數的話,則所有成員都會更加一視同仁

1
status.HTTP_509_BANDWIDTH_LIMIT_EXCEEDED

三、雙層生成式

書中的例子是,你想把一個二維 list「攤平」成一維,於是寫了雙層串列生成式(List Comprehension)進行處理,像這樣:

1
2
3
4
nested_list = [[0, 1, 2, 3], [4], [5, 6], [7, 8, 9]]
flat_list = [num for sub_list in nested_list for num in sub_list]
>>> flat_list
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

這樣的寫法雖然簡短,但可讀性極差。哪怕是 Python 老手,也很難一眼就看出來這個生成式到底在做什麼。

作者建議用雙層的 for 迴圈改寫,以增加可讀性:

1
2
3
4
5
6
7
8
nested_list = [[0, 1, 2, 3], [4], [5, 6], [7, 8, 9]]
flat_list = []
for sub_list in nested_list:
for num in sub_list:
flat_list.append(num)

>>> flat_list
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

確實。但說真的,可以的話,我覺得盡量也不要使用雙層迴圈。

其中的原因,容我直接引用臉書舊貼文

程式碼裡有:一層迴圈是壞味道,兩層迴圈是罪惡,三層迴圈是罪大惡極
 
因為這些都是程式中的「手工藝」,乏缺架構思維,除了當下用起來很「方便」,日後更可能是難以維護,畢竟正常人無法一眼就看出第二、三層迴圈到底在做什麼(仔細看也一樣啦!)
 
此乃典型的技術債,而且是很廉價的那種

雙重迴圈有時是不得已——總會有這樣的時候,但我們仍必須視之為罪惡

程式碼異味的迷思

這是第 5 章的最後一節,但我覺得特別精彩,值得獨立拿出來講。

書中所謂「程式碼異味的迷思」,指的是作者不認為那是異味,但常常會被視為異味的情況。

書中列舉了 5 個迷思(作者不認同的命題),分別如下:

  1. 迷思:函式末尾應該只有一個 return
  2. 迷思:函式中最多應該只有一個 try 陳述句
  3. 迷思:旗標(flag)引數是不好的
  4. 迷思:全域變數是不好的
  5. 迷思:注釋是不需要的

有些你可能也不覺得是迷思,沒關係,這裡我只打算討論第 5 個。

註解是不需要的?

我們可能聽過類似「好的程式碼應該透過優秀的命名與明確的意圖來自我表達、好的程式不需要註解」等等論述。

這堪稱是一種理想,乍聽也有幾分道理,但事實又如何呢?

在我看來,不寫任何註解或 docstring,卻期待程式非常好讀,幾乎是不可能的!

註解或 docstring 使用自然語言,表達能力與彈性遠遠超過受限於「用詞精簡且著重於功能面」的程式碼。

我在「Python docstring 之我見」也表述過類似的看法:

在我看來,無論程式寫得如何簡潔易讀,對一些比較複雜的函式或類別而言,docstring 終究是不可少的。因為文字的詮釋能力和程式碼相比,絕不在同一個層次,相信這也是為何 docstring 會有屬於自己的獨立 PEP 加以規範的理由。

不知道你怎麼看?我相信,寫好 docstring,是簡潔程式碼不可或缺的一環,更是優秀軟體工程師的必備條件——我對此深信不疑。

註解與 docstring 的價值

有些事很重要,開發者在維護與修改程式時必須知道,這些事往往難以直接藉由程式碼透露出來,比如函式的「使用方式」與「注意事項」,這時註解就派上用場了。

退萬步言,複雜的程式光要看懂就很不容易。而良好的註解與 docstring 可以大幅縮短閱讀理解的時間,你的隊友一定會感謝你。

尤其在團隊開發時,註解與 docstring 是溝通的重要媒介,有時甚至是唯一的媒介。

當然我們也不得不承認,很多註解都寫得很爛!但這不是註解的錯,而是寫的人還不夠熟練或深思。而且寫好註解也真的很難,需要大量的練習。

顯然作者也是這麼認為的:

要寫出簡潔有效的注釋並不容易,注釋就像程式碼一樣,需要重寫和多次修調才會更好。

寫好註解是同理心的表現

就像我在〈18,論軟體工程師常見的「路徑依賴」問題(上)〉中所闡述的:

寫程式若只顧自己,終究難登大雅之堂。這些程式碼看起來就像是一個人的自言自語(只要自己有完成任務就好)——它並沒有在溝通

程式碼是溝通,而註解更是。它是同理心中「換位思考」的具體表現。

而且,把註解寫好,自己也會是受益者。因為我們不可能總是記得程式的所有細節,哪怕是自己寫的程式。

優秀的註解和 docstring,讓人讀程式時心曠神怡,維護的壓力也小得多。我相信任何開發者都希望自己接手的是這樣的程式碼。

在團隊協作中,這是一種任何人都會喜愛的溫柔。