《Python 功力提升的樂趣》筆記(二)Pythonic、行話、陷阱
文章目錄
Python 功力提升的樂趣
我們繼續《Python 功力提升的樂趣:寫出乾淨程式碼的最佳實務》閱讀筆記,這是第 2 篇,你可以把它當作是一則重點整理,加上我個人的開發經驗與心得。
系列:Python 功力提升的樂趣
- 《Python 功力提升的樂趣》筆記(一)Black、命名、壞味道
- 《Python 功力提升的樂趣》筆記(二)Pythonic、行話、陷阱
第 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 | 'cat', 'dog', 'moose'] animals = [ |
這種「手刻邏輯式的寫作風格」——要什麼就寫什麼,寫完也不重構——你是否已經屢見不鮮?我們來評論一下,這段程式碼有兩個很醜(難讀)的地方。
i + 1
。這寫法顯然是為了讓計數從 1 開始——而不是預設的 0。range(len(animals))
寫法非常不直觀,多層的嵌套式呼叫讓人看了昏昏欲睡。
這兩個地方充滿了「手刻」的僵化感,只是為了最快速達到開發者「當下目的」而強行採用的一種粗糙手段——但顯然不是最好的那個。
雖然兩者都不難理解與推測,但除了寫這段程式的人,誰也無法 100% 確信作者的「意圖」——這是糙 code 的特色,它存在著大量且不必要的誤解空間。
這些不明確的誤解空間會造成別人閱讀本段程式碼時的「思考停頓」,更別說後續滋生 bug 的可能。所幸,它們是有可能被消除的。比如使用以下的enumerate
。
一言以蔽之,你只要在for
迴圈內,看到以下這樣的寫作模式,那就是典型的 Python 壞味道:
iterable[index]
(可迭代物件[物件元素的索引值]
)
迭代任何iterable
同時,又需要知道每一個元素的索引值,這毋寧是很常見的需求。
因為太常見了,所以 Python 早就幫你準備好了——就是為了這種情況而存在的——enumerate
。
同樣的需求,我們應該要這樣寫:
1 | 'cat', 'dog', 'moose'] animals = [ |
start=1
讓你不用再i+1
,也不再需要range
和len
的疊床架屋。
兩種寫法的結果是一樣的,但後者更加善用 Python 的內建特性,且優雅。
善用 Python 的自身的特性寫出優雅而簡潔的程式碼——這就是 Pythonic。
使用with
陳述式而不要手動close()
因為你很可能會忘記 close!
with
(context manager,上下文管理器)在資源管理上很有用,比如文件讀寫或資料庫連接等,可以確保即使有錯誤發生,也能正確地關閉、釋放資源。
但對於不熟悉或不常見的物件,則要注意物件是否實作了所謂的「上下文管理協定」。
這個協定要求物件必須實作以下兩個方法:__enter__
和 __exit__
。這樣物件搭配with
使用時,才能正常運作。
使用is
比較是否為None
,而不用==
細節請參考書中的詳盡說明,所幸現代 linter 都會提醒你:在比較None
、True
、False
這 3 種值時,請使用is
而非==
。
以 Pythonic 風格來運用字典
- 使用
get()
來處理沒有 key 值而出現KeyError
的情況。(不過有時候缺少 key 是一個警訊,此時不宜透過 get 讓這個警訊被忽略) - 使用
setdefault()
來處理沒有 key 值就要新增該 key 並給定一個預設值的情況。 - 使用
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 | id(12345) |
而型別則是透過type()
:
1 | type(12345) |
了解這些細節的重要理由是,每次執行程式時,物件的 id 與型別不會改變,但物件的值是可以變動的!這是可變與不可變物件的基石。
此外,如果兩個物件的 id 相同,那麼它們就是同一個物件。這將有助於你理解關於「物件的參照」與「一、Python 中的變數:是水桶還是標籤?」等議題。
可變與不可變
剛剛提到,物件中只有「值」是可以(有可能)改變的。所以依照物件的值是否可以改變,物件也分為兩大類:
- 可變物件。
- 不可變物件。
這無疑是一大經典議題,但這裡就不展開了。只要知道物件的可變與不可變,對於變數的「參照」有著巨大的影響。有興趣可以再看看「二、使用可變物件作為引數時要小心」部分。
本章還有很多術語的定義介紹,比如陳述式(statement)與表達式(expression)、iterable 與 iterator、參數與引數……等等,就請讀者自行參照書中介紹囉!
第 8 章:常見的 Python 誤解和陷阱
本章介紹基於 Python 特性的一些常見錯誤——我們一定都踩過。在此只提「為什麼這樣不好」以及如何避免這些不佳實作的核心思路。
不要在迴圈中新增或刪除「被迭代物件(比如 list)」中的元素
只要遇過一、兩次就會知道這個坑,但一段時間後,很可能會一錯再錯XD。
新增可能會造成無限迴圈(因為迭代不完),而且刪除則很可能會造成索引值與你預期內容產生錯亂的問題。
大原則就是「不要在迴圈中編輯(修改)當前被迭代的可變物件」,如果必須這麼麼做,就為它們建立一個「副本」吧!
一定要用copy.copy()
和copy.deepcopy()
來複製「可變值」
承上,既然要建立複本,就要採用穩妥的方式,而穩妥方式就是使用內建方法。
這又又又是一個「物件參照」的相關議題。下一個也是。
不要使用「可變物件」作為「預設參數」
這點你可能已經很熟悉了,因為幾乎每一本書都會強調。
我們用簡單的一段話來再次回顧,為什麼不能這麼做,by ChatGPT:
在Python 中,不應該用可變物件做預設參數(引數),因為這個可變物件在函式第一次被定義的時候就已經「固定」了。(只會建立一次)
以後每次呼叫這個函式但沒有提供這個引數的話,都會共用同一個物件。如果你在函式裡改變了這個物件,那麼下次呼叫時,這個改變就被保留下來了。這會讓程式的行為變得很難預測和控制。
看一下程式碼,這個函式正常使用時,希望你帶入一個fruit_list
作為引數——當然,從上面的「二、使用可變物件作為引數時要小心」我們知道,這也是一個不太健康的做法!
關於使用可變物件為引數,我們先不論,下面的呼叫我們都不給第二個引數,讓它只使用預設值。
我們預期的行為是:每次給定不同的水果,且不給fruit_list
引數,則產出應該都要是「以該水果為單一元素的」的 list——但事實完全不是如此。
1 | def add_fruit_to_list(fruit, fruit_list=[]): |
當「第二次」呼叫函式時,情況就不是我們要的了:
- banana_list 竟然包含了 apple!
- apple_list 竟然也被改變了!
替代方案
而替代方案想必你也知曉,那就是把預設值改設為None
,然後在函式中加上一個判斷式,比如下面的if fruit_list is None:
。
1 | def add_fruit_to_list(fruit, fruit_list=None): |
當沒有提供fruit_list
參數(引數)時,if fruit_list is None:
判斷式會成立,判斷式內通常會建立一個全新的可變物件,讓每一次呼叫都會有不同的fruit_list
可變物件,避免前述的「狀態殘留」與意料之外的錯誤。
不要以字串連接來製作字串
所謂的字串連接,就是使用+
運算子來合併多個字串。比如:
1 | greeting = 'Hello' + 'World!' |
連接兩個字串無傷大傷,但如果是這樣的話:
1 | greeting = 'Hello World!' |
就會造成效能上的低落——和下列替代方案相比,慢了近 10 倍。
因為 Python 的字串是不可變的,所以這種拼接實際上是用舊的字串建立新的字串,for 迴圈中的每一次迭代,都會建立一個新字串。
替代方案當然就是經典的 list +join
字串方法。
1 | greeting_parts = ['Hello World!'] |
tuple 中即使只有一個元素,也不要忘記加逗號
這個例子非常經典!因為不少人可能寫了超過一年的 Python,都未必知道這個特性,而在某些「意料之外」的事發生後,才赫然發現這個事實。
好,我承認第一次知道時我也很吃驚,還好我是看書知道的。
照標題的意思,單元素 tuple 應該要長這樣:("cat",)
,而不是這樣("cat")
。
為什麼?
我們先看一下 ChatGPT 怎麼說:
這是因為在 Python 中,括號(這裡指的是「小」括號)被用於多個目的,例如分組表達式。當你寫
("cat")
時,括號會被解釋為分組符號,所以這個表達式的值就是字串"cat"
,而不是包含一個元素的元組(tuple)。
如果你想要創建一個只有一個元素的元組,你需要在那個元素後面加上逗號。所以
("cat",)
才是包含一個元素的元組。逗號告訴Python你想要創建一個元組,而不僅僅是使用括號進行分組。
非常經典的講解!
就像《流暢的 Python》中說的,逗點才是 tuple 的本體XD。
這也是為什麼我們在函式中 return 複數值時,不需要為它們加上小括號,只要使用逗點分隔,return 自然就是一個 tuple。
1 | def multiple_values(): |
足見,逗點確實是定義 tuple 的關鍵。
相關文章