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

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

系列:Python 功力提升的樂趣

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

第 6 章:寫出 Pythonic 風格的程式碼

我們常常聽到 Pythonic,但如果問起它究竟意味著什麼,一時之間可能也不容易回答,畢竟它沒有一個公認的標準或定義。

不過,充利分用 Python 獨有的特性、寫作風格、語法,尤其是善用標準函式庫(不重複造輪子——因為你自己造的輪子往往不會更好、更方便、更有效),顯然是大家都認同的部分。

至於遵守上篇提到的 PEP 8,自然不在話下。如果你的變數命名使用了小駝峰式命名法(lower camel case),那麼在「形式上」就已經不太 Pythonic 了。

話說回來,本書雖然是一本優秀的 Clean Code in Python 入門書,但不知為何,書中的函式命名皆是採用小駝峰,讓我不甚理解。

但我們要知道:好孩子不要學。至於為什麼,不用問,問就是 PEP 8。

使用enumerate()而不要用range()

這真的很重要!當我 review 到下面這類 code 時,我會強烈要求必須改用enumerate重構

設想你有一個 Python list 如下:

1
animals = ['cat', 'dog', 'moose']

你想同時知道每一個元素是 list 中的第幾個內容為何,直觀上可能會這樣寫:

1
2
3
4
5
6
7
>>> animals = ['cat', 'dog', 'moose']
>>> for i in range(len(animals)):
... print(i + 1, animals[i])
...
1 cat
2 dog
3 moose

這種「手刻邏輯式的寫作風格」——要什麼就寫什麼,寫完也不重構——你是否已經屢見不鮮?我們來評論一下,這段程式碼有兩個很醜(難讀)的地方。

  1. i + 1。這寫法顯然是為了讓計數從 1 開始——而不是預設的 0。
  2. range(len(animals))寫法非常不直觀,多層的嵌套式呼叫讓人看了昏昏欲睡。

這兩個地方充滿了「手刻」的僵化感,只是為了最快速達到開發者「當下目的」而強行採用的一種粗糙手段——但顯然不是最好的那個。

雖然兩者都不難理解與推測,但除了寫這段程式的人,誰也無法 100% 確信作者的「意圖」——這是糙 code 的特色,它存在著大量且不必要的誤解空間

這些不明確的誤解空間會造成別人閱讀本段程式碼時的「思考停頓」,更別說後續滋生 bug 的可能。所幸,它們是有可能被消除的。比如使用以下的enumerate

一言以蔽之,你只要for迴圈內,看到以下這樣的寫作模式,那就是典型的 Python 壞味道

iterable[index]可迭代物件[物件元素的索引值]

迭代任何iterable同時,又需要知道每一個元素的索引值,這毋寧是很常見的需求。

因為太常見了,所以 Python 早就幫你準備好了——就是為了這種情況而存在的——enumerate

同樣的需求,我們應該要這樣寫:

1
2
3
4
5
6
7
>>> animals = ['cat', 'dog', 'moose']
>>> for i, animal in enumerate(animals, start=1):
... print(i, animal)
...
1 cat
2 dog
3 moose

start=1讓你不用再i+1,也不再需要rangelen的疊床架屋。

兩種寫法的結果是一樣的,但後者更加善用 Python 的內建特性,且優雅

善用 Python 的自身的特性寫出優雅而簡潔的程式碼——這就是 Pythonic。


使用with陳述式而不要手動close()

因為你很可能會忘記 close!

withcontext manager,上下文管理器)在資源管理上很有用,比如文件讀寫或資料庫連接等,可以確保即使有錯誤發生,也能正確地關閉、釋放資源。

但對於不熟悉或不常見的物件,則要注意物件是否實作了所謂的「上下文管理協定」

這個協定要求物件必須實作以下兩個方法:__enter____exit__。這樣物件搭配with使用時,才能正常運作。

使用is比較是否為None,而不用==

細節請參考書中的詳盡說明,所幸現代 linter 都會提醒你:在比較NoneTrueFalse這 3 種值時,請使用is而非==

以 Pythonic 風格來運用字典

  1. 使用get()來處理沒有 key 值而出現KeyError的情況。(不過有時候缺少 key 是一個警訊,此時不宜透過 get 讓這個警訊被忽略)
  2. 使用setdefault()來處理沒有 key 值就要新增該 key 並給定一個預設值的情況。
  3. 使用collections.defaultdict來更細緻地處理預設值。

