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

2024/05/04:重新編輯全文,部分內容獨立成篇。

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

系列:Python 功力提升的樂趣

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

本文目錄

  1. 系列緣起
  2. 只講重點
  3. 第 3 章:使用 Black 進行程式碼格式化
  4. Why Black?
  5. 第 4 章:選用易懂的命名
  6. 3 個代表性案例
  7. 第 5 章:找出程式碼的異味
  8. 程式碼異味的迷思
  9. 註解是不需要的?
  10. 寫好註解是同理心的表現

系列緣起

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

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

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

但因為 Python 的「自由度」很高,現實中的 Python 程式碼品質,往往都不那麼理想——你甚至希望它不要那麼自由

我認為這本書被低估了

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

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

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

只講重點

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

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

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


本文整理書中第 3-5 章重點。

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

Formatter(格式化器)想必大家都很熟悉了,我也有不少文章提過,有興趣可以查看「Code Formatting」這個標籤下的文章。

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

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

至於要用哪一個格式化器,本書推薦 Black,我認為 yapf 也不錯。不過現在,我一律使用 Ruff。

相關文章:Python 開發:Ruff Linter、Formatter 介紹 + 設定教學

Why Black?

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

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

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

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

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

一致性的好處

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

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

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

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

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

可不是嗎?


第 4 章:選用易懂的命名

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

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

兩種不佳的命名模式

一、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,可以用來強調型別。前述例子,你想強調它是一個布林值,大可明示性地為它加上 type hint 就好:is_valid: bool

有了 type hints,我們很難再找到足夠充分的理由,再使用型別後綴來命名變數。

不要使用內建名稱

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

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


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

相關文章:我絕不用 result 作為變數名稱

不瞞您說,萬事萬物都是「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 命名變數,這是為了養成好習慣。


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

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

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

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

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

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

一、重複的程式碼

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

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

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

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

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

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

當然,這需要一些經驗,更重要的是,一次又一次的思考。

二、魔術數字

有些數字我們很容易知曉其含意,比如 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 中的status模組(原始碼):

1
return Response(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 迴圈

作者建議用雙層的 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]

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

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

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

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

解法二:使用 itertools.chain

這個例子,你可以使用 Python 標準函式庫中的 itertools.chain 來達到同樣的效果:

1
2
3
4
from itertools import chain

nested_list = [[0, 1, 2, 3], [4], [5, 6], [7, 8, 9]]
flat_list = list(chain.from_iterable(nested_list))

這樣是不是更加優雅呢?


程式碼異味的迷思

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

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

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

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

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

註解是不需要的?

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

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

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

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

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

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

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

註解與 docstring 的價值

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

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

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

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

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

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

寫好註解是同理心的表現

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

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

程式碼是溝通,而註解更是。

它是同理心中「換位思考」的具體表現。

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

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

在團隊協作中,這是一種受人喜愛的溫柔。