前兩者你可能已經熟悉,而第 3 種在特定情境真的很好用!以前當資料工程師,進行資料清洗時,我就很喜歡用defaultdict

關於defaultdict的用法與介紹,可直接參考這篇〈collections雜談之一 ——— dict的key值存不存在乾我屁事〉。


上面舉的種種例子,都是常見的 Python 寫作慣例,這些慣例是 Python 在設計功能時就已經詳細考慮到的常見情況。換句話說,它們也往往是最佳實踐

這樣寫,我們不敢說是必要的。只是你真的這麼做的話,熟悉 Python 的人會知道,你就是一個「內行人」。

還是那句〈Zen of Python〉中的名言:

任何問題應有一種,且最好只有一種顯而易見的解決方法。(There should be one– and preferably only one –obvious way to do it.)

說到內行人,下一章我們就來看看,內行人還需要知道哪些事情。

第 7 章:程式設計的行話

行話,在本章的情境中,指的是程式設計中的「術語」——包括不限於 Python。

本章應該算是這些重要基礎的再次強調,有一定經驗的人,可以直接跳過。但我還是介紹一些值得重新溫習的內容。

物件、值、型別、識別

物件(object)是資料的表示形式,例如數字、字串,或更複雜的資料結構,比如 list 或 set。

所有的物件都具有識別碼(id)和資料型別

這句話很重要。因為我們很常把物件的「值」(比如數字 12、字串"hello")視為物件「本身」,但實際上那只能說是物件的一個重要屬性

而物件的其它兩個重要屬性,就是 id(代表物件在當前的程式執行期間記憶體中的地址)和型別。

id 屬性的內容可以透過內建的id函式取得:

1
2
>>> id(12345)
4603344144

而型別則是透過type()

1
2
>>> type(12345)
int

了解這些細節的重要理由是,每次執行程式時,物件的 id 與型別不會改變,但物件的值是可以變動的!這是可變與不可變物件的基石。

此外,如果兩個物件的 id 相同,那麼它們就是同一個物件。這將有助於你理解關於「物件的參照」與「一、Python 中的變數:是水桶還是標籤?」等議題。

相關文章:最佳 Python 入門書——《Python 技術者們 - 練功!》心得與導讀

可變與不可變

剛剛提到,物件中只有「值」是可以(有可能)改變的。所以依照物件的值是否可以改變,物件也分為兩大類:

  1. 可變物件。
  2. 不可變物件。

這無疑是一大經典議題,但這裡就不展開了。只要知道物件的可變與不可變,對於變數的「參照」有著巨大的影響。有興趣可以再看看「二、使用可變物件作為引數時要小心」部分。

本章還有很多術語的定義介紹,比如陳述式(statement)與表達式(expression)、iterable 與 iterator、參數與引數……等等,就請讀者自行參照書中介紹囉!


第 8 章:常見的 Python 誤解和陷阱

本章介紹基於 Python 特性的一些常見錯誤——我們一定都踩過。在此只提「為什麼這樣不好」以及如何避免這些不佳實作的核心思路

不要在迴圈中新增或刪除「被迭代物件(比如 list)」中的元素

只要遇過一、兩次就會知道這個坑,但一段時間後,很可能會一錯再錯XD。

新增可能會造成無限迴圈(因為迭代不完),而且刪除則很可能會造成索引值與你預期內容產生錯亂的問題

大原則就是「不要在迴圈中編輯(修改)當前被迭代的可變物件」,如果必須這麼麼做,就為它們建立一個「副本」吧!

一定要用copy.copy()copy.deepcopy()來複製「可變值」

承上,既然要建立複本,就要採用穩妥的方式,而穩妥方式就是使用內建方法。

這又又又是一個「物件參照」的相關議題。下一個也是。


不要使用「可變物件」作為「參數預設值」

這點你可能已經很熟悉了,因為幾乎每一本書都會強調。

我們用下列兩段話(by ChatGPT)來回顧,為什麼不能這麼做

在Python 中,不應該用可變物件做預設參數(引數),因為這個可變物件在函式第一次被定義的時候就已經「固定」了。(只會建立一次

以後每次呼叫這個函式但沒有提供這個引數的話,都會共用同一個物件。如果你在函式裡改變了這個物件,那麼下次呼叫時,這個改變就被保留下來了。這會讓程式的行為變得很難預測和控制。

一個簡單的例子

參考程式碼,這個函式正常使用時,希望你帶入一個fruit_list作為引數——當然,從上面的「二、使用可變物件作為引數時要小心」我們知道,這也是一個不太健康的做法!

關於使用可變物件為引數,我們先不論。下面的呼叫我們都不給第二個引數,讓它只使用參數預設值

我們預期的行為是:每次給定不同的水果,且不給fruit_list引數,則產出應該都要是「以該水果為單一元素的」的 list——但事實完全不是如此

1
2
3
4
5
6
7
8
9
10
def add_fruit_to_list(fruit, fruit_list=[]):
fruit_list.append(fruit)
return fruit_list

apple_list = add_fruit_to_list('apple')
print(apple_list) # 輸出: ['apple']

banana_list = add_fruit_to_list('banana')
print(banana_list) # 輸出: ['apple', 'banana']
print(apple_list) # 輸出: ['apple', 'banana']

當「第二次」呼叫函式時,情況就不是我們要的了:

  1. banana_list 竟然包含了 apple!
  2. apple_list 竟然也被改變了!

替代方案

而替代方案想必你也知曉,就是把預設值改設為None,然後在函式中加上一個判斷,比如下面的if fruit_list is None:

1
2
3
4
5
6
7
8
9
10
11
def add_fruit_to_list(fruit, fruit_list=None):
if fruit_list is None:
fruit_list = []
fruit_list.append(fruit)
return fruit_list

apple_list = add_fruit_to_list('apple')
banana_list = add_fruit_to_list('banana')

print(apple_list) # 輸出: ['apple']
print(banana_list) # 輸出: ['banana']

當沒有提供fruit_list參數(引數)時,if fruit_list is None:判斷式會成立,判斷式內通常會建立一個全新的可變物件,讓每一次呼叫都會有不同的fruit_list可變物件,避免前述的「狀態殘留」與意料之外的錯誤。


不要以字串連接來製作字串

所謂的字串連接,就是使用+運算子來合併多個字串。比如:

1
greeting = 'Hello' + 'World!'

連接兩個字串無傷大傷,但如果是這樣的話:

1
2
3
greeting = 'Hello World!'
for i in range(10000):
greeting += 'one more'

就會造成效能上的低落——和下列替代方案相比,慢了近 10 倍。

因為 Python 的字串不可變的,所以這種拼接實際上是用舊的字串建立新的字串,for 迴圈中的每一次迭代,都會建立一個新字串。

替代方案當然就是經典的 list +join字串方法。

1
2
3
4
5
greeting_parts = ['Hello World!']
for i in range(10000):
greeting_parts.append('one more')

greeting = ''.join(greeting_parts)

tuple 中即使只有一個元素,也不要忘記加逗號

這個例子非常經典!因為不少人可能寫了超過一年的 Python,都未必知道這個特性,而在某些「意料之外」的事發生後,才赫然發現這個事實。

好,我承認第一次知道時我也很吃驚,還好我是看書知道的。

照標題的意思,單元素 tuple 應該要長這樣:("cat",),而不是這樣("cat")

為什麼?

我們先看一下 ChatGPT 怎麼說:

這是因為在 Python 中,括號(這裡指的是「小」括號)被用於多個目的,例如分組表達式。當你寫("cat")時,括號會被解釋為分組符號,所以這個表達式的值就是字串"cat",而不是包含一個元素的元組(tuple)。

如果你想要創建一個只有一個元素的元組,你需要在那個元素後面加上逗號。所以("cat",)才是包含一個元素的元組。逗號告訴Python你想要創建一個元組,而不僅僅是使用括號進行分組。

非常經典的講解!

就像《流暢的 Python》中說的,逗點才是 tuple 的本體XD。

這也是為什麼我們在函式中 return 複數值時,不需要為它們加上小括號,只要使用逗點分隔,return 自然就是一個 tuple。

1
2
3
4
5
def multiple_values():
return 'apple', 'banana', 42

result = multiple_values()
print(result) # 輸出: ('apple', 'banana', 42)

足見,逗點確實是定義 tuple 的關鍵。