add: translate to zh_tw

This commit is contained in:
afunTW 2020-10-12 11:51:56 +08:00
parent f87c45f661
commit e364a27441
16 changed files with 6202 additions and 0 deletions

285
README_zh-tw.md Normal file
View File

@ -0,0 +1,285 @@
## 『Python 工匠』是什麼?
我一直覺得程式設計某種意義上是一門『手藝』,因為優雅而高效的程式碼,就如同完美的手工藝品一樣讓人賞心悅目。
在雕琢程式碼的過程中有大工程比如應該用什麼架構、哪種設計模式。也有更多的小細節比如何時使用異常Exceptions、或怎麼給變數起名。那些真正優秀的程式碼正是由無數優秀的細節造就的。
『Python 工匠』這個系列文章,是我的一次小小嚐試。它專注於分享 Python 程式設計中的一些偏 **『小』** 的東西。希望能夠幫到每一位程式設計路上的匠人。
## 文章列表
- [1. 善用變數改善程式碼質量](zh_CN/1-using-variables-well.md)
- [2. 編寫條件分支程式碼的技巧](zh_CN/2-if-else-block-secrets.md)
- [3. 使用數字與字串的技巧](zh_CN/3-tips-on-numbers-and-strings.md)
- [4. 容器的門道](zh_CN/4-mastering-container-types.md)
- [5. 讓函式返回結果的技巧](zh_CN/5-function-returning-tips.md)
- [6. 異常處理的三個好習慣](zh_CN/6-three-rituals-of-exceptions-handling.md)
- [7. 編寫地道迴圈的兩個建議](zh_CN/7-two-tips-on-loop-writing.md)
- [8. 使用裝飾器的技巧](zh_CN/8-tips-on-decorators.md)
- [9. 一個關於模組的小故事](zh_CN/9-a-story-on-cyclic-imports.md)
- [10. 做一個精通規則的玩家](zh_CN/10-a-good-player-know-the-rules.md)
- [11. 高效操作檔案的三個建議](zh_CN/11-three-tips-on-writing-file-related-codes.md)
- [12. 寫好面向物件程式碼的原則(上)](zh_CN/12-write-solid-python-codes-part-1.md)
- [13. 寫好面向物件程式碼的原則(中)](zh_CN/13-write-solid-python-codes-part-2.md)
- [14. 寫好面向物件程式碼的原則(下)](zh_CN/14-write-solid-python-codes-part-3.md)
- [15. 在邊界處思考](zh_CN/15-thinking-in-edge-cases.md)
關注我的微信公眾號,在第一時間閱讀最新文章:
<img src="https://user-images.githubusercontent.com/731266/54093209-2edced80-43d0-11e9-8e69-764f5da8b275.png" />
所有文章禁止轉載,如需轉載請透過微信公眾號聯絡我。
## 詳細內容
### 1. [善用變數改善程式碼質量](zh_CN/1-using-variables-well.md)
* 如何為變數起名
* 1 - 變數名要有描述性,不能太寬泛
* 2 - 變數名最好讓人能猜出型別
* 『什麼樣的名字會被當成 bool 型別?』
* 『什麼樣的名字會被當成 int/float 型別?』
* 其他型別
* 3 - 適當使用『匈牙利命名法』
* 4 - 變數名儘量短,但是絕對不要太短
* 使用短名字的例外情況
* 5 - 其他注意事項
* 更好的使用變數
* 1 - 保持一致性
* 2 - 儘量不要用 globals()/locals()
* 3 - 變數定義儘量靠近使用
* 4 - 合理使用 namedtuple/dict 來讓函式返回多個值
* 5 - 控制單個函式內的變數數量
* 6 - 及時刪掉那些沒用的變數
* 7 - 能不定義變數就不定義
* 結語
### 2. [編寫條件分支程式碼的技巧](zh_CN/2-if-else-block-secrets.md)
* 最佳實踐
* 1 - 避免多層分支巢狀
* 2 - 封裝那些過於複雜的邏輯判斷
* 3 - 留意不同分支下的重複程式碼
* 4 - 謹慎使用三元表示式
* 常見技巧
* 1 - 使用“德摩根定律”
* 2 - 自定義物件的“布林真假”
* 3 - 在條件判斷中使用 all() / any()
* 4 - 使用 try/while/for 中 else 分支
* 常見陷阱
* 1 - 與 None 值的比較
* 2 - 留意 and 和 or 的運算優先順序
* 結語
* 註解
### 3. [使用數字與字串的技巧](zh_CN/3-tips-on-numbers-and-strings.md)
* 最佳實踐
* 1 - 少寫數字字面量
* 使用 enum 列舉型別改善程式碼
* 2 - 別在裸字串處理上走太遠
* 3 - 不必預計算字面量表達式
* 實用技巧
* 1 - 布林值其實也是“數字”
* 2 - 改善超長字串的可讀性
* 當多級縮排裡出現多行字串時
* 3 - 別忘了那些 “r” 開頭的內建字串函式
* 4 - 使用“無窮大” float("inf")
* 常見誤區
* 1 - “value += 1” 並非執行緒安全
* 2 - 字串拼接並不慢
* 結語
### 4. [容器的門道](zh_CN/4-mastering-container-types.md)
* 底層看容器
* 寫更快的程式碼
* 1 - 避免頻繁擴充列表/建立新列表
* 2 - 在列表頭部操作多的場景使用 deque 模組
* 3 - 使用集合/字典來判斷成員是否存在
* 高層看容器
* 寫擴充套件性更好的程式碼
* 面向容器介面程式設計
* 常用技巧
* 1 - 使用元組改善分支程式碼
* 2 - 在更多地方使用動態解包
* 3 - 使用 next() 函式
* 4 - 使用有序字典來去重
* 常見誤區
* 1 - 當心那些已經枯竭的迭代器
* 2 - 別在迴圈體內修改被迭代物件
* 總結
* 系列其他文章
* 註解
### 5. [讓函式返回結果的技巧](zh_CN/5-function-returning-tips.md)
* 程式設計建議
* 1 - 單個函式不要返回多種型別
* 2 - 使用 partial 構造新函式
* 3 - 丟擲異常,而不是返回結果與錯誤
* 4 - 謹慎使用 None 返回值
* 1 - 作為操作類函式的預設返回值
* 2 - 作為某些“意料之中”的可能沒有的值
* 3 - 作為呼叫失敗時代表“錯誤結果”的值
* 5 - 合理使用“空物件模式”
* 6 - 使用生成器函式代替返回列表
* 7 - 限制遞迴的使用
* 總結
* 附錄
### 6. [異常處理的三個好習慣](zh_CN/6-three-rituals-of-exceptions-handling.md)
* 前言
* 三個好習慣
* 1 - 只做最精確的異常捕獲
* 2 - 別讓異常破壞抽象一致性
* 3 - 異常處理不應該喧賓奪主
* 總結
* 附錄
### 7. [編寫地道迴圈的兩個建議](zh_CN/7-two-tips-on-loop-writing.md)
* 前言
* 什麼是“地道”的迴圈?
* enumerate() 所代表的程式設計思路
* 建議1使用函式修飾被迭代物件來最佳化迴圈
* 1 - 使用 product 扁平化多層巢狀迴圈
* 2 - 使用 islice 實現迴圈內隔行處理
* 3 - 使用 takewhile 替代 break 語句
* 4 - 使用生成器編寫自己的修飾函式
* 建議2按職責拆解迴圈體內複雜程式碼塊
* 複雜迴圈體如何應對新需求
* 使用生成器函式解耦迴圈體
* 總結
* 附錄
### 8. [使用裝飾器的技巧](zh_CN/8-tips-on-decorators.md)
* 前言
* 最佳實踐
* 1 - 嘗試用類來實現裝飾器
* 2 - 使用 wrapt 模組編寫更扁平的裝飾器
* 常見錯誤
* 1 - “裝飾器”並不是“裝飾器模式”
* 2 - 記得用 functools.wraps() 裝飾內層函式
* 3 - 修改外層變數時記得使用 nonlocal
* 總結
* 附錄
### 9. [一個關於模組的小故事](zh_CN/9-a-story-on-cyclic-imports.md)
* 前言
* 一個關於模組的小故事
* 需求變更
* 解決環形依賴問題
* 小 C 的疑問
* 總結
* 附錄
### 10. [做一個精通規則的玩家](zh_CN/10-a-good-player-know-the-rules.md)
* 前言
* Python 裡的規則
* 案例:從兩份旅遊資料中獲取人員名單
* 第一次蠻力嘗試
* 嘗試使用集合最佳化函式
* 對問題的重新思考
* 利用集合的遊戲規則
* 使用 dataclass 簡化程式碼
* 案例總結
* 其他規則如何影響我們
* 使用 `__format__` 做物件字串格式化
* 使用 `__getitem__` 定義物件切片操作
* 總結
* 附錄
### 11. [高效操作檔案的三個建議](zh_CN/11-three-tips-on-writing-file-related-codes.md)
* 前言
* 建議一:使用 pathlib 模組
* 使用 pathlib 模組改寫程式碼
* 其他用法
* 建議二:掌握如何流式讀取大檔案
* 標準做法的缺點
* 使用 read 方法分塊讀取
* 利用生成器解耦程式碼
* 建議三:設計接受檔案物件的函式
* 如何編寫相容二者的函式
* 總結
* 附錄
* 註解
### 12. [寫好面向物件程式碼的原則(上)](zh_CN/12-write-solid-python-codes-part-1.md)
* 前言
* Python 對 OOP 的支援
* SOLID 設計原則
* SOLID 原則與 Python
* S單一職責原則
* 違反“單一職責原則”的壞處
* 拆分大類為多個小類
* 另一種方案:使用函式
* O開放-關閉原則
* 如何違反“開放-關閉原則”
* 使用類繼承來改造程式碼
* 使用組合與依賴注入來改造程式碼
* 使用資料驅動思想來改造程式碼
* 總結
* 附錄
### 13. [寫好面向物件程式碼的原則(中)](zh_CN/13-write-solid-python-codes-part-2.md)
* 前言
* 里氏替換原則與繼承
* L里氏替換原則
* 一個違反 L 原則的樣例
* 不當繼承關係如何違反 L 原則
* 一個簡單但錯誤的解決辦法
* 正確的修改辦法
* 另一種違反方式:子類修改方法返回值法返回值)
* 分析類方法返回結果
* 如何修改程式碼
* 方法引數與 L 原則
* 總結
* 附錄
### 14. [寫好面向物件程式碼的原則(下)](zh_CN/14-write-solid-python-codes-part-3.md)
* 前言
* D依賴倒置原則
* 需求:按域名分組統計 HN 新聞數量
* 為 SiteSourceGrouper 編寫單元測試
* 使用 mock 模組
* 實現依賴倒置原則
* 依賴倒置後的單元測試
* 問題:一定要使用抽象類 abc 嗎?
* 問題:抽象一定是好東西嗎?
* I介面隔離原則
* 例子:開發頁面歸檔功能
* 問題:實體類不符合 HNWebPage 介面規範
* 成功違反 I 協議
* 如何分拆介面
* 一些不容易發現的違反情況
* 現實世界中的介面隔離
* 總結
* 附錄
### 15. [在邊界處思考](zh_CN/15-thinking-in-edge-cases.md)
* 前言
* 第一課:使用分支還是異常?
* 獲取原諒比許可簡單(EAFP)
* 當容器內容不存在時
* 使用 defaultdict 改寫示例
* 使用 setdefault 取值並修改
* 使用 dict.pop 刪除不存在的鍵
* 當列表切片越界時
* 好用又危險的 “or” 運算子
* 不要手動去做資料校驗
* 不要忘記做數學計算
* 總結
* 附錄

View File

@ -0,0 +1,320 @@
# Python 工匠:善用變數來改善程式碼質量
## 『Python 工匠』是什麼?
我一直覺得程式設計某種意義上是一門『手藝』,因為優雅而高效的程式碼,就如同完美的手工藝品一樣讓人賞心悅目。
在雕琢程式碼的過程中有大工程比如應該用什麼架構、哪種設計模式。也有更多的小細節比如何時使用異常Exceptions、或怎麼給變數起名。那些真正優秀的程式碼正是由無數優秀的細節造就的。
『Python 工匠』這個系列文章,是我的一次小小嚐試。它專注於分享 Python 程式設計中的一些偏**『小』**的東西。希望能夠幫到每一位程式設計路上的匠人。
> 這是 “Python 工匠”系列的第 1 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
## 變數和程式碼質量
作為『Python 工匠』系列文章的第一篇,我想先談談 『變數Variables』。因為如何定義和使用變數一直都是學習任何一門程式語言最先要掌握的技能之一。
變數用的好或不好,和程式碼質量有著非常重要的聯絡。在關於變數的諸多問題中,為變數起一個好名字尤其重要。
### 內容目錄
* [如何為變數起名](#如何為變數起名)
* [1. 變數名要有描述性,不能太寬泛](#1-變數名要有描述性不能太寬泛)
* [2. 變數名最好讓人能猜出型別](#2-變數名最好讓人能猜出型別)
* [『什麼樣的名字會被當成 bool 型別?』](#什麼樣的名字會被當成-bool-型別)
* [『什麼樣的名字會被當成 int/float 型別?』](#什麼樣的名字會被當成-intfloat-型別)
* [其他型別](#其他型別)
* [3. 適當使用『匈牙利命名法』](#3-適當使用匈牙利命名法)
* [4. 變數名儘量短,但是絕對不要太短](#4-變數名儘量短但是絕對不要太短)
* [使用短名字的例外情況](#使用短名字的例外情況)
* [5. 其他注意事項](#5-其他注意事項)
* [更好的使用變數](#更好的使用變數)
* [1. 保持一致性](#1-保持一致性)
* [2. 儘量不要用 globals()/locals()](#2-儘量不要用-globalslocals)
* [3. 變數定義儘量靠近使用](#3-變數定義儘量靠近使用)
* [4. 合理使用 namedtuple/dict 來讓函式返回多個值](#4-合理使用-namedtupledict-來讓函式返回多個值)
* [5. 控制單個函式內的變數數量](#5-控制單個函式內的變數數量)
* [6. 及時刪掉那些沒用的變數](#6-及時刪掉那些沒用的變數)
* [7. 能不定義變數就不定義](#7-能不定義變數就不定義)
* [結語](#結語)
## 如何為變數起名
在電腦科學領域,有一句著名的格言(俏皮話):
> There are only two hard things in Computer Science: cache invalidation and naming things.
> 在電腦科學領域只有兩件難事:快取失效 和 給東西起名字
>
> -- Phil Karlton
第一個『快取過期問題』的難度不用多說,任何用過快取的人都會懂。至於第二個『給東西起名字』這事的難度,我也是深有體會。在我的職業生涯裡,度過的最為黑暗的下午之一,就是坐在顯示器前抓耳撓腮為一個新專案起一個合適的名字。
程式設計時起的最多的名字,還數各種變數。給變數起一個好名字很重要,**因為好的變數命名可以極大提高程式碼的整體可讀性。**
下面幾點,是我總結的為變數起名時,最好遵守的基本原則。
### 1. 變數名要有描述性,不能太寬泛
在**可接受的長度範圍內**,變數名能把它所指向的內容描述的越精確越好。所以,儘量不要用那些過於寬泛的詞來作為你的變數名:
- **BAD**: `day`, `host`, `cards`, `temp`
- **GOOD**: `day_of_week`, `hosts_to_reboot`, `expired_cards`
### 2. 變數名最好讓人能猜出型別
所有學習 Python 的人都知道Python 是一門動態型別語言,它(至少在 [PEP 484](https://www.python.org/dev/peps/pep-0484/) 出現前)沒有變數型別宣告。所以當你看到一個變數時,除了透過上下文猜測,沒法輕易知道它是什麼型別。
不過,人們對於變數名和變數型別的關係,通常會有一些直覺上的約定,我把它們總結在了下面。
#### 『什麼樣的名字會被當成 bool 型別?』
布林型別變數的最大特點是:它只存在兩個可能的值**『是』** 或 **『不是』**。所以,用 `is`、`has` 等非黑即白的詞修飾的變數名,會是個不錯的選擇。原則就是:**讓讀到變數名的人覺得這個變數只會有『是』或『不是』兩種值**。
下面是幾個不錯的示例:
- `is_superuser`:『是否超級使用者』,只會有兩種值:是/不是
- `has_error`:『有沒有錯誤』,只會有兩種值:有/沒有
- `allow_vip`:『是否允許 VIP』只會有兩種值允許/不允許
- `use_msgpack`:『是否使用 msgpack』只會有兩種值使用/不使用
- `debug`:『是否開啟除錯模式』,被當做 bool 主要是因為約定俗成
#### 『什麼樣的名字會被當成 int/float 型別?』
人們看到和數字相關的名字,都會預設他們是 int/float 型別,下面這些是比較常見的:
- 釋義為數字的所有單詞,比如:`port埠號`、`age年齡`、`radius半徑` 等等
- 使用 _id 結尾的單詞,比如:`user_id`、`host_id`
- 使用 length/count 開頭或者結尾的單詞,比如: `length_of_username`、`max_length`、`users_count`
**注意:**不要使用普通的複數來表示一個 int 型別變數,比如 `apples`、`trips`,最好用 `number_of_apples`、`trips_count` 來替代。
#### 其他型別
對於 str、list、tuple、dict 這些複雜型別,很難有一個統一的規則讓我們可以透過名字去猜測變數型別。比如 `headers`,既可能是一個頭資訊列表,也可能是包含頭資訊的 dict。
對於這些型別的變數名,最推薦的方式,就是編寫規範的文件,在函式和方法的 document string 中,使用 sphinx 格式([Python 官方文件使用的文件工具](http://www.sphinx-doc.org/en/stable/))來標註所有變數的型別。
### 3. 適當使用『匈牙利命名法』
第一次知道『[匈牙利命名法](https://en.wikipedia.org/wiki/Hungarian_notation)』,是在 [Joel on Software 的一篇博文](http://www.joelonsoftware.com/articles/Wrong.html)中。簡而言之,匈牙利命名法就是把變數的『型別』縮寫,放到變數名的最前面。
關鍵在於,這裡說的變數『型別』,並非指傳統意義上的 int/str/list 這種型別,而是指那些和你的程式碼業務邏輯相關的型別。
比如,在你的程式碼中有兩個變數:`students` 和 `teachers`,他們指向的內容都是一個包含 Person 物件的 list 。使用『匈牙利命名法』後,可以把這兩個名字改寫成這樣:
students -> `pl_students`
teachers -> `pl_teachers`
其中 pl 是 **person list** 的首字母縮寫。當變數名被加上字首後,如果你看到以 `pl_` 打頭的變數,就能知道它所指向的值型別了。
很多情況下,使用『匈牙利命名法』是個不錯的主意,因為它可以改善你的程式碼可讀性,尤其在那些變數眾多、同一型別多次出現時。注意不要濫用就好。
### 4. 變數名儘量短,但是絕對不要太短
在前面,我們提到要讓變數名有描述性。如果不給這條原則加上任何限制,那麼你很有可能寫出這種描述性極強的變數名:`how_much_points_need_for_level2`。如果程式碼中充斥著這種過長的變數名,對於程式碼可讀性來說是個災難。
一個好的變數名,長度應該控制在 **兩到三個單詞左右**。比如上面的名字,可以縮寫為 `points_level2`
**絕大多數情況下,都應該避免使用那些只有一兩個字母的短名字**,比如陣列索引三劍客 `i`、`j`、`k`,用有明確含義的名字,比如 person_index 來代替它們總是會更好一些。
#### 使用短名字的例外情況
有時,上面的原則也存在一些例外。當一些意義明確但是較長的變數名重複出現時,為了讓程式碼更簡潔,使用短名字縮寫是完全可以的。但是為了降低理解成本,同一段程式碼內最好不要使用太多這種短名字。
比如在 Python 中匯入模組時,就會經常用到短名字作為別名,像 Django i18n 翻譯時常用的 `gettext` 方法通常會被縮寫成 `_` 來使用*from django.utils.translation import ugettext as _*
### 5. 其他注意事項
其他一些給變數命名的注意事項:
- 同一段程式碼內不要使用過於相似的變數名,比如同時出現 `users`、`users1`、 `user3` 這種序列
- 不要使用帶否定含義的變數名,用 `is_special` 代替 `is_not_normal`
## 更好的使用變數
前面講了如何為變數取一個好名字,下面我們談談在日常使用變數時,應該注意的一些小細節。
### 1. 保持一致性
如果你在一個方法內裡面把圖片變數叫做 `photo`,在其他的地方就不要把它改成 `image`,這樣只會讓程式碼的閱讀者困惑:『`image` 和 `photo` 到底是不是同一個東西?』
另外,雖然 Python 是動態型別語言,但那也不意味著你可以用同一個變數名一會表示 str 型別,過會又換成 list。**同一個變數名指代的變數型別,也需要保持一致性。**
### 2. 儘量不要用 globals()/locals()
也許你第一次發現 globals()/locals() 這對內建函式時很興奮,迫不及待的寫下下面這種極端『簡潔』的程式碼:
```python
def render_trip_page(request, user_id, trip_id):
user = User.objects.get(id=user_id)
trip = get_object_or_404(Trip, pk=trip_id)
is_suggested = is_suggested(user, trip)
# 利用 locals() 節約了三行程式碼,我是個天才!
return render(request, 'trip.html', locals())
```
千萬不要這麼做,這樣只會讓讀到這段程式碼的人(包括三個月後的你自己)痛恨你,因為他需要記住這個函式內定義的所有變數(想想這個函式增長到兩百行會怎麼樣?),更別提 locals() 還會把一些不必要的變數傳遞出去。
更何況, [The Zen of PythonPython 之禪)](https://www.python.org/dev/peps/pep-0020/) 說的清清楚楚:**Explicit is better than implicit.(顯式優於隱式)**。所以,還是老老實實把程式碼寫成這樣吧:
```python
return render(request, 'trip.html', {
'user': user,
'trip': trip,
'is_suggested': is_suggested
})
```
### 3. 變數定義儘量靠近使用
這個原則屬於老生常談了。很多人(包括我)在剛開始學習程式設計時,會有一個習慣。就是把所有的變數定義寫在一起,放在函式或方法的最前面。
```python
def generate_trip_png(trip):
path = []
markers = []
photo_markers = []
text_markers = []
marker_count = 0
point_count = 0
... ...
```
這樣做只會讓你的程式碼『看上去很整潔』,但是對提高程式碼可讀性沒有任何幫助。
更好的做法是,**讓變數定義儘量靠近使用**。那樣當你閱讀程式碼時,可以更好的理解程式碼的邏輯,而不是費勁的去想這個變數到底是什麼、哪裡定義的?
### 4. 合理使用 namedtuple/dict 來讓函式返回多個值
Python 的函式可以返回多個值:
```python
def latlon_to_address(lat, lon):
return country, province, city
# 利用多返回值一次解包定義多個變數
country, province, city = latlon_to_address(lat, lon)
```
但是,這樣的用法會產生一個小問題:如果某一天, `latlon_to_address` 函式需要返回『城區District』時怎麼辦
如果是上面這種寫法,你需要找到所有呼叫 `latlon_to_address` 的地方,補上多出來的這個變數,否則 *ValueError: too many values to unpack* 就會找上你:
```python
country, province, city, district = latlon_to_address(lat, lon)
# 或者使用 _ 忽略多出來的返回值
country, province, city, _ = latlon_to_address(lat, lon)
```
對於這種可能變動的多返回值函式,使用 namedtuple/dict 會更方便一些。當你新增返回值時,不會對之前的函式呼叫產生任何破壞性的影響:
```python
# 1. 使用 dict
def latlon_to_address(lat, lon):
return {
'country': country,
'province': province,
'city': city
}
addr_dict = latlon_to_address(lat, lon)
# 2. 使用 namedtuple
from collections import namedtuple
Address = namedtuple("Address", ['country', 'province', 'city'])
def latlon_to_address(lat, lon):
return Address(
country=country,
province=province,
city=city
)
addr = latlon_to_address(lat, lon)
```
不過這樣做也有壞處,因為程式碼對變更的相容性雖然變好了,但是你不能繼續用之前 `x, y = f()` 的方式一次解包定義多個變量了。取捨在於你自己。
### 5. 控制單個函式內的變數數量
人腦的能力是有限的,研究表明,人類的短期記憶只能同時記住不超過十個名字。所以,當你的某個函式過長(一般來說,超過一屏的的函式就會被認為有點過長了),包含了太多變數時。請及時把它拆分為多個小函式吧。
### 6. 及時刪掉那些沒用的變數
這條原則非常簡單,也很容易做到。但是如果沒有遵守,那它對你的程式碼質量的打擊是毀滅級的。會讓閱讀你程式碼的人有一種被愚弄的感覺。
```python
def fancy_func():
# 讀者心理:嗯,這裡定義了一個 fancy_vars
fancy_vars = get_fancy()
... ...(一大堆程式碼過後)
# 讀者心理:這裡就結束了?之前的 fancy_vars 去哪了?被貓吃了嗎?
return result
```
所以,請開啟 IDE 的智慧提示,及時清理掉那些定義了但是沒有使用的變數吧。
### 7. 定義臨時變數提升可讀性
有時,我們的程式碼裡會出現一些複雜的表示式,像下面這樣:
```python
# 為所有性別為女性,或者級別大於 3 的活躍使用者發放 10000 個金幣
if user.is_active and (user.sex == 'female' or user.level > 3):
user.add_coins(10000)
return
```
看見 `if` 後面那一長串了嗎?有點難讀對不對?但是如果我們把它賦值成一個臨時變數,
就能給讀者一個心理緩衝,提高可讀性:
```
# 為所有性別為女性,或者級別大於 3 的活躍使用者發放 10000 個金幣
user_is_eligible = user.is_active and (user.sex == 'female' or user.level > 3):
if user_is_eligible:
user.add_coins(10000)
return
```
定義臨時變數可以提高可讀性。但有時,把不必要的東西賦值成臨時變數反而會讓程式碼顯得囉嗦:
```python
def get_best_trip_by_user_id(user_id):
# 心理活動:『嗯,這個值未來說不定會修改/二次使用』,讓我們先把它定義成變數吧!
user = get_user(user_id)
trip = get_best_trip(user_id)
result = {
'user': user,
'trip': trip
}
return result
```
其實,你所想的『未來』永遠不會來,這段程式碼裡的三個臨時變數完全可以去掉,變成這樣:
```python
def get_best_trip_by_user_id(user_id):
return {
'user': get_user(user_id),
'trip': get_best_trip(user_id)
}
```
沒必要為了那些可能出現的變動,犧牲程式碼當前的可讀性。如果以後有定義變數的需求,那就以後再加吧。
## 結語
碎碎唸了一大堆,不知道有多少人能夠堅持到最後。變數作為程式語言的重要組成部分,值得我們在定義和使用它時,多花一丁點時間思考一下,那樣會讓你的程式碼變得更優秀。
這是『Python 工匠』系列文章的第一篇,不知道看完文章的你,有沒有什麼想吐槽的?請留言告訴我吧。
[>>>下一篇【2.編寫條件分支程式碼的技巧】](2-if-else-block-secrets.md)
> 文章更新記錄:
>
> - 2018.04.09:根據 @onlyice 的建議,添加了 namedtuple 部分

View File

@ -0,0 +1,369 @@
# Python 工匠:做一個精通規則的玩家
## 前言
> 這是 “Python 工匠”系列的第 10 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/05/jeshoots-com-632498-unsplash_w1280.jpg" width="100%" />
</div>
程式設計,其實和玩電子遊戲有一些相似之處。你在玩不同遊戲前,需要先學習每個遊戲的不同規則,只有熟悉和靈活運用遊戲規則,才更有可能在遊戲中獲勝。
而程式設計也是一樣,不同程式語言同樣有著不一樣的“規則”。大到是否支援面向物件,小到是否可以定義常量,程式語言的規則比絕大多數電子遊戲要複雜的多。
當我們程式設計時,如果直接拿一種語言的經驗套用到另外一種語言上,很多時候並不能取得最佳結果。這就好像一個 CS反恐精英 高手在不瞭解規則的情況下去玩 PUBG絕地求生雖然他的槍法可能萬中無一但是極有可能在發現第一個敵人前他就會倒在某個窩在草叢裡的敵人的伏擊下。
### Python 裡的規則
Python 是一門初見簡單、深入後愈覺複雜的語言。拿 Python 裡最重要的“物件”概念來說Python 為其定義了多到讓你記不全的規則,比如:
- 定義了 `__str__` 方法的物件,就可以使用 `str()` 函式來返回可讀名稱
- 定義了 `__next__``__iter__` 方法的物件,就可以被迴圈迭代
- 定義了 `__bool__` 方法的物件,在進行布林判斷時就會使用自定義的邏輯
- ... ...
**熟悉規則,並讓自己的程式碼適應這些規則,可以幫助我們寫出更地道的程式碼,事半功倍的完成工作。**下面,讓我們來看一個有關適應規則的故事。
## 案例:從兩份旅遊資料中獲取人員名單
某日,在一個主打紐西蘭出境遊的旅遊公司裡,商務同事突然興沖沖的跑過來找到我,說他從某合作伙伴那裡,要到了兩份重要的資料:
1. 所有去過“泰國普吉島”的人員及聯絡方式
2. 所有去過“紐西蘭”的人員及聯絡方式
資料採用了 JSON 格式,如下所示:
```python
# 去過普吉島的人員資料
users_visited_phuket = [
{"first_name": "Sirena", "last_name": "Gross", "phone_number": "650-568-0388", "date_visited": "2018-03-14"},
{"first_name": "James", "last_name": "Ashcraft", "phone_number": "412-334-4380", "date_visited": "2014-09-16"},
... ...
]
# 去過紐西蘭的人員資料
users_visited_nz = [
{"first_name": "Justin", "last_name": "Malcom", "phone_number": "267-282-1964", "date_visited": "2011-03-13"},
{"first_name": "Albert", "last_name": "Potter", "phone_number": "702-249-3714", "date_visited": "2013-09-11"},
... ...
]
```
每份資料裡面都有著`姓`、`名`、`手機號碼`、`旅遊時間` 四個欄位。基於這份資料,商務同學提出了一個*(聽上去毫無道理)*的假設:“去過普吉島的人,應該對去紐西蘭旅遊也很有興趣。我們需要從這份資料裡,找出那些**去過普吉島但沒有去過紐西蘭的人**,針對性的賣產品給他們。
### 第一次蠻力嘗試
有了原始資料和明確的需求,接下來的問題就是如何寫程式碼了。依靠蠻力,我很快就寫出了第一個方案:
```python
def find_potential_customers_v1():
"""找到去過普吉島但是沒去過紐西蘭的人
"""
for phuket_record in users_visited_phuket:
is_potential = True
for nz_record in users_visited_nz:
if phuket_record['first_name'] == nz_record['first_name'] and \
phuket_record['last_name'] == nz_record['last_name'] and \
phuket_record['phone_number'] == nz_record['phone_number']:
is_potential = False
break
if is_potential:
yield phuket_record
```
因為原始資料裡沒有*“使用者 ID”*之類的唯一標示,所以我們只能把“姓名和電話號碼完全相同”作為判斷是不是同一個人的標準。
`find_potential_customers_v1` 函式透過迴圈的方式,先遍歷所有去過普吉島的人,然後再遍歷紐西蘭的人,如果在紐西蘭的記錄中找不到完全匹配的記錄,就把它當做“潛在客戶”返回。
這個函式雖然可以完成任務,但是相信不用我說你也能發現。**它有著非常嚴重的效能問題。**對於每一條去過普吉島的記錄,我們都需要遍歷所有紐西蘭訪問記錄,嘗試找到匹配。整個演算法的時間複雜度是可怕的 `O(n*m)`,如果紐西蘭的訪問條目數很多的話,那麼執行它將耗費非常長的時間。
為了最佳化內層迴圈效能,我們需要減少線性查詢匹配部分的開銷。
### 嘗試使用集合最佳化函式
如果你對 Python 有所瞭解的話那麼你肯定知道Python 裡的字典和集合物件都是基於 [雜湊表Hash Table](https://en.wikipedia.org/wiki/Hash_table) 實現的。判斷一個東西是不是在集合裡的平均時間複雜度是 `O(1)`,非常快。
所以,對於上面的函式,我們可以先嚐試針對紐西蘭訪問記錄初始化一個集合,之後的查詢匹配部分就可以變得很快,函式整體時間複雜度就能變為 `O(n+m)`
讓我們看看新的函式:
```python
def find_potential_customers_v2():
"""找到去過普吉島但是沒去過紐西蘭的人,效能改進版
"""
# 首先,遍歷所有紐西蘭訪問記錄,建立查詢索引
nz_records_idx = {
(rec['first_name'], rec['last_name'], rec['phone_number'])
for rec in users_visited_nz
}
for rec in users_visited_phuket:
key = (rec['first_name'], rec['last_name'], rec['phone_number'])
if key not in nz_records_idx:
yield rec
```
使用了集合物件後,新函式在速度上相比舊版本有了飛躍性的突破。但是,對這個問題的最佳化並不是到此為止,不然文章標題就應該改成:“如何使用集合提高程式效能” 了。
### 對問題的重新思考
讓我們來嘗試重新抽象思考一下問題的本質。首先,我們有一份裝了很多東西的容器 A*(普吉島訪問記錄)*,然後給我們另一個裝了很多東西的容器 B*(紐西蘭訪問記錄)*,之後定義相等規則:“姓名與電話一致”。最後基於這個相等規則,求 A 和 B 之間的**“差集”**。
如果你對 Python 裡的集合不是特別熟悉,我就稍微多介紹一點。假如我們擁有兩個集合 A 和 B那麼我們可以直接使用 `A - B` 這樣的數學運算表示式來計算二者之間的 **差集**
```python
>>> a = {1, 3, 5, 7}
>>> b = {3, 5, 8}
# 產生新集合:所有在 a 但是不在 b 裡的元素
>>> a - b
{1, 7}
```
所以,計算“所有去過普吉島但沒去過紐西蘭的人”,其實就是一次集合的求差值操作。那麼要怎麼做,才能把我們的問題套入到集合的遊戲規則裡去呢?
### 利用集合的遊戲規則
在 Python 中,如果要把某個東西裝到集合或字典裡,一定要滿足一個基本條件:**“這個東西必須是可以被雜湊Hashable的”** 。什麼是 “Hashable”
舉個例子Python 裡面的所有可變物件,比如字典,就 **不是** Hashable 的。當你嘗試把字典放入集合中時,會發生這樣的錯誤:
```python
>>> s = set()
>>> s.add({'foo': 'bar'})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'dict'
```
所以,如果要利用集合解決我們的問題,就首先得定義我們自己的 “Hashable” 物件:`VisitRecord`。而要讓一個自定義物件變得 Hashable唯一要做的事情就是定義物件的 `__hash__` 方法。
```python
class VisitRecord:
"""旅遊記錄
"""
def __init__(self, first_name, last_name, phone_number, date_visited):
self.first_name = first_name
self.last_name = last_name
self.phone_number = phone_number
self.date_visited = date_visited
```
一個好的雜湊演算法,應該讓不同物件之間的值儘可能的唯一,這樣可以最大程度減少[“雜湊碰撞”](https://en.wikipedia.org/wiki/Collision_(computer_science))發生的概率,預設情況下,所有 Python 物件的雜湊值來自它的記憶體地址。
在這個問題裡,我們需要自定義物件的 `__hash__` 方法,讓它利用 `(姓,名,電話)`元組作為 `VisitRecord` 類的雜湊值來源。
```python
def __hash__(self):
return hash(
(self.first_name, self.last_name, self.phone_number)
)
```
自定義完 `__hash__` 方法後,`VisitRecord` 例項就可以正常的被放入集合中了。但這還不夠,為了讓前面提到的求差值演算法正常工作,我們還需要實現 `__eq__` 特殊方法。
`__eq__` 是 Python 在判斷兩個物件是否相等時呼叫的特殊方法。預設情況下,它只有在自己和另一個物件的記憶體地址完全一致時,才會返回 `True`。但是在這裡,我們複用了 `VisitRecord` 物件的雜湊值,當二者相等時,就認為它們一樣。
```python
def __eq__(self, other):
# 當兩條訪問記錄的名字與電話號相等時,判定二者相等。
if isinstance(other, VisitRecord) and hash(other) == hash(self):
return True
return False
```
完成了恰當的資料建模後,之後的求差值運算便算是水到渠成了。新版本的函式只需要一行程式碼就能完成操作:
```python
def find_potential_customers_v3():
return set(VisitRecord(**r) for r in users_visited_phuket) - \
set(VisitRecord(**r) for r in users_visited_nz)
```
> Hint如果你使用的是 Python 2那麼除了 `__eq__` 方法外,你還需要自定義類的 `__ne__`(判斷不相等時使用) 方法。
### 使用 dataclass 簡化程式碼
故事到這裡並沒有結束。在上面的程式碼裡,我們手動定義了自己的 **資料類** `VisitRecord`,實現了 `__init__`、`__eq__` 等初始化方法。但其實還有更簡單的做法。
因為定義資料類這種需求在 Python 中實在太常見了,所以在 3.7 版本中,標準庫中新增了 [dataclasses](https://docs.python.org/3/library/dataclasses.html) 模組,專門幫你簡化這類工作。
如果使用 dataclasses 提供的特性,我們的程式碼可以最終簡化成下面這樣:
```python
@dataclass(unsafe_hash=True)
class VisitRecordDC:
first_name: str
last_name: str
phone_number: str
# 跳過“訪問時間”欄位,不作為任何對比條件
date_visited: str = field(hash=False, compare=False)
def find_potential_customers_v4():
return set(VisitRecordDC(**r) for r in users_visited_phuket) - \
set(VisitRecordDC(**r) for r in users_visited_nz)
```
不用幹任何髒活累活,只要不到十行程式碼就完成了工作。
### 案例總結
問題解決以後,讓我們再做一點小小的總結。在處理這個問題時,我們一共使用了三種方案:
1. 使用普通的兩層迴圈篩選符合規則的結果集
2. 利用雜湊表結構set 物件)建立索引,提升處理效率
3. 將資料轉換為自定義物件,利用規則,直接使用集合運算
為什麼第三種方式會比前面兩種好呢?
首先,第一個方案的效能問題過於明顯,所以很快就會被放棄。那麼第二個方案呢?仔細想想看,方案二其實並沒有什麼明顯的缺點。甚至和第三個方案相比,因為少了自定義物件的過程,它在效能與記憶體佔用上,甚至有可能會微微強於後者。
但請再思考一下,如果你把方案二的程式碼換成另外一種語言,比如 Java它是不是基本可以做到 1:1 的完全翻譯?換句話說,**它雖然效率高、程式碼直接,但是它沒有完全利用好 Python 世界提供的規則,最大化的從中受益。**
如果要具體化這個問題裡的“規則”,那就是 **“Python 擁有內建結構集合,集合之間可以進行差值等四則運算”** 這個事實本身。匹配規則後編寫的方案三程式碼擁有下面這些優勢:
- 為資料建模後,可以更方便的定義其他方法
- 如果需求變更,做反向差值運算、求交集運算都很簡單
- 理解集合與 dataclasses 邏輯後,程式碼遠比其他版本更簡潔清晰
- 如果要修改相等規則,比如“只擁有相同姓的記錄就算作一樣”,只需要繼承`VisitRecord` 覆蓋 `__eq__` 方法即可
## 其他規則如何影響我們
在前面我們花了很大的篇幅講了如何利用“集合的規則”來編寫事半功倍的程式碼。除此之外Python 世界中還有著很多其他規則。如果能熟練掌握這些規則,就可以設計出符合 Python 慣例的 API讓程式碼更簡潔精煉。
下面是兩個具體的例子。
### 使用 `__format__` 做物件字串格式化
如果你的自定義物件需要定義多種字串表示方式,就像下面這樣:
```python
class Student:
def __init__(self, name, age):
self.name = name
self.age = age
def get_simple_display(self):
return f'{self.name}({self.age})'
def get_long_display(self):
return f'{self.name} is {self.age} years old.'
piglei = Student('piglei', '18')
# OUTPUT: piglei(18)
print(piglei.get_simple_display())
# OUTPUT: piglei is 18 years old.
print(piglei.get_long_display())
```
那麼除了增加這種 `get_xxx_display()` 額外方法外,你還可以嘗試自定義 `Student` 類的 `__format__` 方法,因為那才是將物件變為字串的標準規則。
```python
class Student:
def __init__(self, name, age):
self.name = name
self.age = age
def __format__(self, format_spec):
if format_spec == 'long':
return f'{self.name} is {self.age} years old.'
elif format_spec == 'simple':
return f'{self.name}({self.age})'
raise ValueError('invalid format spec')
piglei = Student('piglei', '18')
print('{0:simple}'.format(piglei))
print('{0:long}'.format(piglei))
```
### 使用 `__getitem__` 定義物件切片操作
如果你要設計某個可以裝東西的容器型別,那麼你很可能會為它定義“是否為空”、“獲取第 N 個物件”等方法:
```python
class Events:
def __init__(self, events):
self.events = events
def is_empty(self):
return not bool(self.events)
def list_events_by_range(self, start, end):
return self.events[start:end]
events = Events([
'computer started',
'os launched',
'docker started',
'os stopped',
])
# 判斷是否有內容,列印第二個和第三個物件
if not events.is_empty():
print(events.list_events_by_range(1, 3))
```
但是,這樣並非最好的做法。因為 Python 已經為我們提供了一套物件規則,所以我們不需要像寫其他語言的 OO*(面向物件)* 程式碼那樣去自己定義額外方法。我們有更好的選擇:
```python
class Events:
def __init__(self, events):
self.events = events
def __len__(self):
"""自定義長度,將會被用來做布林判斷"""
return len(self.events)
def __getitem__(self, index):
"""自定義切片方法"""
# 直接將 slice 切片物件透傳給 events 處理
return self.events[index]
# 判斷是否有內容,列印第二個和第三個物件
if events:
print(events[1:3])
```
新的寫法相比舊程式碼,更能適配進 Python 世界的規則API 也更為簡潔。
關於如何適配規則、寫出更好的 Python 程式碼。Raymond Hettinger 在 PyCon 2015 上有過一次非常精彩的演講 [“Beyond PEP8 - Best practices for beautiful intelligible code”](https://www.youtube.com/watch?v=wf-BqAjZb8M)。這次演講長期排在我個人的 *“PyCon 影片 TOP5”* 名單上,如果你還沒有看過,我強烈建議你現在就去看一遍 :)
> Hint更全面的 Python 物件模型規則可以在 [官方文件](https://docs.python.org/3/reference/datamodel.html) 找到,有點難讀,但值得一讀。
## 總結
Python 世界有著一套非常複雜的規則,這些規則的涵蓋範圍包括“物件與物件是否相等“、”物件與物件誰大誰小”等等。它們大部分都需要透過重新定義“雙下劃線方法 `__xxx__`” 去實現。
如果熟悉這些規則,並在日常編碼中活用它們,有助於我們更高效的解決問題、設計出更符合 Python 哲學的 API。下面是本文的一些要點總結
- **永遠記得對原始需求做抽象分析,比如問題是否能用集合求差集解決**
- 如果要把物件放入集合,需要自定義物件的 `__hash__``__eq__` 方法
- `__hash__` 方法決定效能(碰撞出現概率),`__eq__` 決定物件間相等邏輯
- 使用 dataclasses 模組可以讓你少寫很多程式碼
- 使用 `__format__` 方法替代自己定義的字串格式化方法
- 在容器類物件上使用 `__len__`、`__getitem__` 方法,而不是自己實現
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[>>>下一篇【11.高效操作檔案的三個建議】](11-three-tips-on-writing-file-related-codes.md)
[<<<上一篇【9.一個關於模組的小故事】](9-a-story-on-cyclic-imports.md)
## 附錄
- 題圖來源: Photo by JESHOOTS.COM on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:編寫條件分支程式碼的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:異常處理的三個好習慣](https://www.zlovezl.cn/articles/three-rituals-of-exceptions-handling/)
- [Python 工匠:編寫地道迴圈的兩個建議](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)

View File

@ -0,0 +1,396 @@
# Python 工匠:高效操作檔案的三個建議
## 前言
> 這是 “Python 工匠”系列的第 11 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/06/devon-divine-1348025-unsplash_1280.jpg" width="100%" />
</div>
在這個世界上,人們每天都在用 Python 完成著不同的工作。而檔案操作,則是大家最常需要解決的任務之一。使用 Python你可以輕鬆為他人生成精美的報表也可以用短短几行程式碼快速解析、整理上萬份資料檔案。
當我們編寫與檔案相關的程式碼時,通常會關注這些事情:**我的程式碼是不是足夠快?我的程式碼有沒有事半功倍的完成任務?** 在這篇文章中,我會與你分享與之相關的幾個程式設計建議。我會向你推薦一個被低估的 Python 標準庫模組、演示一個讀取大檔案的最佳方式、最後再分享我對函式設計的一點思考。
下面,讓我們進入第一個“模組安利”時間吧。
> **注意:**因為不同作業系統的檔案系統大不相同,本文的主要編寫環境為 Mac OS/Linux 系統,其中一些程式碼可能並不適用於 Windows 系統。
## 建議一:使用 pathlib 模組
如果你需要在 Python 裡進行檔案處理,那麼標準庫中的 `os``os.path` 兄弟倆一定是你無法避開的兩個模組。在這兩個模組裡,有著非常多與檔案路徑處理、檔案讀寫、檔案狀態檢視相關的工具函式。
讓我用一個例子來展示一下它們的使用場景。有一個目錄裡裝了很多資料檔案,但是它們的字尾名並不統一,既有 `.txt`,又有 `.csv`。我們需要把其中以 `.txt` 結尾的檔案都修改為 `.csv` 字尾名。
我們可以寫出這樣一個函式:
```python
import os
import os.path
def unify_ext_with_os_path(path):
"""統一目錄下的 .txt 檔名字尾為 .csv
"""
for filename in os.listdir(path):
basename, ext = os.path.splitext(filename)
if ext == '.txt':
abs_filepath = os.path.join(path, filename)
os.rename(abs_filepath, os.path.join(path, f'{basename}.csv'))
```
讓我們看看,上面的程式碼一共用到了哪些與檔案處理相關的函式:
- [`os.listdir(path)`](https://docs.python.org/3/library/os.html#os.listdir):列出 path 目錄下的所有檔案*(含資料夾)*
- [`os.path.splitext(filename)`](https://docs.python.org/3/library/os.path.html#os.path.splitext):切分檔名裡面的基礎名稱和字尾部分
- [`os.path.join(path, filename)`](https://docs.python.org/3/library/os.path.html#os.path.join):組合需要操作的檔名為絕對路徑
- [`os.rename(...)`](https://docs.python.org/3/library/os.html#os.rename):重新命名某個檔案
上面的函式雖然可以完成需求,但說句實話,即使在寫了很多年 Python 程式碼後,我依然覺得:**這些函式不光很難記,而且最終的成品程式碼也不怎麼討人喜歡。**
### 使用 pathlib 模組改寫程式碼
為了讓檔案處理變得更簡單Python 在 3.4 版本引入了一個新的標準庫模組:[pathlib](https://docs.python.org/3/library/pathlib.html)。它基於面向物件思想設計,封裝了非常多與檔案操作相關的功能。如果使用它來改寫上面的程式碼,結果會大不相同。
使用 pathlib 模組後的程式碼:
```python
from pathlib import Path
def unify_ext_with_pathlib(path):
for fpath in Path(path).glob('*.txt'):
fpath.rename(fpath.with_suffix('.csv'))
```
和舊程式碼相比,新函式只需要兩行程式碼就完成了工作。而這兩行程式碼主要做了這麼幾件事:
1. 首先使用 [Path(path)](https://docs.python.org/3/library/pathlib.html#pathlib.Path) 將字串路徑轉換為 `Path` 物件
2. 呼叫 [.glob('*.txt')](https://docs.python.org/3/library/pathlib.html#pathlib.Path.glob) 對路徑下所有內容進行模式匹配並以生成器方式返回,結果仍然是 `Path` 物件,所以我們可以接著做後面的操作
3. 使用 [.with_suffix('.csv')](https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.with_suffix) 直接獲取使用新字尾名的檔案全路徑
4. 呼叫 [.rename(target)](https://docs.python.org/3/library/pathlib.html#pathlib.Path.rename) 完成重新命名
相比 `os``os.path`,引入 `pathlib` 模組後的程式碼明顯更精簡,也更有整體統一感。所有檔案相關的操作都是一站式完成。
### 其他用法
除此之外pathlib 模組還提供了很多有趣的用法。比如使用 `/` 運算子來組合檔案路徑:
```python
# 😑 舊朋友:使用 os.path 模組
>>> import os.path
>>> os.path.join('/tmp', 'foo.txt')
'/tmp/foo.txt'
# ✨ 新潮流:使用 / 運算子
>>> from pathlib import Path
>>> Path('/tmp') / 'foo.txt'
PosixPath('/tmp/foo.txt')
```
或者使用 `.read_text()` 來快速讀取檔案內容:
```python
# 標準做法,使用 with open(...) 開啟檔案
>>> with open('foo.txt') as file:
... print(file.read())
...
foo
# 使用 pathlib 可以讓這件事情變得更簡單
>>> from pathlib import Path
>>> print(Path('foo.txt').read_text())
foo
```
除了我在文章裡介紹的這些pathlib 模組還提供了非常多有用的方法,強烈建議去 [官方文件]((https://docs.python.org/3/library/pathlib.html#module-pathlib)) 詳細瞭解一下。
如果上面這些都不足以讓你動心,那麼我再多給你一個使用 pathlib 的理由:[PEP-519](https://www.python.org/dev/peps/pep-0519/) 裡定義了一個專門用於“檔案路徑”的新物件協議,這意味著從該 PEP 生效後的 Python 3.6 版本起pathlib 裡的 Path 物件,可以和以前絕大多數只接受字串路徑的標準庫函式相容使用:
```python
>>> p = Path('/tmp')
# 可以直接對 Path 型別物件 p 進行 join
>>> os.path.join(p, 'foo.txt')
'/tmp/foo.txt'
```
所以,無需猶豫,趕緊把 pathlib 模組用起來吧。
> **Hint:** 如果你使用的是更早的 Python 版本,可以嘗試安裝 [pathlib2](https://pypi.org/project/pathlib2/) 模組 。
## 建議二:掌握如何流式讀取大檔案
幾乎所有人都知道,在 Python 裡讀取檔案有一種“標準做法”:首先使用 `with open(fine_name)` 上下文管理器的方式獲得一個檔案物件,然後使用 `for` 迴圈迭代它,逐行獲取檔案裡的內容。
下面是一個使用這種“標準做法”的簡單示例函式:
```python
def count_nine(fname):
"""計算檔案裡包含多少個數字 '9'
"""
count = 0
with open(fname) as file:
for line in file:
count += line.count('9')
return count
```
假如我們有一個檔案 `small_file.txt`,那麼使用這個函式可以輕鬆計算出 9 的數量。
```python
# small_file.txt
feiowe9322nasd9233rl
aoeijfiowejf8322kaf9a
# OUTPUT: 3
print(count_nine('small_file.txt'))
```
為什麼這種檔案讀取方式會成為標準?這是因為它有兩個好處:
1. `with` 上下文管理器會自動關閉開啟的檔案描述符
2. 在迭代檔案物件時,內容是一行一行返回的,不會佔用太多記憶體
### 標準做法的缺點
但這套標準做法並非沒有缺點。如果被讀取的檔案裡,根本就沒有任何換行符,那麼上面的第二個好處就不成立了。**當代碼執行到 `for line in file`line 將會變成一個非常巨大的字串物件,消耗掉非常可觀的記憶體。**
讓我們來做個試驗:有一個 **5GB** 大的檔案 `big_file.txt`,它裡面裝滿了和 `small_file.txt` 一樣的隨機字串。只不過它儲存內容的方式稍有不同,所有的文字都被放在了同一行裡:
```raw
# FILE: big_file.txt
df2if283rkwefh... <剩餘 5GB 大小> ...
```
如果我們繼續使用前面的 `count_nine` 函式去統計這個大檔案裡 `9` 的個數。那麼在我的筆記本上,這個過程會足足花掉 **65** 秒,並在執行過程中吃掉機器 **2GB** 記憶體 [[注1]]((#annot1))。
### 使用 read 方法分塊讀取
為了解決這個問題,我們需要暫時把這個“標準做法”放到一邊,使用更底層的 `file.read()` 方法。與直接迴圈迭代檔案物件不同,每次呼叫 `file.read(chunk_size)` 會直接返回從當前位置往後讀取 `chunk_size` 大小的檔案內容,不必等待任何換行符出現。
所以,如果使用 `file.read()` 方法,我們的函式可以改寫成這樣:
```python
def count_nine_v2(fname):
"""計算檔案裡包含多少個數字 '9',每次讀取 8kb
"""
count = 0
block_size = 1024 * 8
with open(fname) as fp:
while True:
chunk = fp.read(block_size)
# 當檔案沒有更多內容時read 呼叫將會返回空字串 ''
if not chunk:
break
count += chunk.count('9')
return count
```
在新函式中,我們使用了一個 `while` 迴圈來讀取檔案內容,每次最多讀取 8kb 大小,這樣可以避免之前需要拼接一個巨大字串的過程,把記憶體佔用降低非常多。
### 利用生成器解耦程式碼
假如我們在討論的不是 Python而是其他程式語言。那麼可以說上面的程式碼已經很好了。但是如果你認真分析一下 `count_nine_v2` 函式,你會發現在迴圈體內部,存在著兩個獨立的邏輯:**資料生成read 呼叫與 chunk 判斷)** 與 **資料消費**。而這兩個獨立邏輯被耦合在了一起。
正如我在[《編寫地道迴圈》](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)裡所提到的,為了提升複用能力,我們可以定義一個新的 `chunked_file_reader` 生成器函式,由它來負責所有與“資料生成”相關的邏輯。這樣 `count_nine_v3` 裡面的主迴圈就只需要負責計數即可。
```python
def chunked_file_reader(fp, block_size=1024 * 8):
"""生成器函式:分塊讀取檔案內容
"""
while True:
chunk = fp.read(block_size)
# 當檔案沒有更多內容時read 呼叫將會返回空字串 ''
if not chunk:
break
yield chunk
def count_nine_v3(fname):
count = 0
with open(fname) as fp:
for chunk in chunked_file_reader(fp):
count += chunk.count('9')
return count
```
進行到這一步,程式碼似乎已經沒有最佳化的空間了,但其實不然。[iter(iterable)](https://docs.python.org/3/library/functions.html#iter) 是一個用來構造迭代器的內建函式,但它還有一個更少人知道的用法。當我們使用 `iter(callable, sentinel)` 的方式呼叫它時,會返回一個特殊的物件,迭代它將不斷產生可呼叫物件 callable 的呼叫結果,直到結果為 setinel 時,迭代終止。
```python
def chunked_file_reader(file, block_size=1024 * 8):
"""生成器函式:分塊讀取檔案內容,使用 iter 函式
"""
# 首先使用 partial(fp.read, block_size) 構造一個新的無需引數的函式
# 迴圈將不斷返回 fp.read(block_size) 呼叫結果,直到其為 '' 時終止
for chunk in iter(partial(file.read, block_size), ''):
yield chunk
```
最終,只需要兩行程式碼,我們就完成了一個可複用的分塊檔案讀取函式。那麼,這個函式在效能方面的表現如何呢?
和一開始的 **2GB 記憶體/耗時 65 秒** 相比,使用生成器的版本只需要 **7MB 記憶體 / 12 秒** 就能完成計算。效率提升了接近 4 倍,記憶體佔用更是不到原來的 1%。
## 建議三:設計接受檔案物件的函式
統計完檔案裡的 “9” 之後,讓我們換一個需求。現在,我想要統計每個檔案裡出現了多少個英文母音字母*aeiou*。只要對之前的程式碼稍作調整,很快就可以寫出新函式 `count_vowels`
```python
def count_vowels(filename):
"""統計某個檔案中,包含母音字母(aeiou)的數量
"""
VOWELS_LETTERS = {'a', 'e', 'i', 'o', 'u'}
count = 0
with open(filename, 'r') as fp:
for line in fp:
for char in line:
if char.lower() in VOWELS_LETTERS:
count += 1
return count
# OUTPUT: 16
print(count_vowels('small_file.txt'))
```
和之前“統計 9”的函式相比新函式變得稍微複雜了一些。為了保證程式的正確性我需要為它寫一些單元測試。但當我準備寫測試時卻發現這件事情非常麻煩主要問題點如下
1. 函式接收檔案路徑作為引數,所以我們需要傳遞一個實際存在的檔案
2. 為了準備測試用例,我要麼提供幾個樣板檔案,要麼寫一些臨時檔案
3. 而檔案是否能被正常開啟、讀取,也成了我們需要測試的邊界情況
**如果,你發現你的函式難以編寫單元測試,那通常意味著你應該改進它的設計。**上面的函式應該如何改進呢?答案是:*讓函式依賴“檔案物件”而不是檔案路徑*。
修改後的函式程式碼如下:
```python
def count_vowels_v2(fp):
"""統計某個檔案中,包含母音字母(aeiou)的數量
"""
VOWELS_LETTERS = {'a', 'e', 'i', 'o', 'u'}
count = 0
for line in fp:
for char in line:
if char.lower() in VOWELS_LETTERS:
count += 1
return count
# 修改函式後,開啟檔案的職責被移交給了上層函式呼叫者
with open('small_file.txt') as fp:
print(count_vowels_v2(fp))
```
**這個改動帶來的主要變化,在於它提升了函式的適用面。**因為 Python 是“鴨子型別”的,雖然函式需要接受檔案物件,但其實我們可以把任何實現了檔案協議的 “類檔案物件file-like object” 傳入 `count_vowels_v2` 函式中。
而 Python 中有著非常多“類檔案物件”。比如 io 模組內的 [StringIO](https://docs.python.org/3/library/io.html#io.StringIO) 物件就是其中之一。它是一種基於記憶體的特殊物件,擁有和檔案物件幾乎一致的介面設計。
利用 StringIO我們可以非常方便的為函式編寫單元測試。
```python
# 注意:以下測試函式需要使用 pytest 執行
import pytest
from io import StringIO
@pytest.mark.parametrize(
"content,vowels_count", [
# 使用 pytest 提供的引數化測試工具,定義測試引數列表
# (檔案內容, 期待結果)
('', 0),
('Hello World!', 3),
('HELLO WORLD!', 3),
('你好,世界', 0),
]
)
def test_count_vowels_v2(content, vowels_count):
# 利用 StringIO 構造類檔案物件 "file"
file = StringIO(content)
assert count_vowels_v2(file) == vowels_count
```
使用 pytest 執行測試可以發現,函式可以透過所有的用例:
```raw
pytest vowels_counter.py
====== test session starts ======
collected 4 items
vowels_counter.py ... [100%]
====== 4 passed in 0.06 seconds ======
```
而讓編寫單元測試變得更簡單,並非修改函式依賴後的唯一好處。除了 StringIO 外subprocess 模組呼叫系統命令時用來儲存標準輸出的 [PIPE](https://docs.python.org/3/library/subprocess.html#subprocess.PIPE) 物件,也是一種“類檔案物件”。這意味著我們可以直接把某個命令的輸出傳遞給 `count_vowels_v2` 函式來計算母音字母數:
```python
import subprocess
# 統計 /tmp 下面所有一級子檔名(目錄名)有多少母音字母
p = subprocess.Popen(['ls', '/tmp'], stdout=subprocess.PIPE, encoding='utf-8')
# p.stdout 是一個流式類檔案物件,可以直接傳入函式
# OUTPUT: 42
print(count_vowels_v2(p.stdout))
```
正如之前所說,將函式引數修改為“檔案物件”,最大的好處是提高了函式的 **適用面****可組合性**。透過依賴更為抽象的“類檔案物件”而非檔案路徑給函式的使用方式開啟了更多可能StringIO、PIPE 以及任何其他滿足協議的物件都可以成為函式的客戶。
不過,這樣的改造並非毫無缺點,它也會給呼叫方帶來一些不便。假如呼叫方就是想要使用檔案路徑,那麼就必須得自行處理檔案的開啟操作。
### 如何編寫相容二者的函式
有沒有辦法即擁有“接受檔案物件”的靈活性,又能讓傳遞檔案路徑的呼叫方更方便?答案是:*有,而且標準庫中就有這樣的例子。*
開啟標準庫裡的 `xml.etree.ElementTree` 模組,翻開裡面的 `ElementTree.parse` 方法。你會發現這個方法即可以使用檔案物件呼叫,也接受字串的檔案路徑。而它實現這一點的手法也非常簡單易懂:
```
def parse(self, source, parser=None):
"""*source* is a file name or file object, *parser* is an optional parser
"""
close_source = False
# 透過判斷 source 是否有 "read" 屬性來判定它是不是“類檔案物件”
# 如果不是,那麼呼叫 open 函式開啟它並負擔起在函式末尾關閉它的責任
if not hasattr(source, "read"):
source = open(source, "rb")
close_source = True
```
使用這種基於“鴨子型別”的靈活檢測方式,`count_vowels_v2` 函式也同樣可以被改造得更方便,我在這裡就不再重複啦。
## 總結
檔案操作我們在日常工作中經常需要接觸的領域,使用更方便的模組、利用生成器節約記憶體以及編寫適用面更廣的函式,可以讓我們編寫出更高效的程式碼。
讓我們最後再總結一下吧:
- 使用 pathlib 模組可以簡化檔案和目錄相關的操作,並讓程式碼更直觀
- [PEP-519](https://www.python.org/dev/peps/pep-0519/) 定義了表示“檔案路徑”的標準協議Path 物件實現了這個協議
- 透過定義生成器函式來分塊讀取大檔案可以節約記憶體
- 使用 `iter(callable, sentinel)` 可以在一些特定場景簡化程式碼
- 難以編寫測試的程式碼,通常也是需要改進的程式碼
- 讓函式依賴“類檔案物件”可以提升函式的適用面和可組合性
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[>>>下一篇【12.寫好面向物件程式碼的原則(上)】](12-write-solid-python-codes-part-1.md)
[<<<上一篇【10.做一個精通規則的玩家】](10-a-good-player-know-the-rules.md)
## 附錄
- 題圖來源: Photo by Devon Divine on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:編寫條件分支程式碼的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:異常處理的三個好習慣](https://www.zlovezl.cn/articles/three-rituals-of-exceptions-handling/)
- [Python 工匠:編寫地道迴圈的兩個建議](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)
## 註解
1. <a id="annot1"></a>視機器空閒記憶體的多少,這個過程可能會消耗比 2GB 更多的記憶體。

View File

@ -0,0 +1,594 @@
# Python 工匠:寫好面向物件程式碼的原則(上)
## 前言
> 這是 “Python 工匠”系列的第 12 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/06/kelly-sikkema-Z9AU36chmQI-unsplash_w1280.jpg" width="100%" />
</div>
Python 是一門支援多種程式設計風格的語言,面對相同的需求,擁有不同背景的程式設計師可能會寫出風格迥異的 Python 程式碼。比如一位習慣編寫 C 語言的程式設計師,通常會定義一大堆函式來搞定所有事情,這是[“程序式程式設計”](https://en.wikipedia.org/wiki/Procedural_programming)的思想。而一位有 Java 背景的程式設計師則更傾向於設計許多個相互關聯的類*class*,這是 [“面向物件程式設計(後簡稱 OOP](https://en.wikipedia.org/wiki/Object-oriented_programming)。
雖然不同的程式設計風格各有特點,無法直接比較。但是 OOP 思想在現代軟體開發中起到的重要作用應該是毋庸置疑的。
很多人在學習如何寫好 OOP 程式碼時,會選擇從那 [23 種經典的“設計模式”](https://zh.wikipedia.org/wiki/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F_(%E8%AE%A1%E7%AE%97%E6%9C%BA))開始。不過對於 Python 程式設計師來說,我認為這並非是一個最佳選擇。
### Python 對 OOP 的支援
Python 語言雖然擁有類、繼承、多型等核心 OOP 特性,但和那些完全基於 OOP 思想設計的程式語言*(比如 Java*相比,它在 OOP 支援方面做了很多簡化工作。比如它 **沒有嚴格的類私有成員沒有介面Interface物件** 等。
而與此同時Python 靈活的函式物件、鴨子型別等許多動態特性又讓一些在其他語言中很難做到的事情變得非常簡單。這些語言間的差異共同導致了一個結果:*很多經典的設計模式到了 Python 裡,就丟失了那個“味道”,實用性也大打折扣。*
拿大家最熟悉的單例模式來說。你可以花上一大把時間,來學習如何在 Python 中利用 `__new__` 方法或元類*metaclass*來實現單例設計模式,但最後你會發現,自己 95% 的需求都可以透過直接定義一個模組級全域性變數來搞定。
所以,與具體化的 **設計模式** 相比,我覺得一些更為抽象的 **設計原則** 適用性更廣、更適合運用到 Python 開發工作中。而談到關於 OOP 的設計原則“SOLID” 是眾多原則中最有名的一個。
### SOLID 設計原則
著名的設計模式書籍[《設計模式:可複用面向物件軟體的基礎》](https://book.douban.com/subject/1052241/)出版於 1994 年,距今已有超過 25 年的歷史。而這篇文章的主角: “SOLID 設計原則”同樣也並不年輕。
早在 2000 年,[Robert C. Martin](https://en.wikipedia.org/wiki/Robert_C._Martin) 就在他的文章 "Design Principles and Design Patterns" 中整理並提出了 “SOLID” 設計原則的雛型,之後又在他的經典著作[《敏捷軟體開發 : 原則、模式與實踐》](https://book.douban.com/subject/1140457/)中將其發揚光大。“SOLID” 由 5 個單詞組合的首字母縮寫組成,分別代表 5 條不同的面向物件領域的設計原則。
在編寫 OOP 程式碼時,如果遵循這 5 條設計原則,就更可能寫出可擴充套件、易於修改的程式碼。相反,如果不斷違反其中的一條或多條原則,那麼很快你的程式碼就會變得不可擴充套件、難以維護。
接下來,讓我用一個真實的 Python 程式碼樣例來分別向你詮釋這 5 條設計原則。
> 寫在最前面的注意事項:
>
> 0. “原則”不是“法律”,它只起到指導作用,並非不可以違反
> 1. “原則”的後兩條與介面Interface有關而 Python 沒有介面,所以對這部分的詮釋是我的個人理解,與原版可能略有出入
> 2. 文章後面的內容含有大量程式碼,請做好心理準備 ☕️
> 3. 為了增強程式碼的說明性,本文中的程式碼使用了 Python3 中的 [型別註解特性](https://docs.python.org/3/library/typing.html)
## SOLID 原則與 Python
[Hacker News](https://news.ycombinator.com/)*(後簡稱 HN)* 是一個在程式設計師圈子裡很受歡迎的站點。在它的首頁,有很多由使用者提交後基於推薦演算法排序的科技相關內容。
我經常會去上面看一些熱門文章,但我覺得每次開啟瀏覽器訪問有點麻煩。所以,我準備編寫一個指令碼,自動抓取 HN 首頁 Top5 的新聞標題與連結,並用純文字的方式寫入到檔案。方便自己用其他工具閱讀。
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/06/hackernews_frontpage.png" width="100%" />
Hacker News 首頁截圖
</div>
編寫爬蟲幾乎是 Python 天生的拿手好戲。利用 requests、lxml 等模組提供的好用功能,我可以輕鬆實現上面的需求。下面是我第一次編寫好的程式碼:
```python
import io
import sys
from typing import Generator
import requests
from lxml import etree
class Post:
"""HN(https://news.ycombinator.com/) 上的條目
:param title: 標題
:param link: 連結
:param points: 當前得分
:param comments_cnt: 評論數
"""
def __init__(self, title: str, link: str, points: str, comments_cnt: str):
self.title = title
self.link = link
self.points = int(points)
self.comments_cnt = int(comments_cnt)
class HNTopPostsSpider:
"""抓取 HackerNews Top 內容條目
:param fp: 儲存抓取結果的目標檔案物件
:param limit: 限制條目數,預設為 5
"""
ITEMS_URL = 'https://news.ycombinator.com/'
FILE_TITLE = 'Top news on HN'
def __init__(self, fp: io.TextIOBase, limit: int = 5):
self.fp = fp
self.limit = limit
def fetch(self) -> Generator[Post, None, None]:
"""從 HN 抓取 Top 內容
"""
resp = requests.get(self.ITEMS_URL)
# 使用 XPath 可以方便的從頁面解析出你需要的內容,以下均為頁面解析程式碼
# 如果你對 xpath 不熟悉,可以忽略這些程式碼,直接跳到 yield Post() 部分
html = etree.HTML(resp.text)
items = html.xpath('//table[@class="itemlist"]/tr[@class="athing"]')
for item in items[:self.limit]:
node_title = item.xpath('./td[@class="title"]/a')[0]
node_detail = item.getnext()
points_text = node_detail.xpath('.//span[@class="score"]/text()')
comments_text = node_detail.xpath('.//td/a[last()]/text()')[0]
yield Post(
title=node_title.text,
link=node_title.get('href'),
# 條目可能會沒有評分
points=points_text[0].split()[0] if points_text else '0',
comments_cnt=comments_text.split()[0]
)
def write_to_file(self):
"""以純文字格式將 Top 內容寫入檔案
"""
self.fp.write(f'# {self.FILE_TITLE}\n\n')
# enumerate 接收第二個引數,表示從這個數開始計數(預設為 0
for i, post in enumerate(self.fetch(), 1):
self.fp.write(f'> TOP {i}: {post.title}\n')
self.fp.write(f'> 分數:{post.points} 評論數:{post.comments_cnt}\n')
self.fp.write(f'> 地址:{post.link}\n')
self.fp.write('------\n')
def main():
# with open('/tmp/hn_top5.txt') as fp:
# crawler = HNTopPostsSpider(fp)
# crawler.write_to_file()
# 因為 HNTopPostsSpider 接收任何 file-like 的物件,所以我們可以把 sys.stdout 傳進去
# 實現往控制檯標準輸出列印的功能
crawler = HNTopPostsSpider(sys.stdout)
crawler.write_to_file()
if __name__ == '__main__':
main()
```
你可以把上面的程式碼稱之為符合 OOP 風格的,因為在上面的程式碼裡,我定義了兩個類:
1. `Post`:表示單個 HN 內容條目,其中定義了標題、連結等欄位,是用來銜接“抓取”和“寫入檔案”兩件事情的資料類
2. `HNTopPostsSpider`:抓取 HN 內容的爬蟲類,其中定義了抓取頁面、解析、寫入結果的方法,是完成主要工作的類
如果你本地的 Python 環境配置正常,那麼可以嘗試執行一下上面這段程式碼,它會輸出下面這樣的內容:
```text
python news_digester.py
> TOP 1: Show HN: NoAgeismInTech Job board for companies fighting ageism in tech
> 分數104 評論數26
> 地址https://noageismintech.com/
------
> TOP 2: Magic Leap sues former employee who founded the China-based Nreal for IP theft
> 分數17 評論數2
> 地址https://www.bloomberg.com/news/articles/2019-06-18/secretive-magic-leap-says-ex-engineer-copied-headset-for-china
------
... ...
```
這個指令碼基於面向物件的方式編寫*(換句話說,就是定義了一些 class 😒)*,可以滿足我的需求。但是從設計的角度來看,它卻違反了 SOLID 原則的第一條“Single responsibility principle單一職責原則讓我們來看看是為什麼。
## S單一職責原則
SOLID 設計原則裡的第一個字母 S 來自於 “Single responsibility principle單一職責原則” 的首字母。這個原則認為:**“一個類應該僅僅只有一個被修改的理由。”**換句話說,每個類都應該只有一種職責。
而在上面的程式碼中,`HNTopPostsSpider` 這個類違反了這個原則。因為我們可以很容易的找到兩個不同的修改它的理由:
- **理由 1**: HN 網站的程式設計師突然更新了頁面樣式,舊的 xpath 解析演算法從新頁面上解析不到內容,需要修改 `fetch` 方法內的解析邏輯。
- **理由 2**: 使用者*(也就是我)*突然覺得純文字格式的輸出不好看,想要改成 Markdown 樣式。需要修改 `write_to_file` 方法內的輸出邏輯。
所以,`HNTopPostsSpider` 類違反了“單一職責原則”,因為它有著多個被修改的理由。而這背後的根本原因是因為它承擔著 “抓取帖子列表” 和 "將帖子列表寫入檔案" 這兩種完全不同的職責。
### 違反“單一職責原則”的壞處
如果某個類違反了“單一職責原則”,那意味著我們經常會因為不同的原因去修改它。這可能會導致不同功能之間相互影響。比如,可能我在某天調整了頁面解析邏輯,卻發現輸出的檔案格式也全部亂掉了。
另外,單個類承擔的職責越多,意味著這個類的複雜度也就越高,它的維護成本也同樣會水漲船高。違反“單一職責原則”的類同樣也難以被複用,假如我有其他程式碼想複用 `HNTopPostsSpider` 類的抓取和解析邏輯,會發現我必須要提供一個莫名其妙的檔案物件給它才行。
那麼,要如何修改程式碼才能讓它遵循“單一職責原則”呢?辦法有很多,最傳統的是:**把大類拆分為小類**。
### 拆分大類為多個小類
為了讓 `HNTopPostsSpider` 類的職責更純粹,我們可以把其中與“寫入檔案”相關的內容拆分出去作為一個新的類:
```python
class PostsWriter:
"""負責將帖子列表寫入到檔案
"""
def __init__(self, fp: io.TextIOBase, title: str):
self.fp = fp
self.title = title
def write(self, posts: List[Post]):
self.fp.write(f'# {self.title}\n\n')
# enumerate 接收第二個引數,表示從這個數開始計數(預設為 0
for i, post in enumerate(posts, 1):
self.fp.write(f'> TOP {i}: {post.title}\n')
self.fp.write(f'> 分數:{post.points} 評論數:{post.comments_cnt}\n')
self.fp.write(f'> 地址:{post.link}\n')
self.fp.write('------\n')
```
而在 `HNTopPostsSpider` 類裡,可以透過呼叫 `PostsWriter` 的方式來完成之前的工作:
```python
class HNTopPostsSpider:
FILE_TITLE = 'Top news on HN'
<... 已省略 ...>
def write_to_file(self, fp: io.TextIOBase):
"""以純文字格式將 Top 內容寫入檔案
例項化引數檔案物件 fp 被挪到了 write_to_file 方法中
"""
# 將檔案寫入邏輯託管給 PostsWriter 類處理
writer = PostsWriter(fp, title=self.FILE_TITLE)
writer.write(list(self.fetch()))
```
透過這種方式,我們讓 `HNTopPostsSpider``PostsWriter` 類都各自滿足了“單一職責原則”。我只會因為解析邏輯變動才去修改 `HNTopPostsSpider` 類,同樣,修改 `PostsWriter` 類的原因也只有調整輸出格式一種。這兩個類各自的修改可以單獨進行而不會相互影響。
### 另一種方案:使用函式
“單一職責原則”雖然是針對類說的,但其實它的適用範圍可以超出類本身。比如在 Python 中,透過定義函式,同樣也可以讓上面的程式碼符合單一職責原則。
我們可以把“寫入檔案”的邏輯拆分為一個新的函式,由它來專門承擔起將帖子列表寫入檔案的職責:
```python
def write_posts_to_file(posts: List[Post], fp: io.TextIOBase, title: str):
"""負責將帖子列表寫入檔案
"""
fp.write(f'# {title}\n\n')
for i, post in enumerate(posts, 1):
fp.write(f'> TOP {i}: {post.title}\n')
fp.write(f'> 分數:{post.points} 評論數:{post.comments_cnt}\n')
fp.write(f'> 地址:{post.link}\n')
fp.write('------\n')
```
而對於 `HNTopPostsSpider` 類來說,改動可以更進一步。這次我們可以直接刪除其中和檔案寫入相關的所有程式碼。讓它只負責一件事情:“獲取帖子列表”。
```python
class HNTopPostsSpider:
"""抓取 HackerNews Top 內容條目
:param limit: 限制條目數,預設為 5
"""
ITEMS_URL = 'https://news.ycombinator.com/'
def __init__(self, limit: int = 5):
self.limit = limit
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
```
相應的,類和函式的呼叫方 `main` 函式就需要稍作調整,它需要負責把 `write_posts_to_file` 函式和 `HNTopPostsSpider` 類之間協調起來,共同完成工作:
```python
def main():
crawler = HNTopPostsSpider()
posts = list(crawler.fetch())
file_title = 'Top news on HN'
write_posts_to_file(posts, sys.stdout, file_title)
```
將“檔案寫入”職責拆分為新函式是一個 Python 特色的解決方案,它雖然沒有那麼 OO*(面向物件)*,但是同樣滿足“單一職責原則”,而且在很多場景下更靈活與高效。
## O開放-關閉原則
O 來自於 “Openclosed principle開放-關閉原則)” 的首字母,它認為:**“類應該對擴充套件開放,對修改封閉。”**這是一個從字面上很難理解的原則,它同樣有著另外一種說法:**“你應該可以在不修改某個類的前提下,擴充套件它的行為。”**
這原則聽上去有點讓人犯迷糊,如何能做到不修改程式碼又改變行為呢?讓我來舉一個例子:你知道 Python 裡的內建排序函式 `sorted` 嗎?
如果我們想對某個列表排序,可以直接呼叫 `sorted` 函式:
```python
>>> l = [5, 3, 2, 4, 1]
>>> sorted(l)
[1, 2, 3, 4, 5]
```
現在,假如我們想改變 `sorted` 函式的排序邏輯。比如,讓它使用所有元素對 3 取餘後的結果來排序。我們是不是需要去修改 `sorted` 函式的原始碼?當然不用,只需要在呼叫 `sort` 函式時,傳入自定義的排序函式 `key` 引數就行了:
```python
>>> l = [8, 1, 9]
# 按照元素對 3 的餘數排序,能被 3 整除的 9 排在了最前面,隨後是 1 和 8
>>> sorted(l, key=lambda i: i % 3)
[9, 1, 8]
```
透過上面的例子,我們可以認為:`sorted` 函式是一個符合“開放-關閉原則”的絕佳例子,因為它:
- **對擴充套件開放**:你可以透過傳入自定義 `key` 函式來擴充套件它的行為
- **對修改關閉**:你無需修改 sort 函式本身
### 如何違反“開放-關閉原則”
現在,讓我們回到爬蟲小程式。在使用了一段時間之後,使用者*(還是我)*覺得每次抓取到的內容有點不合口味。我其實只關注那些來自特定網站,比如 github 上的內容。所以我需要修改 `HNTopPostsSpider` 類的程式碼來對結果進行過濾:
```python
class HNTopPostsSpider:
# <... 已省略 ...>
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
counter = 0
for item in items:
if counter >= self.limit:
break
# <... 已省略 ...>
link = node_title.get('href')
# 只關注來自 github.com 的內容
if 'github' in link.lower():
counter += 1
yield Post(... ...)
```
完成修改後,讓我們來簡單測試一下效果:
```text
python news_digester_O_before.py
# Top news on HN
> TOP 1: Mimalloc A compact general-purpose allocator
> 分數291 評論數40
> 地址https://github.com/microsoft/mimalloc
------
> TOP 2: Olivia: An open source chatbot build with a neural network in Go
> 分數53 評論數19
> 地址https://github.com/olivia-ai/olivia
------
<... 已省略 ...>
```
看上去新加的過濾程式碼起到了作用,現在只有連結中含有 `github` 的內容才會被寫入到結果中。
但是,正如某位哲學家的名言所說:*“這世間唯一不變的,只有變化本身。”*某天,使用者*(永遠是我)*突然覺得,來自 `bloomberg` 的內容也都很有意思,所以我想要把 `bloomberg` 也加入篩選關鍵字邏輯裡。
這時我們就會發現:現在的程式碼違反了"開放-關閉原則"。因為我必須要修改現有的 `HNTopPostsSpider` 類程式碼,調整那個 `if 'github' in link.lower()` 判斷語句才能完成我的需求。
“開放-關閉原則”告訴我們,類應該透過擴充套件而不是修改的方式改變自己的行為。那麼我應該如何調整程式碼,讓它可以遵循原則呢?
### 使用類繼承來改造程式碼
繼承是面向物件理論中最重要的概念之一。它允許我們在父類中定義好資料和方法,然後透過繼承的方式讓子類獲得這些內容,並可以選擇性的對其中一些進行重寫,修改它的行為。
使用繼承的方式來讓類遵守“開放-關閉原則”的關鍵點在於:**找到父類中會變動的部分,將其抽象成新的方法(或屬性),最終允許新的子類來重寫它以改變類的行為。**
對於 `HNTopPostsSpider` 類來說。首先,我們需要找到其中會變動的那部分邏輯,也就是*“判斷是否對條目感興趣”*,然後將其抽象出來,定義為新的方法:
```python
class HNTopPostsSpider:
# <... 已省略 ...>
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
for item in items:
# <... 已省略 ...>
post = Post( ... ... )
# 使用測試方法來判斷是否返回該帖子
if self.interested_in_post(post):
counter += 1
yield post
def interested_in_post(self, post: Post) -> bool:
"""判斷是否應該將帖子加入結果中
"""
return True
```
如果我們只關心來自 `github` 的帖子,那麼只需要定義一個繼承於 `HNTopPostsSpider` 子類,然後重寫父類的 `interested_in_post` 方法即可。
```python
class GithubOnlyHNTopPostsSpider(HNTopPostsSpider):
"""只關心來自 Github 的內容
"""
def interested_in_post(self, post: Post) -> bool:
return 'github' in post.link.lower()
def main():
# crawler = HNTopPostsSpider()
# 使用新的子類
crawler = GithubOnlyHNTopPostsSpider()
<... ...>
```
假如我們的興趣發生了變化?沒關係,增加新的子類就行:
```python
class GithubNBloomBergHNTopPostsSpider(HNTopPostsSpider):
"""只關係來自 Github/BloomBerg 的內容
"""
def interested_in_post(self, post: Post) -> bool:
if 'github' in post.link.lower() \
or 'bloomberg' in post.link.lower():
return True
return False
```
所有的這一切,都不需要修改原本的 `HNTopPostsSpider` 類的程式碼,只需要不斷在它的基礎上建立新的子類就能完成新需求。最終實現了對擴充套件開放、對改變關閉。
### 使用組合與依賴注入來改造程式碼
雖然類的繼承特性很強大,但它並非唯一辦法,[依賴注入Dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) 是解決這個問題的另一種思路。與繼承不同,依賴注入允許我們在類例項化時,透過引數將業務邏輯的變化點:**帖子過濾演算法** 注入到類例項中。最終同樣實現“開放-關閉原則”。
首先,我們定義一個名為 `PostFilter` 的抽象類:
```python
from abc import ABC, abstractmethod
class PostFilter(metaclass=ABCMeta):
"""抽象類:定義如何過濾帖子結果
"""
@abstractmethod
def validate(self, post: Post) -> bool:
"""判斷帖子是否應該被保留"""
```
> Hint定義抽象類在 Python 的 OOP 中並不是必須的,你也可以不定義它,直接從下面的 DefaultPostFilter 開始。
然後定義一個繼承於該抽象類的預設 `DefaultPostFilter` 類,過濾邏輯為保留所有結果。之後再調整一下 `HNTopPostsSpider` 類的構造方法,讓它接收一個名為 `post_filter` 的結果過濾器:
```python
class DefaultPostFilter(PostFilter):
"""保留所有帖子
"""
def validate(self, post: Post) -> bool:
return True
class HNTopPostsSpider:
"""抓取 HackerNews Top 內容條目
:param limit: 限制條目數,預設為 5
:param post_filter: 過濾結果條目的演算法,預設為保留所有
"""
ITEMS_URL = 'https://news.ycombinator.com/'
def __init__(self, limit: int = 5, post_filter: Optional[PostFilter] = None):
self.limit = limit
self.post_filter = post_filter or DefaultPostFilter()
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
for item in items:
# <... 已省略 ...>
post = Post( ... ... )
# 使用測試方法來判斷是否返回該帖子
if self.post_filter.validate(post):
counter += 1
yield post
```
預設情況下,`HNTopPostsSpider.fetch` 會保留所有的結果。假如我們想要定義自己的過濾演算法,只要新建自己的 `PostFilter` 類即可,下面是兩個分別過濾 GitHub 與 BloomBerg 的 `PostFilter` 類:
```
class GithubPostFilter(PostFilter):
def validate(self, post: Post) -> bool:
return 'github' in post.link.lower()
class GithubNBloomPostFilter(PostFilter):
def validate(self, post: Post) -> bool:
if 'github' in post.link.lower() or 'bloomberg' in post.link.lower():
return True
return False
```
`main()` 函式中,我可以用不同的 `post_filter` 引數來例項化 `HNTopPostsSpider` 類,最終滿足不同的過濾需求:
```python
def main():
# crawler = HNTopPostsSpider()
# crawler = HNTopPostsSpider(post_filter=GithubPostFilter())
crawler = HNTopPostsSpider(post_filter=GithubNBloomPostFilter())
posts = list(crawler.fetch())
file_title = 'Top news on HN'
write_posts_to_file(posts, sys.stdout, file_title)
```
與基於繼承的方式一樣,利用將“過濾演算法”抽象為 `PostFilter` 類並以例項化引數的方式注入到 `HNTopPostsSpider` 中,我們同樣實現了“開放-關閉原則”。
### 使用資料驅動思想來改造程式碼
在實現“開放-關閉”原則的眾多手法中,除了繼承與依賴注入外,還有一種經常被用到的方式:**“資料驅動”**。這個方式的核心思想在於:**將經常變動的東西,完全以資料的方式抽離出來。當需求變動時,只改動資料,程式碼邏輯保持不動。**
它的原理與“依賴注入”有一些相似,同樣是把變化的東西抽離到類外部。不同的是,後者抽離的通常是類,而前者抽離的是資料。
為了讓 `HNTopPostsSpider` 類的行為可以被資料驅動,我們需要使其接收 `filter_by_link_keywords` 引數:
```python
class HNTopPostsSpider:
"""抓取 HackerNews Top 內容條目
:param limit: 限制條目數,預設為 5
:param filter_by_link_keywords: 過濾結果的關鍵詞列表,預設為 None 不過濾
"""
ITEMS_URL = 'https://news.ycombinator.com/'
def __init__(self,
limit: int = 5,
filter_by_link_keywords: Optional[List[str]] = None):
self.limit = limit
self.filter_by_link_keywords = filter_by_link_keywords
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
for item in items:
# <... 已省略 ...>
post = Post( ... ... )
if self.filter_by_link_keywords is None:
counter += 1
yield post
# 當 link 中出現任意一個關鍵詞時,返回結果
elif any(keyword in post.link for keyword in self.filter_by_link_keywords):
counter += 1
yield post
```
調整了初始化引數後,還需要在 `main` 函式中定義 `link_keywords` 變數並將其傳入到 `HNTopPostsSpider` 類的構造方法中,之後所有針對過濾關鍵詞的調整都只需要修改這個列表即可,無需改動 `HNTopPostsSpider` 類的程式碼,同樣滿足了“開放-關閉原則”。
```python
def main():
# link_keywords = None
link_keywords = [
'github.com',
'bloomberg.com'
]
crawler = HNTopPostsSpider(filter_by_link_keywords=link_keywords)
posts = list(crawler.fetch())
file_title = 'Top news on HN'
write_posts_to_file(posts, sys.stdout, file_title)
```
與前面的繼承和依賴注入方式相比,“資料驅動”的程式碼更簡潔,不需要定義額外的類。但它同樣也存在缺點:**它的可定製性不如前面的兩種方式**。假如,我想要以“連結是否以某個字串結尾”作為新的過濾條件,那麼現在的資料驅動程式碼就有心無力了。
如何選擇合適的方式來讓程式碼符合“開放-關閉原則”,需要根據具體的需求和場景來判斷。這也是一個無法一蹴而就、需要大量練習和經驗積累的過程。
## 總結
在這篇文章中,我透過一個具體的 Python 程式碼案例,向你描述了 “SOLID” 設計原則中的前兩位成員:**“單一職責原則”** 與 **“開放-關閉原則”**。
這兩個原則雖然看上去很簡單,但是它們背後蘊藏了許多從好程式碼中提煉而來的智慧。它們的適用範圍也不僅僅侷限在 OOP 中。一旦你深入理解它們後,你可能會驚奇的在許多設計模式和框架中發現它們的影子*(比如這篇文章就出現了至少 3 種設計模式,你知道是哪些嗎?)*。
讓我們最後再總結一下吧:
- **“S: 單一職責原則”** 認為一個類只應該有一種被修改的原因
- 編寫更小的類通常更不容易違反 S 原則
- S 原則同樣適用於函式,你可以讓函式和類協同工作
- **“O: 開放-關閉原則”** 認為類應該對改動關閉,對擴充套件開放
- 找到需求中頻繁變化的那個點,是讓類遵循 O 原則的重點所在
- 使用子類繼承的方式可以讓類遵守 O 原則
- 透過定義演算法類,並進行依賴注入,也可以讓類遵循 O 原則
- 將資料與邏輯分離,使用資料驅動的方式也是改造程式碼的好辦法
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[>>>下一篇【13.寫好面向物件程式碼的原則(中)】](13-write-solid-python-codes-part-2.md)
[<<<上一篇【11.高效操作檔案的三個建議】](11-three-tips-on-writing-file-related-codes.md)
## 附錄
- 題圖來源: Photo by Kelly Sikkema on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:讓函式返回結果的技巧](https://www.zlovezl.cn/articles/function-returning-tips/)
- [Python 工匠:編寫地道迴圈的兩個建議](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)
- [Python 工匠:高效操作檔案的三個建議](https://www.zlovezl.cn/articles/three-tips-on-writing-file-related-codes/)

View File

@ -0,0 +1,356 @@
# Python 工匠:寫好面向物件程式碼的原則(中)
## 前言
> 這是 “Python 工匠”系列的第 13 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/11/neonbrand-CXDw96Oy-Yw-unsplash_w1280.jpg" width="100%" />
</div>
在 [上一篇文章](https://www.zlovezl.cn/articles/write-solid-python-codes-part-1/) 裡我用一個虛擬小專案作為例子講解了“SOLID”設計原則中的前兩位成員S*(單一職責原則)*與 O*(開放-關閉原則)*。
在這篇文章中,我將繼續介紹 SOLID 原則的第三位成員:**L里氏替換原則**。
## 里氏替換原則與繼承
在開始前,我覺得有必要先提一下 [繼承Inheritance](https://en.wikipedia.org/wiki/Inheritance)。因為和前面兩條非常抽象的原則不同,“里氏替換原則”是一條非常具體的,和類繼承有關的原則。
在 OOP 世界裡,繼承算是一個非常特殊的存在,它有點像一把無堅不摧的雙刃劍,強大且危險。合理使用繼承,可以大大減少類與類之間的重複程式碼,讓程式事半功倍,而不當的繼承關係,則會讓類與類之間建立起錯誤的強耦合,帶來大片難以理解和維護的程式碼。
正是因為這樣,對繼承的態度也可以大致分為兩類。大多數人認為,繼承和多型、封裝等特性一樣,屬於面向物件程式設計的幾大核心特徵之一。而同時有另一部分人覺得,繼承帶來的 [壞處遠比好處多](https://www.javaworld.com/article/2073649/why-extends-is-evil.html)。甚至在 Go 這門相對年輕的程式語言裡,設計者直接去掉了繼承,提倡完全使用組合來替代。
從我個人的程式設計經驗來看,繼承確實極易被誤用。要設計出合理的繼承關係,是一件需要深思熟慮的困難事兒。不過幸運的是,在這方面,"里氏替換原則"*(後簡稱 L 原則)* 為我們提供了非常好的指導意義。
讓我們來看看它的內容。
## L里氏替換原則
同前面的 S 與 O 兩個原則的命名方式不同,里氏替換原則*Liskov Substitution Principle*是直接用它的發明者 [Barbara Liskov](https://en.wikipedia.org/wiki/Barbara_Liskov) 命名的,原文看起來像一個複雜的數學公式:
> Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.
> - 出處: [Liskov substitution principle - Wikipedia](https://en.wikipedia.org/wiki/Liskov_substitution_principle)
如果把它比較通俗的翻譯過來,大概是這樣:**當你使用繼承時,子類(派生類)物件應該可以在程式中替代父類(基類)物件使用,而不破壞程式原本的功能。**
光說有點難理解,讓我們用程式碼來看看一個在 Python 中違反 Liskov 原則的例子。
## 一個違反 L 原則的樣例
假設我們在為一個 Web 站點設計使用者模型。這個站點的使用者分為兩類:普通使用者和站點管理員。所以在程式碼裡,我們定義了兩個使用者類:普通使用者類 `User` 和管理員類 `Admin`
```python
class User(Model):
"""普通使用者模型類
"""
def __init__(self, username: str):
self.username = username
def deactivate(self):
"""停用當前使用者
"""
self.is_active = False
self.save()
class Admin(User):
"""管理員使用者類
"""
def deactivate(self):
# 管理員使用者不允許被停用
raise RuntimeError('admin can not be deactivated!')
```
因為普通使用者的絕大多數操作在管理員上都適用,所以我們把 `Admin` 類設計成了繼承自 `User` 類的子類。不過在“停用”操作方面,管理員和普通使用者之間又有所區別: **普通使用者可以被停用,但管理員不行。**
於是在 `Admin` 類裡,我們重寫了 `deactivate` 方法,使其丟擲一個 `RuntimeError` 異常,讓管理員物件無法被停用。
子類繼承父類,然後重寫父類的少量行為,這看上去正是類繼承的典型用法。但不幸的是,這段程式碼違反了“里氏替換原則”。具體是怎麼回事呢?讓我們來看看。
### 不當繼承關係如何違反 L 原則
現在,假設我們需要寫一個新函式,它可以同時接受多個使用者物件作為引數,批次將它們停用。程式碼如下:
```python
def deactivate_users(users: Iterable[User]):
"""批次停用多個使用者
"""
for user in users:
user.deactivate()
```
很明顯,上面的程式碼是有問題的。因為 `deactivate_users` 函式在引數註解裡寫到,它接受一切 **可被迭代的 User 物件**,那麼管理員 `Admin` 是不是 `User` 物件?當然是,因為它是繼承自 `User` 類的子類。
但是,如果你真的把 `[User("foo"), Admin("bar_admin")]` 這樣的使用者列表傳到 `deactivate_users` 函式裡,程式立馬就會丟擲 `RuntimeError` 異常,因為管理員物件 `Admin("bar_admin")` 壓根不支援停用操作。
`deactivate_users` 函式看來,子類 `Admin` 無法隨意替換父類 `User` 使用,所以現在的程式碼是不符合 L 原則的。
### 一個簡單但錯誤的解決辦法
要修復上面的函式,最直接的辦法就是在函式內部增加一個額外的型別判斷:
```python
def deactivate_users(users: Iterable[User]):
"""批次停用多個使用者
"""
for user in users:
# 管理員使用者不支援 deactivate 方法,跳過
if isinstance(user, Admin):
logger.info(f'skip deactivating admin user {user.username}')
continue
user.deactivate()
```
在修改版的 `deactivate_users` 函式裡,如果它在迴圈時恰好發現某個使用者是 `Admin` 類,就跳過這次操作。這樣它就能正確處理那些混合了管理員的使用者列表了。
但是,這樣修改的缺點是顯而易見的。因為雖然到目前為止,只有 `Admin` 型別的使用者不允許被停用。但是,**誰能保證未來不會出現其他不能被停用的使用者型別呢?**比如:
- 公司員工不允許被停用
- VIP 使用者不允許被停用
- 等等(... ...)
而當這些新需求在未來不斷出現時,我們就需要重複的修改 `deactivate_users` 函式,來不斷適配這些無法被停用的新使用者型別。
```python
def deactivate_users(users: Iterable[User]):
for user in users:
# 在型別判斷語句不斷追加新使用者型別
if isinstance(user, (Admin, VIPUser, Staff)):
... ...
```
現在,讓我們再回憶一下前面的 SOLID 第二原則:**“開放-關閉原則”**。這條原則認為:好的程式碼應該對擴充套件開發,**對修改關閉**。而上面的函式很明顯不符合這條原則。
到這裡你會發現,**SOLID 裡的每條原則並非完全獨立的個體,它們之間其實互有聯絡。**比如,在這個例子裡,我們先是違反了“里氏替換原則”,然後我們使用了錯誤的修復方式:*增加型別判斷*。之後發現,這樣的程式碼同樣也無法符合“開放-關閉原則”。
### 正確的修改辦法
既然為函式增加型別判斷無法讓程式碼變得更好,那我們就應該從別的方面入手。
“里氏替換原則”提到,**子類*Admin*應該可以隨意替換它的父類*User*,而不破壞程式*deactivate_users*本身的功能。**我們試過直接修改類的使用者來遵守這條原則,但是失敗了。所以這次,讓我們試著從源頭上解決問題:重新設計類之間的繼承關係。
具體點來說,子類不能只是簡單透過丟擲異常的方式對某個類方法進行“退化”。如果 *“物件不能支援某種操作”* 本身就是這個型別的 **核心特徵** 之一,那我們在進行父類設計時,就應該把這個 **核心特徵** 設計進去。
拿使用者型別舉例,*“使用者可能無法被停用”* 就是 `User` 類的核心特徵之一,所以在設計父類時,我們就應該把它作為類方法*(或屬性)*寫進去。
讓我們看看調整後的程式碼:
```python
class User(Model):
"""普通使用者模型類
"""
def __init__(self, username: str):
self.username = username
def allow_deactivate(self) -> bool:
"""是否允許被停用
"""
return True
def deactivate(self):
"""將當前使用者停用
"""
self.is_active = True
self.save()
class Admin(User):
"""管理員使用者類
"""
def allow_deactivate(self) -> bool:
# 管理員使用者不允許被停用
return False
def deactivate_users(users: Iterable[User]):
"""批次停用多個使用者
"""
for user in users:
if not user.allow_deactivate():
logger.info(f'user {user.username} does not allow deactivating, skip.')
continue
user.deactivate()
```
在新程式碼裡,我們在父類中增加了 `allow_deactivate` 方法,由它來決定當前的使用者型別是否允許被停用。而在 `deactivate_users` 函式中,也不再需要透過脆弱的型別判斷,來判定某類使用者是否可以被停用。我們只需要呼叫 `user.allow_deactivate()` 方法,程式便能自動跳過那些不支援停用操作的使用者物件。
在這樣的設計中,`User` 類的子類 `Admin` 做到了可以完全替代父類使用,而不會破壞程式 `deactivate_users` 的功能。
所以我們可以說,修改後的類繼承結構是符合里氏替換原則的。
## 另一種違反方式:子類修改方法返回值
除了上面的例子外,還有一種常見的違反里氏替換原則的情況。讓我們看看下面這段程式碼:
```python
class User(Model):
"""普通使用者模型類
"""
def __init__(self, username: str):
self.username = username
def list_related_posts(self) -> List[int]:
"""查詢所有與之相關的帖子 ID
"""
return [post.id for post in session.query(Post).filter(username=self.username)]
class Admin(User):
"""管理員使用者類
"""
def list_related_posts(self) -> Iterable[int]:
# 管理員與所有的帖子都有關,為了節約記憶體,使用生成器返回帖子 ID
for post in session.query(Post).all():
yield post.id
```
在這段程式碼裡,我給使用者類增加了一個新方法:`list_related_posts`,呼叫它可以拿到所有和當前使用者有關的帖子 ID。對於普通使用者方法返回的是自己釋出過的所有帖子而管理員則是站點裡的所有帖子。
現在,假設我需要寫一個函式,來獲取和使用者有關的所有帖子標題:
```python
def list_user_post_titles(user: User) -> Iterable[str]:
"""獲取與使用者有關的所有帖子標題
"""
for post_id in user.list_related_posts():
yield session.query(Post).get(post_id).title
```
對於上面的 `list_user_post_titles` 函式來說,無論傳入的 `user` 引數是 `User` 還是 `Admin` 型別,它都能正常工作。因為,雖然普通使用者和管理員型別的 `list_related_posts` 方法返回結果略有區別,但它們都是**“可迭代的帖子 ID”**,所以函式裡的迴圈在碰到不同的使用者型別時都能正常進行。
既然如此,那上面的程式碼符合“里氏替換原則”嗎?答案是否定的。因為雖然在當前 `list_user_post_titles` 函式的視角看來,子類 `Admin` 可以任意替代父類 `User` 使用,但這只是特殊用例下的一個巧合,並沒有通用性。請看看下面這個場景。
有一位新成員最近加入了專案開發,她需要實現一個新函式來獲取與使用者有關的所有帖子數量。當她讀到 `User` 類程式碼時,發現 `list_related_posts` 方法返回一個包含所有帖子 ID 的列表,於是她就此寫下了統計帖子數量的程式碼:
```python
def get_user_posts_count(user: User) -> int:
"""獲取與使用者相關的帖子個數
"""
return len(user.list_related_posts())
```
在大多數情況下,當 `user` 引數只是普通使用者類時,上面的函式是可以正常執行的。
不過有一天,有其他人偶然使用了一個管理員使用者呼叫了上面的函式,馬上就碰到了異常:`TypeError: object of type 'generator' has no len()`。這時因為 `Admin` 雖然是 `User` 型別的子類,但它的 `list_related_posts` 方法返回卻是一個可迭代的生成器,並不是列表物件。而生成器是不支援 `len()` 操作的。
所以,對於新的 `get_user_posts_count` 函式來說,現在的使用者類繼承結構仍然違反了 L 原則。
### 分析類方法返回結果
在我們的程式碼裡,`User` 類和 `Admin` 類的 `list_related_posts` 返回的是兩類不同的結果:
- `User 類`:返回一個包含帖子 ID 的列表物件
- `Admin 類`:返回一個產生帖子 ID 的生成器
很明顯,二者之間存在共通點:它們都是可被迭代的 int 物件(`Iterable[int]`)。這也是為什麼對於第一個獲取使用者帖子標題的函式來說,兩個使用者類可以互相交換使用的原因。
不過,針對某個特定函式,子類可以替代父類使用,並不等同於程式碼就符合“里氏替換原則”。要符合 L 原則,**我們一定得讓子類方法和父類返回同一型別的結果,支援同樣的操作。或者更進一步,返回支援更多種操作的子型別結果也是可以接受的。**
而現在的設計沒做到這點,現在的子類返回值所支援的操作,只是父類的一個子集。`Admin` 子類的 `list_related_posts` 方法所返回的生成器,只支援父類 `User` 返回列表裡的“迭代操作”,而不支援其他行為(比如 `len()`)。所以我們沒辦法隨意的用子類替換父類,自然也就無法符合里氏替換原則。
> **注意:**此處說“生成器”支援的操作是“列表”的子集其實不是特別嚴謹,因為生成器還支援 `.send()` 等其他操作。不過在這裡,我們可以只關注它的可迭代特性。
### 如何修改程式碼
為了讓程式碼符合“里氏替換原則”。我們需要讓子類和父類的同名方法,返回同一類結果。
```python
class User(Model):
"""普通使用者模型類
"""
def __init__(self, username: str):
self.username = username
def list_related_posts(self) -> Iterable[int]:
"""查詢所有與之相關的帖子 ID
"""
for post in session.query(Post).filter(username=self.username):
yield post.id
def get_related_posts_count(self) -> int:
"""獲取與使用者有關的帖子總數
"""
value = 0
for _ in self.list_related_posts():
value += 1
return value
class Admin(User):
"""管理員使用者類
"""
def list_related_posts(self) -> Iterable[int]:
# 管理員與所有的帖子都有關,為了節約記憶體,使用生成器返回
for post in session.query(Post).all():
yield post.id
```
而對於“獲取與使用者有關的帖子總數”這個需求,我們可以直接在父類 `User` 中定義一個 `get_related_posts_count` 方法,遍歷帖子 ID統計數量後返回。
### 方法引數與 L 原則
除了子類方法返回不一致的型別以外,子類對父類方法引數的變更也容易導致違反 L 原則。拿下面這段程式碼為例:
```python
class User(Model):
def list_related_posts(self, include_hidden: bool = False) -> List[int]:
# ... ...
class Admin(User):
def list_related_posts(self) -> List[int]:
# ... ...
```
如果父類 `User``list_related_posts` 方法接收一個可選的 `include_hidden` 引數,那它的子類就不應該去掉這個引數。否則當某個函式呼叫依賴了 `include_hidden` 引數,但使用者物件卻是子類 `Admin` 型別時,程式就會報錯。
為了讓程式碼符合 L 原則,我們必須做到 **讓子類的方法引數簽名和父類完全一致,或者更寬鬆**。這樣才能做到在任何使用引數呼叫父類方法的地方,隨意用子類替換。
比如下面這樣就是符合 L 原則的:
```python
class User(Model):
def list_related_posts(self, include_hidden: bool = False) -> List[int]:
# ... ...
class Admin(User):
def list_related_posts(self, include_hidden: bool = False, active_only = True) -> List[int]:
# 子類可以為方法增加額外的可選引數active_only
# ... ...
```
## 總結
在這篇文章裡,我透過兩個具體場景,向你描述了 “SOLID” 設計原則中的第三位成員:**里氏替換原則**。
“里氏替換原則”是一個非常具體的原則,它專門為 OOP 裡的繼承場景服務。當你設計類繼承關係,尤其是編寫子類程式碼時,請經常性的問自己這個問題:*“如果我把專案裡所有使用父類的地方換成這個子類,程式是否還能正常執行?”*
如果答案是否定的,那麼你就應該考慮調整一下現在的類設計了。調整方式有很多種,有時候你得把大類拆分為更小的類,有時候你得調換類之間的繼承關係,有時候你得為父類新增新的方法和屬性,就像文章裡的第一個場景一樣。只要開動腦筋,總會找到合適的辦法。
讓我們最後再總結一下吧:
- **“L里氏替換原則”**認為子類應該可以任意替換父類被使用
- 在類的使用方增加具體的型別判斷(*isinstance*),通常不是最佳解決方案
- 違反里氏替換原則,通常也會導致違反“開放-關閉”原則
- 考慮什麼是類的核心特徵,然後為父類增加新的方法和屬性可以幫到你
- 子類方法應該和父類同名方法返回同一型別,或者返回支援更多操作的子型別也行
- 子類的方法引數應該和父類同名方法完全一致,或者更為寬鬆
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[>>>下一篇【14.寫好面向物件程式碼的原則(下)】](14-write-solid-python-codes-part-3.md)
[<<<上一篇【12.寫好面向物件程式碼的原則(上)】](12-write-solid-python-codes-part-1.md)
## 附錄
- 題圖來源: Photo by NeONBRAND on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:寫好面向物件程式碼的原則(上)](https://www.zlovezl.cn/articles/write-solid-python-codes-part-1/)
- [Python 工匠:編寫地道迴圈的兩個建議](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)
- [Python 工匠:高效操作檔案的三個建議](https://www.zlovezl.cn/articles/three-tips-on-writing-file-related-codes/)

View File

@ -0,0 +1,550 @@
# Python 工匠:寫好面向物件程式碼的原則(下)
## 前言
> 這是 “Python 工匠”系列的第 14 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/02/carolina-garcia-tavizon-w1280.jpg" width="100%" />
</div>
在這篇文章中,我將繼續介紹 SOLID 原則剩下的兩位成員:**I介面隔離原則** 和 **D依賴倒置原則**。為了方便,這篇文章將會使用先 D 後 I 的順序。
## D依賴倒置原則
軟體是由一個個模組組合而成的。當你跟別人說:*“我在寫一個很複雜的軟體”*,其實你並不是直接在寫那個軟體,你只是在編寫它的一個個模組,最後把它們放在一起組合成你的軟體。
有了模組,模組間自然就有了依賴關係。比如,你的個人部落格可能依賴著 Flask 框架,而 Flask 又依賴了 WerkzeugWerkzeug 又由更多個低層模組組成。
依賴倒置原則Dependency Inversion Principle就是一條和依賴關係相關的原則。它認為**“高層模組不應該依賴於低層模組,二者都應該依賴於抽象。”**
> High-level modules should not depend on low-level modules. Both should depend on abstractions.
這個原則看上去有點反直覺。畢竟,在我們的第一堂程式設計課上,老師就是這麼教我們寫程式碼的:*“高層模組要依賴低層模組hello world 程式依賴 printf()。”*那為什麼這條原則又說不要這樣做呢?而依賴倒置原則裡的“倒置”又是指什麼?
讓我們先把這些問題放在一邊,看看下面這個小需求。上面這些問題的答案都藏在這個需求中。
### 需求:按域名分組統計 HN 新聞數量
這次出場的還是我們的老朋友:新聞站點 [Hacker News](https://news.ycombinator.com/)。在 HN 上,每個使用者提交的條目標題後面,都跟著這條內容的來源域名。
我想要按照來源域名來分組統計條目數量,這樣就能知道哪個站在 HN 上最受歡迎。
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/02/SOLID_3_hn.jpg" width="100%" />
Hacker News 條目來源截圖
</div>
這個需求非常簡單,使用 `requests`、`lxml` 模組可以很快完成任務:
```python
# file: hn_site_grouper.py
import requests
from lxml import etree
from typing import Dict
from collections import Counter
class SiteSourceGrouper:
"""對 HN 頁面的新聞來源站點進行分組統計
"""
def __init__(self, url: str):
self.url = url
def get_groups(self) -> Dict[str, int]:
"""獲取 (域名, 個數) 分組
"""
resp = requests.get(self.url)
html = etree.HTML(resp.text)
# 透過 xpath 語法篩選新聞域名標籤
elems = html.xpath('//table[@class="itemlist"]//span[@class="sitestr"]')
groups = Counter()
for elem in elems:
groups.update([elem.text])
return groups
def main():
groups = SiteSourceGrouper("https://news.ycombinator.com/").get_groups()
# 列印最常見的 3 個域名
for key, value in groups.most_common(3):
print(f'Site: {key} | Count: {value}')
if __name__ == '__main__':
main()
```
程式碼執行結果:
```bash
python hn_sitestr_grouper.py
Site: github.com | Count: 2
Site: howonlee.github.io | Count: 1
Site: latimes.com | Count: 1
```
這段程式碼很短,核心程式碼總共不到 20 行。現在,讓我們來理一理它裡面的依賴關係。
`SiteSourceGrouper` 是我們的核心類。為了完成任務,它需要使用 `requests` 模組獲取首頁內容、`lxml` 模組解析標題。所以,現在的依賴關係是“正向”的,高層模組 `SiteSourceGrouper` 依賴低層模組 `requests`、`lxml`。
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/02/SOLID_D_before.png" width="100%" />
SiteSourceGrouper 依賴 requests、lxml
</div>
也許現在這張圖在你眼裡看起來特別合理。正常的依賴關係不就應該是這樣的嗎?彆著急,我們還沒給程式碼寫單元測試呢。
### 為 SiteSourceGrouper 編寫單元測試
現在讓我來為這段程式碼加上單元測試。首先讓最普通的情況開始:
```python
from hn_site_grouper import SiteSourceGrouper
from collections import Counter
def test_grouper_returning_valid_types():
"""測試 get_groups 是否返回了正確型別
"""
grouper = SiteSourceGrouper('https://news.ycombinator.com/')
result = grouper.get_groups()
assert isinstance(result, Counter), "groups should be Counter instance"
```
這是一個再簡單不過的單元測試,我呼叫了 `SiteSourceGrouper.get_groups()` 方法,然後簡單校驗了一下返回結果型別是否正常。
這個測試在本地電腦上執行時沒有一點問題,可以正常透過。但當我在伺服器上執行這段單元測試程式碼時,卻發現它根本沒辦法成功。因為 **我的伺服器不能訪問外網。**
```python
# 執行單元測試時提示網路錯誤
requests.exceptions.ConnectionError: HTTPSConnectionPool(host='news.ycombinator.com', port=443): ... ... [Errno 8] nodename nor servname provided, or not known'))
```
到這裡,單元測試暴露了 `SiteSourceGrouper` 類的一個問題:*它的核心邏輯依賴 requests 模組和網路連線,嚴格限制了單元測試的執行條件。*
既然如此,那要如何解決這個問題呢?如果你去問一個有經驗的 Python 的開發者,十有八九他會甩給你一句話:**“用 mock 啊!”**
#### 使用 mock 模組
[mock](https://docs.python.org/3/library/unittest.mock.html) 是 unittest 裡的一個模組,同時也是一類測試手法的統稱。假如你需要測試的模組裡有一部分依賴很難被滿足*(比如程式碼需要訪問一整套 Kubernetes 叢集)*,或者你想在測試時故意替換掉某些依賴,那麼 mock 就能派上用場。
在這個例子裡,使用 unittest.mock 模組需要做下面這些事情:
- 把一份正確的 HN 頁面內容儲存為本地檔案 `static_hn.html`
- 在測試檔案中匯入 `unittest.mock` 模組
- 在測試函式中,透過 [`mock.path('requests.get')`](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch) 替換網路請求部分
- 將其修改為直接返回檔案 `static_hn.html` 的內容
使用 mock 後的程式碼看起來是這樣的:
```python
from unittest import mock
def test_grouper_returning_valid_types():
"""測試 get_groups 是否返回了正確型別
"""
resp = mock.Mock()
# Mock 掉 requests.get 函式
with mock.patch('hn_site_grouper.requests.get') as mocked_get:
mocked_get.return_value = resp
with open('static_hn.html', 'r') as fp:
# Mock 掉響應的 text 欄位
resp.text = fp.read()
grouper = SiteSourceGrouper('https://news.ycombinator.com/')
result = grouper.get_groups()
assert isinstance(result, Counter), "groups should be Counter instance"
```
上面的程式碼並不算複雜。對於 Python 這類動態語言來說,使用 mock 有著一種得天獨厚的優勢。因為在 Python 裡,執行時的一切物件幾乎都可以被替換掉。
不過雖然 mock 用起來很方便,但它不是解決我們問題的最佳做法。因為 mock 在帶來方便的同時,也讓測試程式碼變得更復雜和難以理解。而且,給測試加上 mock 也僅僅只是讓我的單元測試能夠跑起來,糟糕設計仍然是糟糕設計。它無法體現出單元測試最重要的價值之一:**“透過編寫測試反向推動設計改進”**。
所以,我們需要做的是改進依賴關係,而不只是簡單的在測試時把依賴模組替換掉。如何改進依賴關係?讓我們看看“依賴倒置”是如何做的。
### 實現依賴倒置原則
首先,讓我們重溫一下“依賴倒置原則”*(後簡稱 D 原則)*的內容:**“高層模組不應該依賴於低層模組,二者都應該依賴於抽象。”**
在上面的程式碼裡,高層模組 `SiteSourceGrouper` 就直接依賴了低層模組 `requests`。為了讓程式碼符合 D 原則,我們首先需要創造一個處於二者中間的抽象,然後讓兩個模組可以都依賴這個新的抽象層。
建立抽象的第一步*(可能也是最重要的一步)*,就是確定這個抽象層的職責。在例子中,高層模組主要依賴 `requests` 做了這些事:
- 透過 `requests.get()` 獲取 response
- 透過 `response.text` 獲取響應文字
所以,這個抽象層的主要職責就是產生 HN 站點的頁面文字。我們可以給它起個名字:`HNWebPage`。
確定了抽象層的職責和名字後,接下來應該怎麼實現它呢?在 Java 或 Go 語言裡,標準答案是定義 **Interface**(介面)。因為對於這些程式語言來說,“介面”這兩個字基本就可以等同於“抽象”。
拿 Go 來說“Hacker News 站點頁面”這層抽象就可以被定義成這樣的 Interface
```go
type HNWebPage interface {
// GetText 獲取頁面文字
GetText() (string, error)
}
```
不過Python 根本沒有介面這種東西。那該怎麼辦呢?雖然 Python 沒有介面,但是有一個非常類似的東西:**“抽象類Abstrace Class”**。使用 [`abc`](https://docs.python.org/3/library/abc.html) 模組就可以輕鬆定義出一個抽象類:
```
from abc import ABCMeta, abstractmethod
class HNWebPage(metaclass=ABCMeta):
"""抽象類Hacker New 站點頁面
"""
@abstractmethod
def get_text(self) -> str:
raise NotImplementedError
```
抽象類和普通類的區別之一就是你不能將它例項化。如果你嘗試例項化一個抽象類,直譯器會報出下面的錯誤:
```
TypeError: Can't instantiate abstract class HNWebPage with abstract methods get_text
```
所以,光有抽象類還不能算完事,我們還得定義幾個依賴這個抽象類的實體。首先定義的是 `RemoteHNWebPage` 類。它的作用就是透過 requests 模組請求 HN 頁面,返回頁面內容。
```
class RemoteHNWebPage(HNWebPage):
"""遠端頁面,透過請求 HN 站點返回內容"""
def __init__(self, url: str):
self.url = url
def get_text(self) -> str:
resp = requests.get(self.url)
return resp.text
```
定義了 `RemoteHNWebPage` 類後,`SiteSourceGrouper` 類的初始化方法和 `get_groups` 也需要做對應的調整:
```
class SiteSourceGrouper:
"""對 HN 頁面的新聞來源站點進行分組統計
"""
def __init__(self, page: HNWebPage):
self.page = page
def get_groups(self) -> Dict[str, int]:
"""獲取 (域名, 個數) 分組
"""
html = etree.HTML(self.page.get_text())
# 透過 xpath 語法篩選新聞域名標籤
elems = html.xpath('//table[@class="itemlist"]//span[@class="sitestr"]')
groups = Counter()
for elem in elems:
groups.update([elem.text])
return groups
def main():
# 例項化 page傳入 SiteSourceGrouper
page = RemoteHNWebPage(url="https://news.ycombinator.com/")
grouper = SiteSourceGrouper(page).get_groups()
```
做完這些修改後,讓我們再看看現在的模組依賴關係:
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/02/SOLID_D_after.png" width="100%" />
SiteSourceGrouper 和 RemoteHNWebPage 都依賴抽象層 HNWebPage
</div>
在圖中,高層模組不再依賴低層模組,二者同時依賴於抽象概念 `HNWebPage`,低層模組的依賴箭頭和之前相比倒過來了。所以我們稱其為 **依賴倒置**
### 依賴倒置後的單元測試
再回到之前的單元測試上來。透過引入了新的抽象層 `HNWebPage`,我們可以實現一個不依賴外部網路的新型別 `LocalHNWebPage`
```
class LocalHNWebPage(HNWebPage):
"""本地頁面,根據本地檔案返回頁面內容"""
def __init__(self, path: str):
self.path = path
def get_text(self) -> str:
with open(self.path, 'r') as fp:
return fp.read()
```
所以,單元測試也可以改為使用 `LocalHNWebPage`
```
def test_grouper_from_local():
page = LocalHNWebPage(path="./static_hn.html")
grouper = SiteSourceGrouper(page)
result = grouper.get_groups()
assert isinstance(result, Counter), "groups should be Counter instance"
```
這樣就可以在沒有外網的伺服器上測試 `SiteSourceGrouper` 類的核心邏輯了。
> Hint其實上面的測試函式 `test_grouper_from_local` 遠遠算不上一個合格的測試用例。
>
> 如果真要測試 `SiteSourceGrouper` 的核心邏輯。我們應該準備一個虛構的 Hacker News 頁面 *(比如剛好包含 5 個 來源自 github.com 的條目)*,然後判斷結果是否包含 `assert result['github.com] == 5`
### 問題:一定要使用抽象類 abc 嗎?
為了實現依賴倒置,我們在上面定義了抽象類:`HNWebPage`。那是不是隻有定義了抽象類才能實現依賴倒置?只有用了抽象類才算是依賴倒置呢?
**答案是否定的。** 如果你願意,你可以把程式碼裡的抽象類 `HNWebPage` 以及所有的相關引用都刪掉,你會發現沒有它們程式碼仍然可以正常執行。
這是因為 Python 是一門“鴨子型別”語言。這意味著只要 `RemoteHNWebPage``LocalHNWebPage` 型別保持著統一的介面協議*(提供 .get_text() 公開方法)*,並且它們的 **協議符合我們定義的抽象**。那麼那個中間層就存在,依賴倒置就是成立的。至於這份 **協議** 是透過抽象類還是普通父類(甚至可以是普通函式)定義的,就沒那麼重要了。
所以,雖然在某些程式語言中,實現依賴倒置必須得定義新的介面型別,但在 Python 裡,依賴倒置並不是抽象類 abc 的特權。
### 問題:抽象一定是好東西嗎?
前面的所有內容,都是在說新增一個抽象層,然後讓依賴關係倒過來的種種好處。所以,多抽象的程式碼一定就是好的嗎?缺少抽象的程式碼就一定不夠靈活?
和所有這類問題的標準回答一樣,答案是:**視情況而定。**
當你習慣了依賴倒置原則以後,你會發現 *抽象Abstract* 其實是一種思維方式,而不僅僅是一種程式設計手法。如果你願意,你可以在程式碼裡的所有地方都 **硬擠** 一層額外抽象出來:
- 比如程式碼依賴了 lxml 模組的 xpath 具體實現,我是不是得定義一層 *“HNTitleDigester”* 把它抽象進去?
- 比如程式碼裡的字串字面量也是具體實現,我是不是得定義一個 *"StringLike"* 型別把它抽象進去?
- ... ...
事實上,抽象的好處顯而易見:**它解耦了高層模組和低層模組間的依賴關係,讓程式碼變得更靈活。** 但抽象同時也帶來了額外的編碼與理解成本。所以,瞭解何時 **不** 抽象與何時抽象同樣重要。**只有對程式碼中那些現在或未來會發生變化的東西進行抽象,才能獲得最大的收益。**
## I介面隔離原則
介面隔離原則*(後簡稱 I 原則)*全稱為 *“Interface Segregation Principles”*。顧名思義它是一條和“介面Interface”有關的原則。
我在前面解釋過何為“介面Interface”。**介面是模組間相互交流的抽象協議**,它在不同的程式語言裡有著不同的表現形態。比如在 Go 裡它是 `type ... interface`,而在 Python 中它可以是抽象類、普通類或者函式,甚至某個只在你大腦裡存在的一套協議。
I 原則認為:**“客戶client應該不依賴於它不使用的方法”**
> The interface-segregation principle (ISP) states that no client should be forced to depend on methods it does not use.
這裡說的“客戶Client”指的是介面的使用方 *(客戶程式)*,也就是呼叫介面方法的高層模組。拿上一個統計 HN 頁面條目的例子來說:
- `使用方(客戶程式)`SiteSourceGrouper
- `介面(其實是抽象類)`HNWebPage
- `依賴關係`:呼叫介面方法:`get_text()` 獲取頁面文字
在 I 原則看來,**一個介面所提供的方法,應該就是使用方所需要的方法,不多不少剛剛好。** 所以,在上個例子裡,我們設計的介面 `HNWebPage` 是符合介面隔離原則的。因為它沒有向使用方提供任何後者不需要的方法 。
> 你需要 get_text()!我提供 get_text()!剛剛好!
所以,這條原則看上去似乎很容易遵守。既然如此,讓我們試試來違反它吧!
### 例子:開發頁面歸檔功能
讓我們接著上一個例子開始。在實現了上個需求後,我現在有一個代表 Hacker News 站點頁面的抽象類 `HNWebPage`,它只提供了一種行為,就是獲取當前頁面的文字內容。
```python
class HNWebPage(metaclass=ABCMeta):
@abstractmethod
def get_text(self) -> str:
"""獲取頁面文字內容"""
```
現在,假設我要開發一個和 HN 頁面有關的新功能: **我想在不同時間點對 HN 首頁內容進行歸檔,觀察熱點新聞在不同時間點發生的變化。** 所以除了頁面文字內容外,我還需要拿到頁面的大小、生成時間這些額外資訊,然後將它們都儲存到資料庫中。
為了做到這一點,現在的 `HNWebPage` 類需要被擴充套件一下:
```python
class HNWebPage(metaclass=ABCMeta):
@abstractmethod
def get_text(self) -> str:
"""獲取頁面文字內容"""
# 新增 get_size 與 get_generated_at
@abstractmethod
def get_size(self) -> int:
"""獲取頁面大小"""
@abstractmethod
def get_generated_at(self) -> datetime.datetime:
"""獲取頁面生成時間"""
```
我在原來的類上增加了兩個新的抽象方法:`get_size` 和 `get_generated_at`。這樣歸檔程式就能透過它們拿到頁面大小和生成時間了。
改完抽象類後,緊接著的任務就是修改依賴它的實體類。
### 問題:實體類不符合 HNWebPage 介面規範
在修改抽象類前,我們有兩個實現了它協議的實體類:`RemoteHNWebPage` 和 `LocalHNWebPage`。如今,`HNWebPage` 增加了兩個新方法 `get_size``get_generated_at`。我們自然需要把這兩個實體類也加上這兩個方法。
`RemoteHNWebPage` 類的修改很好做,我們只要讓 `get_size` 放回頁面長度,讓 `get_generated_at` 返回當前時間就行了。
```python
# class RemoteHNWebPage:
#
def get_generated_at(self) -> datetime.datetime:
# 頁面生成時間等同於透過 requests 請求的時間
return datetime.datetime.now()
```
但是,在給 `LocalHNWebPage` 新增 `get_generated_at` 方法時,我碰到了一個問題。`LocalHNWebPage` 是一個完全基於本地頁面檔案作為資料來源的類,僅僅透過 “static_hn.html” 這麼一個本地檔案,我根本就沒法知道它的內容是什麼時候生成的。
這時我只能選擇讓它的 `get_generated_at` 方法返回一個錯誤的結果*(比如檔案的修改時間)*,或者直接丟擲異常。無論是哪種做法,我都可能違反 [裡式替換原則](https://www.zlovezl.cn/articles/write-solid-python-codes-part-2/)。
> Hint裡式替換原則認為子類派生類物件應該可以在程式中替代父類基類物件使用而不破壞程式原本的功能。讓方法丟擲異常顯然破壞了這一點。
```python
# class LocalHNWebPage:
#
def get_generated_at(self) -> datetime.datetime:
raise NotImplementedError("local web page can not provide generate_at info")
```
所以,對現有介面的盲目擴充套件暴露出來一個問題:**更多的介面方法意味著更高的實現成本,給實現方帶來麻煩的概率也變高了。**
不過現在讓我們暫且把這個問題放到一邊,繼續寫一個 `SiteAchiever` 類完成歸檔任務:
```python
class SiteAchiever:
"""將不同時間點的 HN 頁面歸檔"""
def save_page(self, page: HNWebPage):
"""將頁面儲存到後端資料庫
"""
data = {
"content": page.get_text(),
"generated_at": page.get_generated_at(),
"size": page.get_size(),
}
# 將 data 儲存到資料庫中
```
### 成功違反 I 協議
程式碼寫到這,讓我們回頭看看上個例子裡的 *條目來源分組類 `SiteSourceGrouper`*
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/02/SOLID_I_before.png" width="100%" />
圖:成功違反了 I 協議
</div>
當我修改完抽象類後,雖然 `SiteSourceGrouper` 仍然依賴著 `HNWebPage`,但它其實只使用了 `get_text` 這一個方法而已,其他 `get_size`、`get_generated` 這些它 **不使用的方法也成為了它的依賴。**
很明顯,現在的設計違反了介面隔離原則。為了修復這一點,我們需要將 `HNWebPage` 拆成更小的介面。
### 如何分拆介面
設計介面有一個技巧:**讓客戶(呼叫方)來驅動協議設計**。讓我們來看看,`HNWebPage` 到底有哪些客戶:
- `SiteSourceGrouper`:域名來源統計,依賴 `get_text()`
- `SiteAchiever`HN 頁面歸檔程式,依賴 `get_text()`、`get_size()`、`get_generated_at()`
按照上面的方式,我們可以把 `HNWebPage` 分離成兩個獨立的抽象類:
```python
class ContentOnlyHNWebPage(metaclass=ABCMeta):
"""抽象類Hacker New 站點頁面(僅提供內容)
"""
@abstractmethod
def get_text(self) -> str:
raise NotImplementedError
class HNWebPage(ContentOnlyHNWebPage):
"""抽象類Hacker New 站點頁面(含元資料)
"""
@abstractmethod
def get_size(self) -> int:
"""獲取頁面大小"""
@abstractmethod
def get_generated_at(self) -> datetime.datetime:
"""獲取頁面生成時間"""
```
將舊類拆分成兩個不同的抽象類後,`SiteSourceGrouper` 和 `SiteAchiever` 就可以分別依賴不同的抽象類了。
同時,對於 `LocalHNWebPage` 類來說,它也只需要實現那個只返回的文字的 `ContentOnlyHNWebPage` 就行。
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/02/SOLID_I_after.png" width="100%" />
圖:實施介面隔離後的結果
</div>
### 一些不容易發現的違反情況
雖然我花了很長的篇幅,用了好幾個抽象類才把介面隔離原則講明白,但其實在我們的日常編碼中,對這條原則的違反經常會出現在一些更容易被忽視的地方。
舉個例子,當我們在 web 站點裡判斷使用者請求的 Cookies 或頭資訊是否包含某個標記值時,我們經常直接寫一個依賴整個 `request` 物件的函式:
```python
def is_new_visitor(request: HttpRequest) -> bool:
"""從 Cookies 判斷是否新訪客
"""
return request.COOKIES.get('is_new_visitor') == 'y'
```
但事實上,除了 `.COOKIES` 以外,`is_new_visitor` 根本就不需要 `request` 物件裡面的任何其他內容。“使用者請求物件request”是一個比“Cookie 字典request.COOKIES”複雜得多的抽象。我們完全可以把函式改成只接收 cookies 字典。
```python
def is_new_visitor(cookies: Dict) -> bool:
"""從 Cookies 判斷是否新訪客
"""
return cookies.get('is_new_visitor') == 'y'
```
類似的情況還有很多,比如一個發簡訊的函式本身只需要兩個引數 `電話號碼``使用者姓名`,但是函式卻依賴了整個使用者物件 `User`,裡面包含著幾十個用不上的其他欄位和方法。
對於這類函式,我們都可以重新考慮一下它們的抽象是否合理,是否需要應用介面隔離原則。
### 現實世界中的介面隔離
當你知道了介面隔離原則的種種好處後,你很自然就會養成寫小類、小介面的習慣。在現實世界裡,其實已經有很多小而精的介面設計可以供你參考。比如:
- Python 的 [collections.abc](https://docs.python.org/3/library/collections.abc.html) 模組裡面有非常多的小介面
- Go 裡面的 [Reader 和 Writer](https://golang.org/pkg/io/#Reader) 也是非常好的例子
## 總結
在這篇文章裡,我向你介紹了 SOLID 原則的最後兩位成員:**“依賴倒置原則”** 與 **“介面隔離原則”**。
這兩條原則之間有一個共同點,那就是它們都和 **“抽象”** 有著緊密的聯絡。前者告訴我們要面向抽象而非實現程式設計,後者則教導我們在設計抽象時應該做到精準。
最後再總結一下:
- **“D依賴倒置原則”** 認為高層模組和低層模組都應該依賴於抽象
- 依賴抽象,意味著我們可以完全修改低層實現,而不影響高層程式碼
- 在 Python 中你可以使用 abc 模組來定義抽象類
- 除 abc 外,你也可以使用其他技術來完成依賴倒置
- **“I介面隔離原則”** 認為客戶不應該依賴任何它不使用的方法
- 設計介面就是設計抽象
- 違反介面隔離原則也可能會導致違反單一職責與裡式替換原則
- 寫更小的類、寫更小的介面在大多數情況下是個好主意
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[>>>下一篇【15.在邊界處思考】](15-thinking-in-edge-cases.md)
[<<<上一篇【13.寫好面向物件程式碼的原則(中)】](13-write-solid-python-codes-part-2.md)
## 附錄
- 題圖來源: Photo by Carolina Garcia Tavizon on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:寫好面向物件程式碼的原則(上)](https://www.zlovezl.cn/articles/write-solid-python-codes-part-1/)
- [Python 工匠:寫好面向物件程式碼的原則(中)](https://www.zlovezl.cn/articles/write-solid-python-codes-part-2/)
- [Python 工匠:寫好面向物件程式碼的原則(下)](https://www.zlovezl.cn/articles/write-solid-python-codes-part-3/)

View File

@ -0,0 +1,475 @@
# Python 工匠:在邊界處思考
## 前言
> 這是 “Python 工匠”系列的第 15 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/06/jessica-ruscello-DoSDQvzjeH0-unsplash_w1440.jpg" width="100%" />
</div>
2016 年Linux 作業系統的創造者 Linus Torvalds 參加了一場[ TED 訪談節目](https://www.ted.com/talks/linus_torvalds_the_mind_behind_linux/transcript?language=en)。整個節目的前半部分,主要是他在講如何在家光著膀子寫出 Linux 的故事,沒有涉及太多程式設計相關的事情。
不過在訪談快結束時,突然出現了一個有趣的環節。主持人向 Linus 提問道:“你曾說過更願意和那些有著好的 **程式碼品味** 的人共事,那在你眼裡,什麼才是好的程式碼品味?”
為了解釋這個問題Linus 在大螢幕上展示了一份程式碼。我把其摘抄如下。
```c
remove_list_entry(entry) {
prev = NULL;
walk = head;
// 遍歷連結串列
while (walk != entry) {
prev = walk;
walk = walk->next;
}
// 關鍵:當要刪除時,判斷當前位置是否在連結串列頭部進行不同的動作
if (!prev)
head = entry->next;
else
prev->next = entry->next;
}
```
函式 `remove_list_entry` 的主要功能是透過遍歷連結串列,刪除裡面的某個成員。但在這份程式碼中,存在一個 **[邊界情況Edge Case](https://en.wikipedia.org/wiki/Edge_case)**。
在程式設計時,“邊界情況”是指那些只在極端情景下出現的情況。比如在上面的程式碼裡,當我們要找的元素剛好處於連結串列頭部時,就是一個邊界情況。為了處理它,函式在刪除前進行了一次 `if / else` 判斷。
Linus 認為這條 if 語句是整段程式碼的“壞味道”來源,寫出它的人程式碼品味不夠好 ☹️。那麼,一個品味更好的人應該怎麼寫呢?很快,螢幕上出現了第二份程式碼。
```c
remove_list_entry(entry) {
indirect = &head
// 遍歷連結串列過程程式碼已省略
// 當要刪除時,直接進行指標操作刪除
*indirect = entry->next
}
```
在新程式碼中,`remove_list_entry` 函式利用了 C 語言裡的指標特性,把之前的 `if / else` 完全消除了。無論待刪除的目標是在連結串列頭部還是中間,函式都能一視同仁的完成刪除操作。之前的邊界情況消失了。
看到這你是不是在犯嘀咕:*Python 又沒有指標,你跟我說這麼多指標不指標的幹啥?*雖然 Python 沒有指標,但我覺得這個例子為我們提供了一個很有趣的主題。那就是 **如何充分利用語言特性,更好的處理編碼時的邊界情況。**
我認為,好程式碼在處理邊界情況時應該是簡潔的、“潤物細無聲”的。就像上面的例子一樣,可以做到讓邊界情況消融在程式碼主流程中。在寫 Python 時,有不少編碼技巧和慣例可以幫我們做到這一點,一塊來看看吧。
## 第一課:使用分支還是異常?
今天週末,你計劃參加朋友組織的聚餐,臨出門時突然想起來最近是雨季。於是你掏出手機開啟天氣 App看看今天是不是會下雨。如果下雨就帶上一把傘再出門。
假如把“今天下雨”類比成程式設計時的 *邊界情況*,那“看天氣預報 + 帶傘”就是我們的邊界處理程式碼。這種 `if 下雨 then 帶傘` 的分支式判斷,基本是一種來自直覺的思考本能。所以,當我們在程式設計時發現邊界情況時,第一反應往往就是:**“弄個 if 分支把它包起來吧!”**。
比如下面這段程式碼:
```python
def counter_ap(l):
"""計算列表裡面每個元素出現的數量"""
result = {}
for key in l:
# 主流程:累加計數器
if key in result:
result[key] += 1
# **邊界情況:當元素第一次出現時,先初始化值為 1**
else:
result[key] = 1
return result
# 執行結果:
print(counter_ap(['apple', 'banana', 'apple']))
{'apple': 2, 'banana': 1}
```
在上面的迴圈裡,程式碼的主流程是*“對每個 key 的計數器加 1”*。但是,當 result 字典裡還沒有 `key` 元素時,是不能直接進行累加操作的(會丟擲 `KeyError`)。
```python
>>> result = {}
>>> result['foo'] += 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'foo'
```
於是一個邊界情況出現了:當元素第一次出現時,我們需要對值進行初始化。
所以,我專門寫了一條 `if` 語句去處理這個邊界情況。程式碼簡單,無需過多解釋。但你可能不知道的是,其實有一個術語來專門描述這種程式設計風格:**“LBYLLook Before You Leap”**。
“LBYL” 這縮寫不太好翻譯。用大白話講,就是在進行操作前,先對可能的邊界情況進行條件判斷。根據結果不同,確定是處理邊界情況,還是執行主流程。
如之前所說,使用 “LBYL” 來處理邊界情況,幾乎是一種直覺式的行為。*“有邊界情況,就加上 if 分支”*和*“如果天氣預報說下雨,我就帶傘出門”*一樣,是一種基本不需要過腦子的操作。
而在 LBYL 之外,有著與其形成鮮明對比的另外一種風格:**“EAFPEasier to Ask for Forgiveness than Permission”**。
### 獲取原諒比許可簡單(EAFP)
“EAFP” 通常被翻譯成“獲取原諒比許可簡單”。如果還用下雨舉例,那 EAFP 的做法就類似於 *“出門前不看任何天氣預報,如果淋雨了,就回家後洗澡吃感冒藥 💊”*
使用 EAFP 風格的程式碼是這樣的:
```python
def counter_af(l):
result = {}
for key in l:
try:
# 總是直接執行主流程:累加計數器
result[key] += 1
except KeyError:
# 邊界情況:當元素第一次出現時會報錯 KeyError此時進行初始化
result[key] = 1
return result
```
和 LBYL 相比EAFP 程式設計風格更為簡單粗暴。它總是直奔主流程而去,把邊界情況都放在異常處理 `try except` 塊內消化掉。
如果你問我:“這兩種程式設計風格哪個更好?”,我只能說整個 Python 社群對基於異常捕獲的“請求原諒EAFP”型程式設計風格有著明顯的偏愛。其中的原因有很多。
首先,和許多其他程式語言不同,在 Python 裡丟擲異常是一個很輕量的操作,即使程式會大量丟擲、捕獲異常,使用 EAFP 也不會給程式帶來額外的負擔。
其次,“請求原諒”在效能上通常也更有優勢,因為程式總是直奔主流程而去,只有極少數情況下才需要處理邊界情況。拿上面的例子來說,第二段程式碼通常會比第一段更快,因為它不用在每次迴圈時都做一次額外的成員檢查。
> Hint如果你想了解更多這方面的知識建議閱讀 [Write Cleaner Python: Use Exceptions](https://jeffknupp.com/blog/2013/02/06/write-cleaner-python-use-exceptions/)
所以,每當你想憑直覺寫下 `if else` 來處理邊界情況時,先考慮下使用 `try` 來捕獲異常是不是更合適。畢竟Pythonista 們總是喜歡“吃感冒藥 💊”勝過“看天氣預報”。😅
## 當容器內容不存在時
Python 裡有很多內建的容器型別,比如字典、列表、集合等等。在進行容器操作時,經常會出現一些邊界情況。其中“要訪問的內容不存在”,是最為常見的一類:
- 操作字典時,訪問的鍵 `key` 不存在,會丟擲 `KeyError` 異常
- 操作列表、元組時,訪問的下標 `index` 不存在,會丟擲 `IndexError` 異常
對於這類邊界情況,除了針對性的捕獲對應異常外,還有許多其他處理方式。
### 使用 defaultdict 改寫示例
在前面的例子裡,我們使用了 `try except` 語句處理了*“key 第一次出現”*這個邊界情況。雖然我說過,使用 `try` 的程式碼比 `if` 更好,但這不代表它就是一份地道的 Python 程式碼。
為什麼?因為如果你想統計列表元素的話,直接用 `collections.defaultdict` 就可以了:
```python
from collections import defaultdict
def counter_by_collections(l):
result = defaultdict(int)
for key in l:
result[key] += 1
return result
```
這樣的程式碼既不用“獲取許可”,也無需“請求原諒”。 整個函式只有一個主流程,程式碼更清晰、更自然。
為什麼 `defaultdict` 可以讓邊界情況消失?因為究其根本,之前的程式碼就是少了針對 *“鍵不存在”* 時的預設處理邏輯。所以,當我們用 `defaultdict` 聲明瞭如何處理這個邊界情況時,原本需要手動判斷的部分就消失了。
> Hint就上面的例子來說使用 [collections.Counter](https://docs.python.org/3/library/collections.html#collections.Counter) 也能達到同樣的目的。
### 使用 setdefault 取值並修改
有時候,我們需要操作字典裡的某個值,但它又可能並不存在。比如下面這個例子:
```python
# 往字典的 values 鍵追加新值,假如不存在,先以列表初始化
try:
d['values'].append(value)
except KeyError:
d['values'] = [value]
```
針對這種情況,我們可以使用 **`d.setdefault(key, default=None)`** 方法來簡化邊界處理邏輯,直接替換上面的異常捕獲語句:
```python
# 如果 setdefault 指定的 key此處為 "values")不存在,以 [] 初始化,否則返回已存在
# 的值。
d.setdefault('values', []).append(value)
```
> Hint使用 `defaultdict(list)` 同樣可以利索的解決這個問題。
### 使用 dict.pop 刪除不存在的鍵
如果我們要刪除字典的某個 `key`,一般會使用 `del` 關鍵字。但當 `key` 不存在時,刪除操作就會丟擲 `KeyError` 異常。
所以,想要安全刪除某個 `key`,還得加上一段異常捕獲邏輯。
```python
try:
del d[key]
except KeyError:
# 忽略 key 不存在的情況
pass
```
但假設只是單純的想刪除某個 `key`,並不關心它是否存在、有沒有刪成功。使用 `dict.pop(key, default)` 方法就夠了。
只要在呼叫 `dict.pop` 方法時傳入預設值,`key` 不存在時就不會丟擲異常了。
```python
# 使用 pop 方法,指定 default 值為 None當 key 不存在時,不會報錯
d.pop(key, None)
```
> Hint嚴格來說`pop` 方法的主要用途並不是去刪除某個 key而是 **取出** 某個 key 對應的值。不過我覺得偶爾用它來做刪除也無傷大雅。
### 當列表切片越界時
所有人都知道,當你的列表*(或元組)*只有 3 個元素,而你想要訪問第 4 個時,直譯器會報出 `IndexError` 錯誤。我們通常稱這類錯誤為*“陣列越界”*。
```python
>>> l = [1, 2, 3]
>>> l[2]
3
>>> l[3]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list index out of range
```
但你可能不知道的是,假如你請求的不是某一個元素,而是一段範圍的切片。那麼無論你指定的範圍是否有效,程式都只會返回一個空列表 `[]`,而不會丟擲任何錯誤:
```python
>>> l = []
>>> l[1000:1001]
[]
```
瞭解了這點後,你會發現像下面這種邊界處理程式碼根本沒有必要:
```python
def sum_list(l, limit):
"""對列表的前 limit 個元素求和
"""
# 如果 limit 過大,設定為陣列長度避免越界
if limit > len(l):
limit = len(l)
return sum(l[:limit])
```
因為做切片不會丟擲任何錯誤,所以不需要判斷 limit 是否超出範圍,直接做 `sum` 操作即可:
```python
def sum_list(l, limit):
return sum(l[:limit])
```
利用這個特點,我們還可以簡化一些特定的邊界處理邏輯。比如安全刪除列表的某個元素:
```
# 使用異常捕獲安全刪除列表的第 5 個元素
try:
l.pop(5)
except IndexError:
pass
# 刪除從 5 開始的長度為 1 的切片,不需要捕獲任何異常
del l[5:6]
```
## 好用又危險的 “or” 運算子
`or` 是一個幾乎在所有程式語言裡都有的運算子,它在 Python 裡通常被用來和 `and` 一起做布林值邏輯運算。比如:
```python
>>> False or True
True
```
`or` 還有一個有趣的特點是短路求值,比如在下面的例子裡,`1 / 0` 永遠不會被執行*(也就意味著不會丟擲 ZeroDivisionError*
```python
>>> True or (1 / 0)
True
```
在很多場景下,我們可以利用 `or` 的特點來簡化一些邊界處理邏輯。看看下面這個例子:
```python
context = {}
# 僅當 extra_context 不為 None 時,將其追加進 context 中
if extra_context:
context.update(extra_context)
```
在這段程式碼裡,`extra_context` 的值一般情況下會是一個字典,但有時也可能是 `None`。所以我加了一個條件判斷語句,當它的值不為 `None` 時才做 `.update` 操作。
如果使用 `or` 運算子,我們可以讓上面的語句更簡練:
```
context.update(extra_context or {})
```
因為 `a or b or c or ...` 這樣的表示式,會返回這些變數裡第一個布林值為真的值,直到最後一個為止。所以 `extra_context or {}``extra_context``None` 時其實就等於 `{}`。因此之前的條件判斷就可以被簡化成一個 `or` 表示式了。
使用 `a or b` 來表示*“ a 為空時用 b 代替”*,這種寫法一點也不新鮮。你在各種程式設計語、各類框架原始碼原始碼裡都能發現它的影子。但在這個寫法下,其實也藏有一個陷阱。
因為 `or` 操作計算的是變數的布林真假值。所以,不光是 `None`,所有的 0、[]、{}、set() 以及其他所有會被判斷為布林假的東西,都會在 `or` 運算中被忽略。
```python
# 所有的 0、空列表、空字串等都是布林假值
>>> bool(None), bool(0), bool([]), bool({}), bool(''), bool(set())
(False, False, False, False, False, False)
```
如果忘記了 `or` 的這個特點,可能會碰到一些很奇怪的問題。比如這段程式碼:
```python
timeout = config.timeout or 60
```
雖然上面程式碼的目的,是想要判斷當 `config.timeout``None` 時使用 60 做預設值。但假如 `config.timeout` 的值被主動配置成了 `0` 秒,`timeout` 也會因為上面的 `0 or 60 = 60` 運算被重新賦值為 60。正確的配置因此被忽略掉了。
所以,有時使用 `if` 來進行精確的邊界處理會更穩妥一些:
```python
if config.timeout is None:
timeout = 60
```
## 不要手動去做資料校驗
無數前輩的經驗告訴我們:*“不要信任任何使用者輸入”*。這意味著所有存在使用者輸入的地方,都必須對其進行校驗。那些無效、危險的使用者輸入值,就是需要我們處理的邊界情況。
假如我在寫一個命令列小程式,需要讓使用者輸入一個 0-100 範圍的數字。要是使用者的輸入無效,就要求其重新輸入。
程式大概長這樣:
```python
def input_a_number():
"""要求使用者輸入一個 0-100 的數字,如果無效則重新輸入
"""
while True:
number = input('Please input a number (0-100): ')
# 此處往下的三條 if 語句都是輸入值的邊界校驗程式碼
if not number:
print('Input can not be empty!')
continue
if not number.isdigit():
print('Your input is not a valid number!')
continue
if not (0 <= int(number) <= 100):
print('Please input a number between 0 and 100!')
continue
number = int(number)
break
print(f'Your number is {number}')
```
執行效果如下:
```python
Please input a number (0-100):
Input can not be empty!
Please input a number (0-100): foo
Your input is not a valid number!
Please input a number (0-100): 65
Your number is 65
```
這個函式一共有 14 行有效程式碼。其中有 3 段 if 共 9 行程式碼,都是用於校驗的邊界值檢查程式碼。也許你覺得這樣的檢查很正常,但請想象一下,假如需要校驗的輸入不止一個、校驗邏輯也比這個複雜怎麼辦?那樣的話,**這些邊界值檢查程式碼就會變得又臭又長。**
如何改進這些程式碼呢?把它們抽離出去,作為一個校驗函式和核心邏輯隔離開是個不錯的辦法。但更重要的在於,要把*“輸入資料校驗”*作為一個獨立的職責與領域,用更恰當的模組來完成這項工作。
在資料校驗這塊,[pydantic](https://pydantic-docs.helpmanual.io/) 模組是一個不錯的選擇。如果用它來做校驗,程式碼可以被簡化成這樣:
```python
from pydantic import BaseModel, conint, ValidationError
class NumberInput(BaseModel):
# 使用型別註解 conint 定義 number 屬性的取值範圍
number: conint(ge=0, le=100)
def input_a_number_with_pydantic():
while True:
number = input('Please input a number (0-100): ')
# 例項化為 pydantic 模型,捕獲校驗錯誤異常
try:
number_input = NumberInput(number=number)
except ValidationError as e:
print(e)
continue
number = number_input.number
break
print(f'Your number is {number}')
```
在日常編碼時,我們應該儘量避免去手動校驗資料。而是應該使用*(或者自己實現)*合適的第三方校驗模組,把這部分邊界處理工作抽象出去,簡化主流程程式碼。
> Hint: 假如你在開發 Web 應用,那麼資料校驗部分通常來說都挺容易。比如 Django 框架有自己的 forms 模組Flask 也可以使用 WTForms 來進行資料校驗。
## 不要忘記做數學計算
很多年前剛接觸 Web 開發時,我想學著用 JavaScript 來實現一個簡單的文字跑馬燈動畫。如果你不知道啥是“跑馬燈”,我可以稍微解釋一下。“跑馬燈”就是讓一段文字從頁面左邊往右邊不斷迴圈滾動,十幾年前的網站特別流行這個。😬
我記得裡面有一段邏輯是這樣的:*控制文字不斷往右邊移動,當橫座標超過頁面寬度時,重置座標後繼續。*我當時寫出來的程式碼,翻譯成 Python 大概是這樣:
```python
while True:
if element.position_x > page_width:
# 邊界情況:當物件位置超過頁面寬度時,重置位置到最左邊
element.position_x -= page_width
# 元素向右邊滾動一個單位寬度
element.position_x += width_unit
```
看上去還不錯對不對?我剛寫完它時也是這麼認為的。但後來有一天,我重新看到它時,才發現其中的古怪之處。
在上面的程式碼裡,我需要在主迴圈裡保證 “element.position_x 不會超過頁面寬度 page_width”。所以我寫了一個 if 來處理當 `position_x` 超過頁面寬度的情況。
但如果是要保證某個累加的數字*position_x*不超過另一個數字*page_width*,直接用 `%` 做取模運算不就好了嗎?
```python
while True:
# 使用 % page_width 控制不要超過頁面寬度
element.position_x = (element.position_x + width_unit) % page_width
```
這樣寫的話,程式碼裡的邊界情況就連著那行 `if` 語句一起消失了。
和取模運算類似的操作還有很多,比如 `abs()`、`math.floor()` 等等。我們應該記住,不要寫出 `if value < 0: value = -value` 這種“邊界判斷程式碼”,直接使用 `abs(value)` 就好,不要重新發明絕對值運算。
## 總結
“邊界情況Edge cases”是我們在日常編碼時的老朋友。但它不怎麼招人喜歡畢竟我們都希望自己的程式碼只有一條主流程貫穿始終不需要太多的條件判斷、異常捕獲。
但邊界情況同時又是無法避免的,只要有程式碼,邊界情況就會存在。所以,如果能更好的處理它們,我們的程式碼就可以變得更清晰易讀。
除了上面介紹的這些思路外,還有很多東西都可以幫助我們處理邊界情況,比如利用面向物件的多型特性、使用 [空物件模式](https://github.com/piglei/one-python-craftsman/blob/master/zh_CN/5-function-returning-tips.md#5-%E5%90%88%E7%90%86%E4%BD%BF%E7%94%A8%E7%A9%BA%E5%AF%B9%E8%B1%A1%E6%A8%A1%E5%BC%8F) 等等。
最後再總結一下:
- 使用條件判斷和異常捕獲都可以用來處理邊界情況
- 在 Python 裡,我們更傾向於使用基於異常捕獲的 EAFP 風格
- 使用 defaultdict / setdefault / pop 可以巧妙的處理當鍵不存在時的邊界情況
- 對列表進行不存在的範圍切片不會丟擲異常
- 使用 `or` 可以簡化預設值邊界處理邏輯,但也要注意不要掉入陷阱
- 不要手動去做資料校驗,使用 `pydantic` 或其他的資料校驗模組
- 利用取模、絕對值計算等方式,可以簡化一些特定的邊界處理邏輯
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[<<<上一篇【14.寫好面向物件程式碼的原則(下)】](14-write-solid-python-codes-part-3.md)
> 為了避免內容重複,在系列第 4 篇“容器的門道”裡出現的 EAPF 相關內容會被刪除,併入到本文中。
## 附錄
- 題圖來源: Photo by Jessica Ruscello on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:寫好面向物件程式碼的原則(上)](https://www.zlovezl.cn/articles/write-solid-python-codes-part-1/)
- [Python 工匠:讓函式返回結果的技巧](https://www.zlovezl.cn/articles/function-returning-tips/)

View File

@ -0,0 +1,394 @@
# Python 工匠:編寫條件分支程式碼的技巧
## 序言
> 這是 “Python 工匠”系列的第 2 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
編寫條件分支程式碼是編碼過程中不可或缺的一部分。
如果用道路來做比喻,現實世界中的程式碼從來都不是一條筆直的高速公路,而更像是由無數個岔路口組成的某個市區地圖。我們編碼者就像是駕駛員,需要告訴我們的程式,下個路口需要往左還是往右。
編寫優秀的條件分支程式碼非常重要,因為糟糕、複雜的分支處理非常容易讓人困惑,從而降低程式碼質量。所以,這篇文章將會種重點談談在 Python 中編寫分支程式碼應該注意的地方。
### 內容目錄
* [最佳實踐](#最佳實踐)
* [1. 避免多層分支巢狀](#1-避免多層分支巢狀)
* [2. 封裝那些過於複雜的邏輯判斷](#2-封裝那些過於複雜的邏輯判斷)
* [3. 留意不同分支下的重複程式碼](#3-留意不同分支下的重複程式碼)
* [4. 謹慎使用三元表示式](#4-謹慎使用三元表示式)
* [常見技巧](#常見技巧)
* [1. 使用“德摩根定律”](#1-使用德摩根定律)
* [2. 自定義物件的“布林真假”](#2-自定義物件的布林真假)
* [3. 在條件判斷中使用 all() / any()](#3-在條件判斷中使用-all--any)
* [4. 使用 try/while/for 中 else 分支](#4-使用-trywhilefor-中-else-分支)
* [常見陷阱](#常見陷阱)
* [1. 與 None 值的比較](#1-與-none-值的比較)
* [2. 留意 and 和 or 的運算優先順序](#2-留意-and-和-or-的運算優先順序)
* [結語](#結語)
* [註解](#註解)
### Python 裡的分支程式碼
Python 支援最為常見的 `if/else` 條件分支語句,不過它缺少在其他程式語言中常見的 `switch/case` 語句。
除此之外Python 還為 `for/while` 迴圈以及 `try/except` 語句提供了 else 分支,在一些特殊的場景下,它們可以大顯身手。
下面我會從 `最佳實踐`、`常見技巧`、`常見陷阱` 三個方面講一下如果編寫優秀的條件分支程式碼。
## 最佳實踐
### 1. 避免多層分支巢狀
如果這篇文章只能刪減成一句話就結束,那麼那句話一定是**“要竭盡所能的避免分支巢狀”**。
過深的分支巢狀是很多程式設計新手最容易犯的錯誤之一。假如有一位新手 JavaScript 程式設計師寫了很多層分支巢狀,那麼你可能會看到一層又一層的大括號:`if { if { if { ... }}}`。俗稱*“巢狀 if 地獄Nested If Statement Hell”*。
但是因為 Python 使用了縮排來代替 `{}`,所以過深的巢狀分支會產生比其他語言下更為嚴重的後果。比如過多的縮排層次很容易就會讓程式碼超過 [PEP8](https://www.python.org/dev/peps/pep-0008/) 中規定的每行字數限制。讓我們看看這段程式碼:
```Python
def buy_fruit(nerd, store):
"""去水果店買蘋果
- 先得看看店是不是在營業
- 如果有蘋果的話,就買 1 個
- 如果錢不夠,就回家取錢再來
"""
if store.is_open():
if store.has_stocks("apple"):
if nerd.can_afford(store.price("apple", amount=1)):
nerd.buy(store, "apple", amount=1)
return
else:
nerd.go_home_and_get_money()
return buy_fruit(nerd, store)
else:
raise MadAtNoFruit("no apple in store!")
else:
raise MadAtNoFruit("store is closed!")
```
上面這段程式碼最大的問題,就是過於直接翻譯了原始的條件分支要求,導致短短十幾行程式碼包含了有三層巢狀分支。
這樣的程式碼可讀性和維護性都很差。不過我們可以用一個很簡單的技巧:**“提前結束”** 來最佳化這段程式碼:
```python
def buy_fruit(nerd, store):
if not store.is_open():
raise MadAtNoFruit("store is closed!")
if not store.has_stocks("apple"):
raise MadAtNoFruit("no apple in store!")
if nerd.can_afford(store.price("apple", amount=1)):
nerd.buy(store, "apple", amount=1)
return
else:
nerd.go_home_and_get_money()
return buy_fruit(nerd, store)
```
“提前結束”指:**在函式內使用 `return``raise` 等語句提前在分支內結束函式。**比如,在新的 `buy_fruit` 函式裡,當分支條件不滿足時,我們直接丟擲異常,結束這段這程式碼分支。這樣的程式碼沒有巢狀分支,更直接也更易讀。
### 2. 封裝那些過於複雜的邏輯判斷
如果條件分支裡的表示式過於複雜,出現了太多的 `not/and/or`,那麼這段程式碼的可讀性就會大打折扣,比如下面這段程式碼:
```
# 如果活動還在開放,並且活動剩餘名額大於 10為所有性別為女性或者級別大於 3
# 的活躍使用者發放 10000 個金幣
if activity.is_active and activity.remaining > 10 and \
user.is_active and (user.sex == 'female' or user.level > 3):
user.add_coins(10000)
return
```
對於這樣的程式碼,我們可以考慮將具體的分支邏輯封裝成函式或者方法,來達到簡化程式碼的目的:
```
if activity.allow_new_user() and user.match_activity_condition():
user.add_coins(10000)
return
```
事實上,將程式碼改寫後,之前的註釋文字其實也可以去掉了。**因為後面這段程式碼已經達到了自說明的目的。**至於具體的 *什麼樣的使用者滿足活動條件?* 這種問題,就應由具體的 `match_activity_condition()` 方法來回答了。
> **Hint:** 恰當的封裝不光直接改善了程式碼的可讀性,事實上,如果上面的活動判斷邏輯在程式碼中出現了不止一次的話,封裝更是必須的。不然重複程式碼會極大的破壞這段邏輯的可維護性。
### 3. 留意不同分支下的重複程式碼
重複程式碼是程式碼質量的天敵,而條件分支語句又非常容易成為重複程式碼的重災區。所以,當我們編寫條件分支語句時,需要特別留意,不要生產不必要的重複程式碼。
讓我們看下這個例子:
```python
# 對於新使用者,建立新的使用者資料,否則更新舊資料
if user.no_profile_exists:
create_user_profile(
username=user.username,
email=user.email,
age=user.age,
address=user.address,
# 對於新建使用者,將使用者的積分置為 0
points=0,
created=now(),
)
else:
update_user_profile(
username=user.username,
email=user.email,
age=user.age,
address=user.address,
updated=now(),
)
```
在上面的程式碼中,我們可以一眼看出,在不同的分支下,程式呼叫了不同的函式,做了不一樣的事情。但是,因為那些重複程式碼的存在,**我們卻很難簡單的區分出,二者的不同點到底在哪。**
其實,得益於 Python 的動態特性,我們可以簡單的改寫一下上面的程式碼,讓可讀性可以得到顯著的提升:
```python
if user.no_profile_exists:
profile_func = create_user_profile
extra_args = {'points': 0, 'created': now()}
else:
profile_func = update_user_profile
extra_args = {'updated': now()}
profile_func(
username=user.username,
email=user.email,
age=user.age,
address=user.address,
**extra_args
)
```
當你編寫分支程式碼時,請額外關注**由分支產生的重複程式碼塊**,如果可以簡單的消滅它們,那就不要遲疑。
### 4. 謹慎使用三元表示式
三元表示式是 Python 2.5 版本後才支援的語法。在那之前Python 社群一度認為三元表示式沒有必要,我們需要使用 `x and a or b` 的方式來模擬它。[[注]](#annot1)
事實是,在很多情況下,使用普通的 `if/else` 語句的程式碼可讀性確實更好。盲目追求三元表示式很容易誘惑你寫出複雜、可讀性差的程式碼。
所以,請記得只用三元表示式處理簡單的邏輯分支。
```python
language = "python" if you.favor("dynamic") else "golang"
```
對於絕大多數情況,還是使用普通的 `if/else` 語句吧。
## 常見技巧
### 1. 使用“德摩根定律”
在做分支判斷時,我們有時候會寫成這樣的程式碼:
```python
# 如果使用者沒有登入或者使用者沒有使用 chrome拒絕提供服務
if not user.has_logged_in or not user.is_from_chrome:
return "our service is only available for chrome logged in user"
```
第一眼看到程式碼時,是不是需要思考一會才能理解它想幹嘛?這是因為上面的邏輯表示式裡面出現了 2 個 `not` 和 1 個 `or`。而我們人類恰好不擅長處理過多的“否定”以及“或”這種邏輯關係。
這個時候,就該 [德摩根定律](https://zh.wikipedia.org/wiki/%E5%BE%B7%E6%91%A9%E6%A0%B9%E5%AE%9A%E5%BE%8B) 出場了。通俗的說,德摩根定律就是 `not A or not B` 等價於 `not (A and B)`。透過這樣的轉換,上面的程式碼可以改寫成這樣:
```python
if not (user.has_logged_in and user.is_from_chrome):
return "our service is only available for chrome logged in user"
```
怎麼樣,程式碼是不是易讀了很多?記住德摩根定律,很多時候它對於簡化條件分支裡的程式碼邏輯非常有用。
### 2. 自定義物件的“布林真假”
我們常說,在 Python 裡,“萬物皆物件”。其實,不光“萬物皆物件”,我們還可以利用很多魔法方法*(文件中稱為:[user-defined method](https://docs.python.org/3/reference/datamodel.html)*,來自定義物件的各種行為。我們可以用很多在別的語言裡面無法做到、有些魔法的方式來影響程式碼的執行。
比如Python 的所有物件都有自己的“布林真假”:
- 布林值為假的物件:`None`, `0`, `False`, `[]`, `()`, `{}`, `set()`, `frozenset()`, ... ...
- 布林值為真的物件:非 `0` 的數值、`True`,非空的序列、元組,普通的使用者類例項,... ...
透過內建函式 `bool()`,你可以很方便的檢視某個物件的布林真假。而 Python 進行條件分支判斷時用到的也是這個值:
```python
>>> bool(object())
True
```
重點來了,雖然所有使用者類例項的布林值都是真。但是 Python 提供了改變這個行為的辦法:**自定義類的 `__bool__` 魔法方法** *(在 Python 2.X 版本中為 `__nonzero__`*。當類定義了 `__bool__` 方法後,它的返回值將會被當作類例項的布林值。
另外,`__bool__` 不是影響例項布林真假的唯一方法。如果類沒有定義 `__bool__` 方法Python 還會嘗試呼叫 `__len__` 方法*(也就是對任何序列物件呼叫 `len` 函式)*,透過結果是否為 `0` 判斷例項真假。
那麼這個特性有什麼用呢?看看下面這段程式碼:
```python
class UserCollection(object):
def __init__(self, users):
self._users = users
users = UserCollection([piglei, raymond])
if len(users._users) > 0:
print("There's some users in collection!")
```
上面的程式碼裡,判斷 `UserCollection` 是否有內容時用到了 `users._users` 的長度。其實,透過為 `UserCollection` 新增 `__len__` 魔法方法,上面的分支可以變得更簡單:
```python
class UserCollection:
def __init__(self, users):
self._users = users
def __len__(self):
return len(self._users)
users = UserCollection([piglei, raymond])
# 定義了 __len__ 方法後UserCollection 物件本身就可以被用於布林判斷了
if users:
print("There's some users in collection!")
```
透過定義魔法方法 `__len__``__bool__` ,我們可以讓類自己控制想要表現出的布林真假值,讓程式碼變得更 pythonic。
### 3. 在條件判斷中使用 all() / any()
`all()``any()` 兩個函式非常適合在條件判斷中使用。這兩個函式接受一個可迭代物件,返回一個布林值,其中:
- `all(seq)`:僅當 `seq` 中所有物件都為布林真時返回 `True`,否則返回 `False`
- `any(seq)`:只要 `seq` 中任何一個物件為布林真就返回 `True`,否則返回 `False`
假如我們有下面這段程式碼:
```python
def all_numbers_gt_10(numbers):
"""僅當序列中所有數字大於 10 時,返回 True
"""
if not numbers:
return False
for n in numbers:
if n <= 10:
return False
return True
```
如果使用 `all()` 內建函式,再配合一個簡單的生成器表示式,上面的程式碼可以寫成這樣:
```python
def all_numbers_gt_10_2(numbers):
return bool(numbers) and all(n > 10 for n in numbers)
```
簡單、高效,同時也沒有損失可用性。
### 4. 使用 try/while/for 中 else 分支
讓我們看看這個函式:
```python
def do_stuff():
first_thing_successed = False
try:
do_the_first_thing()
first_thing_successed = True
except Exception as e:
print("Error while calling do_some_thing")
return
# 僅當 first_thing 成功完成時,做第二件事
if first_thing_successed:
return do_the_second_thing()
```
在函式 `do_stuff` 中,我們希望只有當 `do_the_first_thing()` 成功呼叫後*(也就是不丟擲任何異常)*,才繼續做第二個函式呼叫。為了做到這一點,我們需要定義一個額外的變數 `first_thing_successed` 來作為標記。
其實,我們可以用更簡單的方法達到同樣的效果:
```python
def do_stuff():
try:
do_the_first_thing()
except Exception as e:
print("Error while calling do_some_thing")
return
else:
return do_the_second_thing()
```
`try` 語句塊最後追加上 `else` 分支後,分支下的`do_the_second_thing()` 便只會在 **try 下面的所有語句正常執行(也就是沒有異常,沒有 return、break 等)完成後執行**
類似的Python 裡的 `for/while` 迴圈也支援新增 `else` 分支,它們表示:當迴圈使用的迭代物件被正常耗盡、或 while 迴圈使用的條件變數變為 False 後才執行 else 分支下的程式碼。
## 常見陷阱
### 1. 與 None 值的比較
在 Python 中,有兩種比較變數的方法:`==` 和 `is`,二者在含義上有著根本的區別:
- `==`:表示二者所指向的的**值**是否一致
- `is`:表示二者是否指向記憶體中的同一份內容,也就是 `id(x)` 是否等於 `id(y)`
`None` 在 Python 語言中是一個單例物件,如果你要判斷某個變數是否為 None 時,記得使用 `is` 而不是 `==`,因為只有 `is` 才能在嚴格意義上表示某個變數是否是 None。
否則,可能出現下面這樣的情況:
```python
>>> class Foo(object):
... def __eq__(self, other):
... return True
...
>>> foo = Foo()
>>> foo == None
True
```
在上面程式碼中Foo 這個類透過自定義 `__eq__` 魔法方法的方式,很容易就滿足了 `== None` 這個條件。
**所以,當你要判斷某個變數是否為 None 時,請使用 `is` 而不是 `==`。**
### 2. 留意 and 和 or 的運算優先順序
看看下面這兩個表示式,猜猜它們的值一樣嗎?
```python
>>> (True or False) and False
>>> True or False and False
```
答案是:不一樣,它們的值分別是 `False``True`,你猜對了嗎?
問題的關鍵在於:**`and` 運算子的優先順序大於 `or`**。因此上面的第二個表示式在 Python 看來實際上是 `True or (False and False)`。所以結果是 `True` 而不是 `False`
在編寫包含多個 `and``or` 的表示式時,請額外注意 `and``or` 的運算優先順序。即使執行優先順序正好是你需要的那樣,你也可以加上額外的括號來讓程式碼更清晰。
## 結語
以上就是『Python 工匠』系列文章的第二篇。不知道文章的內容是否對你的胃口。
程式碼內的分支語句不可避免,我們在編寫程式碼時,需要尤其注意它的可讀性,避免對其他看到程式碼的人造成困擾。
看完文章的你,有沒有什麼想吐槽的?請留言告訴我吧。
[>>>下一篇【3.使用數字與字串的技巧】](3-tips-on-numbers-and-strings.md)
[<<<上一篇【1.善用變數來改善程式碼質量】](1-using-variables-well.md)
## 註解
1. <a id="annot1"></a>事實上 `x and a or b` 不是總能給你正確的結果,只有當 a 與 b 的布林值為真時,這個表示式才能正常工作,這是由邏輯運算的短路特性決定的。你可以在命令列中執行 `True and None or 0` 試試看,結果是 0 而非 None。
> 文章更新記錄:
>
> - 2018.04.08:在與 @geishu 的討論後,調整了“運算優先符”使用的程式碼樣例
> - 2018.04.10:根據 @dongweiming 的建議,添加註解說明 "x and y or c" 表示式的陷阱

View File

@ -0,0 +1,413 @@
# Python 工匠:使用數字與字串的技巧
## 序言
> 這是 “Python 工匠”系列的第 3 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
數字是幾乎所有程式語言裡最基本的資料型別,它是我們透過程式碼連線現實世界的基礎。在 Python 裡有三種數值型別整型int、浮點型float和複數complex。絕大多數情況下我們只需要和前兩種打交道。
整型在 Python 中比較讓人省心,因為它不區分有無符號並且永不溢位。但浮點型仍和絕大多數其他程式語言一樣,依然有著精度問題,經常讓很多剛進入程式設計世界大門的新人們感到困惑:["Why Are Floating Point Numbers Inaccurate?"](https://stackoverflow.com/questions/21895756/why-are-floating-point-numbers-inaccurate)。
相比數字Python 裡的字串要複雜的多。要掌握它,你得先弄清楚 bytes 和 str 的區別。如果更不巧,你還是位 Python2 使用者的話,光 unicode 和字元編碼問題就夠你喝上好幾壺了*(趕快遷移到 Python3 吧,就在今天!)*。
不過,上面提到的這些都不是這篇文章的主題,如果感興趣,你可以在網上找到成堆的相關資料。在這篇文章裡,我們將討論一些 **更細微、更不常見** 的程式設計實踐。來幫助你寫出更好的 Python 程式碼。
### 內容目錄
* [最佳實踐](#最佳實踐)
* [1. 少寫數字字面量](#1-少寫數字字面量)
* [使用 enum 列舉型別改善程式碼](#使用-enum-列舉型別改善程式碼)
* [2. 別在裸字串處理上走太遠](#2-別在裸字串處理上走太遠)
* [3. 不必預計算字面量表達式](#3-不必預計算字面量表達式)
* [實用技巧](#實用技巧)
* [1. 布林值其實也是“數字”](#1-布林值其實也是數字)
* [2. 改善超長字串的可讀性](#2-改善超長字串的可讀性)
* [當多級縮排裡出現多行字串時](#當多級縮排裡出現多行字串時)
* [3. 別忘了那些 “r” 開頭的內建字串函式](#3-別忘了那些-r-開頭的內建字串函式)
* [4. 使用“無窮大” float("inf")](#4-使用無窮大-floatinf)
* [常見誤區](#常見誤區)
* [1. “value = 1” 並非執行緒安全](#1-value--1-並非執行緒安全)
* [2. 字串拼接並不慢](#2-字串拼接並不慢)
* [結語](#結語)
## 最佳實踐
### 1. 少寫數字字面量
“數字字面量integer literal” 是指那些直接出現在程式碼裡的數字。它們分佈在程式碼裡的各個角落,比如程式碼 `del users[0]` 裡的 `0` 就是一個數字字面量。它們簡單、實用,每個人每天都在寫。**但是,當你的程式碼裡不斷重複出現一些特定字面量時,你的“程式碼質量告警燈”就應該亮起黃燈 🚥 了。**
舉個例子,假如你剛加入一家心儀已久的新公司,同事轉交給你的專案裡有這麼一個函式:
```python
def mark_trip_as_featured(trip):
"""將某個旅程新增到推薦欄目
"""
if trip.source == 11:
do_some_thing(trip)
elif trip.source == 12:
do_some_other_thing(trip)
... ...
return
```
這個函式做了什麼事?你努力想搞懂它的意思,不過 `trip.source == 11` 是什麼情況?那 `== 12` 呢?這兩行程式碼很簡單,沒有用到任何魔法特性。但初次接觸程式碼的你可能需要花費**一整個下午**,才能弄懂它們的含義。
**問題就出在那幾個數字字面量上。** 最初寫下這個函式的人,可能是在公司成立之初加入的那位元老程式設計師。而他對那幾個數字的含義非常清楚。但如果你是一位剛接觸這段程式碼的新人,就完全是另外一碼事了。
#### 使用 enum 列舉型別改善程式碼
那麼,怎麼改善這段程式碼?最直接的方式,就是為這兩個條件分支添加註釋。不過在這裡,“添加註釋”顯然不是提升程式碼可讀性的最佳辦法*(其實在絕大多數其他情況下都不是)*。我們需要用有意義的名稱來代替這些字面量,而`列舉型別enum`用在這裡最合適不過了。
`enum` 是 Python 自 3.4 版本引入的內建模組,如果你使用的是更早的版本,可以透過 `pip install enum34` 來安裝它。下面是使用 enum 的樣例程式碼:
```python
# -*- coding: utf-8 -*-
from enum import IntEnum
class TripSource(IntEnum):
FROM_WEBSITE = 11
FROM_IOS_CLIENT = 12
def mark_trip_as_featured(trip):
if trip.source == TripSource.FROM_WEBSITE:
do_some_thing(trip)
elif trip.source == TripSource.FROM_IOS_CLIENT:
do_some_other_thing(trip)
... ...
return
```
將重複出現的數字字面量定義成列舉型別,不光可以改善程式碼的可讀性,程式碼出現 Bug 的機率也會降低。
試想一下,如果你在某個分支判斷時將 `11` 錯打成了 `111` 會怎麼樣?我們時常會犯這種錯,而這類錯誤在早期特別難被發現。將這些數字字面量全部放入列舉型別中可以比較好的規避這類問題。類似的,將字串字面量改寫成列舉也可以獲得同樣的好處。
使用列舉型別代替字面量的好處:
- **提升程式碼可讀性**:所有人都不需要記憶某個神奇的數字代表什麼
- **提升程式碼正確性**:減少打錯數字或字母產生 bug 的可能性
當然,你完全沒有必要把程式碼裡的所有字面量都改成列舉型別。 **程式碼裡出現的字面量,只要在它所處的上下文裡面容易理解,就可以使用它。** 比如那些經常作為數字下標出現的 `0``-1` 就完全沒有問題,因為所有人都知道它們的意思。
### 2. 別在裸字串處理上走太遠
什麼是“裸字串處理”?在這篇文章裡,它指**只使用基本的加減乘除和迴圈、配合內建函式/方法來操作字串,獲得我們需要的結果。**
所有人都寫過這樣的程式碼。有時候我們需要拼接一大段發給使用者的告警資訊,有時我們需要構造一大段傳送給資料庫的 SQL 查詢語句,就像下面這樣:
```python
def fetch_users(conn, min_level=None, gender=None, has_membership=False, sort_field="created"):
"""獲取使用者列表
:param int min_level: 要求的最低使用者級別,預設為所有級別
:param int gender: 篩選使用者性別,預設為所有性別
:param int has_membership: 篩選所有會員/非會員使用者,預設非會員
:param str sort_field: 排序欄位,預設為按 created "使用者建立日期"
:returns: 列表:[(User ID, User Name), ...]
"""
# 一種古老的 SQL 拼接技巧,使用 "WHERE 1=1" 來簡化字串拼接操作
# 區分查詢 params 來避免 SQL 注入問題
statement = "SELECT id, name FROM users WHERE 1=1"
params = []
if min_level is not None:
statement += " AND level >= ?"
params.append(min_level)
if gender is not None:
statement += " AND gender >= ?"
params.append(gender)
if has_membership:
statement += " AND has_membership == true"
else:
statement += " AND has_membership == false"
statement += " ORDER BY ?"
params.append(sort_field)
return list(conn.execute(statement, params))
```
我們之所以用這種方式拼接出需要的字串 - *在這裡是 SQL 語句* - 是因為這樣做簡單、直接,符合直覺。但是這樣做最大的問題在於:**隨著函式邏輯變得更復雜,這段拼接程式碼會變得容易出錯、難以擴充套件。**事實上,上面這段 Demo 程式碼也只是僅僅做到**看上去**沒有明顯的 bug 而已 *(誰知道有沒有其他隱藏問題)*
其實,對於 SQL 語句這種結構化、有規則的字串,用物件化的方式構建和編輯它才是更好的做法。下面這段程式碼用 [SQLAlchemy](https://www.sqlalchemy.org/) 模組完成了同樣的功能:
```python
def fetch_users_v2(conn, min_level=None, gender=None, has_membership=False, sort_field="created"):
"""獲取使用者列表
"""
query = select([users.c.id, users.c.name])
if min_level is not None:
query = query.where(users.c.level >= min_level)
if gender is not None:
query = query.where(users.c.gender == gender)
query = query.where(users.c.has_membership == has_membership).order_by(users.c[sort_field])
return list(conn.execute(query))
```
上面的 `fetch_users_v2` 函式更短也更好維護,而且根本不需要擔心 SQL 注入問題。所以,當你的程式碼中出現複雜的裸字串處理邏輯時,請試著用下面的方式替代它:
`Q: 目標/源字串是結構化的,遵循某種格式嗎?`
- 是:找找是否已經有開源的物件化模組操作它們,或是自己寫一個
- SQLSQLAlchemy
- XMLlxml
- JSON、YAML ...
- 否:嘗試使用模板引擎而不是複雜字串處理邏輯來達到目的
- Jinja2
- Mako
- Mustache
### 3. 不必預計算字面量表達式
我們的程式碼裡偶爾會出現一些比較複雜的數字,就像下面這樣:
```python
def f1(delta_seconds):
# 如果時間已經過去了超過 11 天,不做任何事
if delta_seconds > 950400:
return
...
```
話說在前頭,上面的程式碼沒有任何毛病。
首先,我們在小本子(當然,和我一樣的聰明人會用 IPython上算了算`11天一共包含多少秒`。然後再把結果 `950400` 這個神奇的數字填進我們的程式碼裡,最後心滿意足的在上面補上一行註釋:告訴所有人這個神奇的數字是怎麼來的。
我想問的是:*“為什麼我們不直接把程式碼寫成 `if delta_seconds < 11 * 24 * 3600:` 呢?”*
**“效能”,答案一定會是“效能”**。我們都知道 Python 是一門~~(速度欠佳的)~~解釋型語言,所以預先計算出 `950400` 正是因為我們不想讓每次對函式 `f1` 的呼叫都帶上這部分的計算開銷。不過事實是:**即使我們把程式碼改成 `if delta_seconds < 11 * 24 * 3600:`,函式也不會多出任何額外的開銷。**
Python 程式碼在執行時會被直譯器編譯成位元組碼,而真相就藏在位元組碼裡。讓我們用 dis 模組看看:
```python
def f1(delta_seconds):
if delta_seconds < 11 * 24 * 3600:
return
import dis
dis.dis(f1)
# dis 執行結果
5 0 LOAD_FAST 0 (delta_seconds)
2 LOAD_CONST 1 (950400)
4 COMPARE_OP 0 (<)
6 POP_JUMP_IF_FALSE 12
6 8 LOAD_CONST 0 (None)
10 RETURN_VALUE
>> 12 LOAD_CONST 0 (None)
14 RETURN_VALUE
```
看見上面的 `2 LOAD_CONST 1 (950400)` 了嗎?這表示 Python 直譯器在將原始碼編譯成成位元組碼時,會計算 `11 * 24 * 3600` 這段整表示式,並用 `950400` 替換它。
所以,**當我們的程式碼中需要出現複雜計算的字面量時,請保留整個算式吧。它對效能沒有任何影響,而且會增加程式碼的可讀性。**
> HintPython 直譯器除了會預計算數值字面量表達式以外,還會對字串、列表做類似的操作。一切都是為了效能。誰讓你們老吐槽 Python 慢呢?
## 實用技巧
### 1. 布林值其實也是“數字”
Python 裡的兩個布林值 `True``False` 在絕大多數情況下都可以直接等價於 `1``0` 兩個整數來使用,就像這樣:
```python
>>> True + 1
2
>>> 1 / False
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
```
那麼記住這點有什麼用呢?首先,它們可以配合 `sum` 函式在需要計算總數時簡化操作:
```python
>>> l = [1, 2, 4, 5, 7]
>>> sum(i % 2 == 0 for i in l)
2
```
此外,如果將某個布林值表示式作為列表的下標使用,可以實現類似三元表示式的目的:
```python
# 類似的三元表示式:"Javascript" if 2 > 1 else "Python"
>>> ["Python", "Javascript"][2 > 1]
'Javascript'
```
### 2. 改善超長字串的可讀性
單行程式碼的長度不宜太長。比如 PEP8 裡就建議每行字元數不得超過 **79**。現實世界裡,大部分人遵循的單行最大字元數在 79 到 119 之間。如果只是程式碼,這樣的要求是比較容易達到的,但假設程式碼裡需要出現一段超長的字串呢?
這時,除了使用斜槓 `\` 和加號 `+` 將長字串拆分為好幾段以外,還有一種更簡單的辦法:**使用括號將長字串包起來,然後就可以隨意折行了**
```python
def main():
logger.info(("There is something really bad happened during the process. "
"Please contact your administrator."))
```
#### 當多級縮排裡出現多行字串時
日常編碼時,還有一種比較麻煩的情況。就是需要在已經有縮排層級的程式碼裡,插入多行字串字面量。因為多行字串不能包含當前的縮排空格,所以,我們需要把程式碼寫成這樣:
```python
def main():
if user.is_active:
message = """Welcome, today's movie list:
- Jaw (1975)
- The Shining (1980)
- Saw (2004)"""
```
但是這樣寫會破壞整段程式碼的縮排視覺效果,顯得非常突兀。要改善它有很多種辦法,比如我們可以把這段多行字串作為變數提取到模組的最外層。不過,如果在你的程式碼邏輯裡更適合用字面量的話,你也可以用標準庫 `textwrap` 來解決這個問題:
```
from textwrap import dedent
def main():
if user.is_active:
# dedent 將會縮排掉整段文字最左邊的空字串
message = dedent("""\
Welcome, today's movie list:
- Jaw (1975)
- The Shining (1980)
- Saw (2004)""")
```
#### 大數字也可以變得更加可讀
> 該小節內容由 [@laixintao](https://github.com/laixintao) 提供。
對那些特別大的數字,可以透過在中間新增下劃線來提高可讀性
([PEP515](https://www.python.org/dev/peps/pep-0515/),需要 Python3.6+)。
比如:
```
>>> 10_000_000.0 # 以“千”為單位劃分數字
10000000.0
>>> 0xCAFE_F00D # 16進位制數字同樣有效4個一組更易讀
3405705229
>>> 0b_0011_1111_0100_1110 # 二進位制也有效
16206
>>> int('0b_1111_0000', 2) # 處理字串的時候也會正確處理下劃線
240
```
### 3. 別忘了那些 “r” 開頭的內建字串函式
Python 的字串有著非常多實用的內建方法,最常用的有 `.strip()`、`.split()` 等。這些內建方法裡的大多數,處理起來的順序都是從左往右。但是其中也包含了部分以 `r` 打頭的**從右至左處理**的映象方法。在處理特定邏輯時,使用它們可以讓你事半功倍。
假設我們需要解析一些訪問日誌,日誌格式為:"{user_agent}" {content_length}
>>> log_line = '"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36" 47632'
如果使用 `.split()` 將日誌拆分為 `(user_agent, content_length) `,我們需要這麼寫:
```python
>>> l = log_line.split()
>>> " ".join(l[:-1]), l[-1]
('"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"', '47632')
```
但是如果使用 `.rsplit()` 的話,處理邏輯就更直接了:
```python
>>> log_line.rsplit(None, 1)
['"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"', '47632']
```
### 4. 使用“無窮大” float("inf")
如果有人問你:*“Python 裡什麼數字最大/最小?”*。你應該怎麼回答?有這樣的東西存在嗎?
答案是:“有的,它們就是:`float("inf")` 和 `float("-inf")`”。它們倆分別對應著數學世界裡的正負無窮大。當它們和任意數值進行比較時,滿足這樣的規律:`float("-inf") < 任意數值 < float("inf")`。
因為它們有著這樣的特點,我們可以在某些場景用上它們:
```python
# A. 根據年齡升序排序,沒有提供年齡放在最後邊
>>> users = {"tom": 19, "jenny": 13, "jack": None, "andrew": 43}
>>> sorted(users.keys(), key=lambda user: users.get(user) or float('inf'))
['jenny', 'tom', 'andrew', 'jack']
# B. 作為迴圈初始值,簡化第一次判斷邏輯
>>> max_num = float('-inf')
>>> # 找到列表中最大的數字
>>> for i in [23, 71, 3, 21, 8]:
...: if i > max_num:
...: max_num = i
...:
>>> max_num
71
```
## 常見誤區
### 1. “value += 1” 並非執行緒安全
當我們編寫多執行緒程式時,經常需要處理複雜的共享變數和競態等問題。
“執行緒安全”,通常被用來形容 **某個行為或者某類資料結構,可以在多執行緒環境下被共享使用併產生預期內的結果。**一個典型的滿足“執行緒安全”的模組就是 [queue 佇列模組](https://docs.python.org/3/library/queue.html)。
而我們常做的 `value += 1` 操作,很容易被想當然的認為是“執行緒安全”的。因為它看上去就是一個原子操作 *(指一個最小的操作單位,執行途中不會插入任何其他操作)*。然而真相併非如此,雖然從 Python 程式碼上來看,`value += 1` 這個操作像是原子的。但它最終被 Python 直譯器執行的時候,早就不再 *“原子”* 了。
我們可以用前面提到的 `dis` 模組來驗證一下:
```
def incr(value):
value += 1
# 使用 dis 模組檢視位元組碼
import dis
dis.dis(incr)
0 LOAD_FAST 0 (value)
2 LOAD_CONST 1 (1)
4 INPLACE_ADD
6 STORE_FAST 0 (value)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
```
在上面輸出結果中,可以看到這個簡單的累加語句,會被編譯成包括取值和儲存在內的好幾個不同步驟,而在多執行緒環境下,任意一個其他執行緒都有可能在其中某個步驟切入進來,阻礙你獲得正確的結果。
**因此,請不要憑藉自己的直覺來判斷某個行為是否“執行緒安全”,不然等程式在高併發環境下出現奇怪的 bug 時,你將為自己的直覺付出慘痛的代價。**
### 2. 字串拼接並不慢
我剛接觸 Python 不久時,在某個網站看到這樣一個說法: *“Python 裡的字串是不可變的,所以每一次對字串進行拼接都會生成一個新物件,導致新的記憶體分配,效率非常低”。* 我對此深信不疑。
所以,一直以來,我儘量都在避免使用 `+=` 的方式去拼接字串,而是用 `"".join(str_list)` 之類的方式來替代。
但是,在某個偶然的機會下,我對 Python 的字串拼接做了一次簡單的效能測試後發現: **Python 的字串拼接根本就不慢!** 在查閱了一些資料後,最終發現了真相。
Python 的字串拼接在 2.2 以及之前的版本確實很慢,和我最早看到的說法行為一致。但是因為這個操作太常用了,所以之後的版本里專門針對它做了效能最佳化。大大提升了執行效率。
如今使用 `+=` 的方式來拼接字串,效率已經非常接近 `"".join(str_list)` 了。所以,該拼接時就拼接吧,不必擔心任何效能問題。
> Hint: 如果你想了解更詳細的相關內容,可以讀一下這篇文章:[Python - Efficient String Concatenation in Python (2016 edition) - smcl](http://blog.mclemon.io/python-efficient-string-concatenation-in-python-2016-edition)
## 結語
以上就是『Python 工匠』系列文章的第三篇,內容比較零碎。由於篇幅原因,一些常用的操作比如字串格式化等,文章裡並沒有涵蓋到。以後有機會再寫吧。
讓我們最後再總結一下要點:
- 編寫程式碼時,請考慮閱讀者的感受,不要出現太多神奇的字面量
- 當操作結構化字串時,使用物件化模組比直接處理更有優勢
- dis 模組非常有用,請多多使用它驗證你的猜測
- 多執行緒環境下的編碼非常複雜,要足夠謹慎,不要相信自己的直覺
- Python 語言的更新非常快,不要被別人的經驗所左右
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[>>>下一篇【4.容器的門道】](4-mastering-container-types.md)
[<<<上一篇【2.編寫條件分支程式碼的技巧】](2-if-else-block-secrets.md)

View File

@ -0,0 +1,431 @@
# Python 工匠:容器的門道
## 序言
> 這是 “Python 工匠”系列的第 4 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static//uploaded/2019/01/6002476959_cca2bf5424_b_thumb.jpg" width="100%" /><div>
圖片來源: <a href="https://www.flickr.com/photos/chiotsrun/6002476959/in/photolist-a9qgh4-W4eQ1j-7MrCfo-4ARLWp-dwCzHh-Tascu9-RNRbRf-foLHW5-22dkkHM-9ceFA8-aGGd3a-26X3sqQ-iuTwX9-q52ktA-osn2eb-29oujY-6mXd1c-8E92nc-mPbq55-9GuPU8-26Q1NZG-8UL8PL-pdyFsW-7V8ifD-VZavJ8-2cUdHbU-9WrgjZ-6g7M5K-VMLVrb-cXDd4-bygFJG-C76kP-nMQW54-7MoQqn-qA3fud-c92dBU-tAzTBm-7KqFXc-24VvcW1-djQX9e-5LzjkA-63U4kb-bt1EEY-jLRpKo-dQSWBH-aDbqXc-8KhfnE-2m5ZsF-6ciuiR-qwdbt">"The Humble Mason Jar" by Chiot's Run</a> - 非商業性使用 2.0 通用</div>
</div>
容器”這兩個字很少被 Python 技術文章提起。一看到“容器”,大家想到的多是那頭藍色小鯨魚:*Docker*,但這篇文章和它沒有任何關係。本文裡的容器,是 Python 中的一個抽象概念,是對**專門用來裝其他物件的資料型別**的統稱。
在 Python 中,有四類最常見的內建容器型別:`列表list`、`元組tuple`、`字典dict`、`集合set`。透過單獨或是組合使用它們,可以高效的完成很多事情。
Python 語言自身的內部實現細節也與這些容器型別息息相關。比如 Python 的類例項屬性、全域性變數 `globals()` 等就都是透過字典型別來儲存的。
在這篇文章裡,我首先會從容器型別的定義出發,嘗試總結出一些日常編碼的最佳實踐。之後再圍繞各個容器型別提供的特殊機能,分享一些程式設計的小技巧。
### 內容目錄
* [底層看容器](#底層看容器)
* [寫更快的程式碼](#寫更快的程式碼)
* [1. 避免頻繁擴充列表/建立新列表](#1-避免頻繁擴充列表建立新列表)
* [2. 在列表頭部操作多的場景使用 deque 模組](#2-在列表頭部操作多的場景使用-deque-模組)
* [3. 使用集合/字典來判斷成員是否存在](#3-使用集合字典來判斷成員是否存在)
* [高層看容器](#高層看容器)
* [寫擴充套件性更好的程式碼](#寫擴充套件性更好的程式碼)
* [面向容器介面程式設計](#面向容器介面程式設計)
* [常用技巧](#常用技巧)
* [1. 使用元組改善分支程式碼](#1-使用元組改善分支程式碼)
* [2. 在更多地方使用動態解包](#2-在更多地方使用動態解包)
* [3. 使用 next() 函式](#3-使用-next-函式)
* [4. 使用有序字典來去重](#4-使用有序字典來去重)
* [常見誤區](#常見誤區)
* [1. 當心那些已經枯竭的迭代器](#1-當心那些已經枯竭的迭代器)
* [2. 別在迴圈體內修改被迭代物件](#2-別在迴圈體內修改被迭代物件)
* [總結](#總結)
* [系列其他文章](#系列其他文章)
* [註解](#註解)
### 當我們談論容器時,我們在談些什麼?
我在前面給了“容器”一個簡單的定義:*專門用來裝其他物件的就是容器*。但這個定義太寬泛了,無法對我們的日常程式設計產生什麼指導價值。要真正掌握 Python 裡的容器,需要分別從兩個層面入手:
- **底層實現**:內建容器型別使用了什麼資料結構?某項操作如何工作?
- **高層抽象**:什麼決定了某個物件是不是容器?哪些行為定義了容器?
下面,讓我們一起站在這兩個不同的層面上,重新認識容器。
## 底層看容器
Python 是一門高階程式語言,**它所提供的內建容器型別,都是經過高度封裝和抽象後的結果**。和“連結串列”、“紅黑樹”、“雜湊表”這些名字相比,所有 Python 內建型別的名字,都只描述了這個型別的功能特點,其他人完全沒法只通過這些名字瞭解它們的哪怕一丁點內部細節。
這是 Python 程式語言的優勢之一。相比 C 語言這類更接近計算機底層的程式語言Python 重新設計並實現了對程式設計者更友好的內建容器型別,遮蔽掉了記憶體管理等額外工作。為我們提供了更好的開發體驗。
但如果這是 Python 語言的優勢的話,為什麼我們還要費勁去了解容器型別的實現細節呢?答案是:**關注細節可以幫助我們編寫出更快的程式碼。**
### 寫更快的程式碼
#### 1. 避免頻繁擴充列表/建立新列表
所有的內建容器型別都不限制容量。如果你願意,你可以把遞增的數字不斷塞進一個空列表,最終撐爆整臺機器的記憶體。
在 Python 語言的實現細節裡,列表的記憶體是按需分配的[[注1]](#annot1),當某個列表當前擁有的記憶體不夠時,便會觸發記憶體擴容邏輯。而分配記憶體是一項昂貴的操作。雖然大部分情況下,它不會對你的程式效能產生什麼嚴重的影響。但是當你處理的資料量特別大時,很容易因為記憶體分配拖累整個程式的效能。
還好Python 早就意識到了這個問題,並提供了官方的問題解決指引,那就是:**“變懶”**。
如何解釋“變懶”?`range()` 函式的進化是一個非常好的例子。
在 Python 2 中,如果你呼叫 `range(100000000)`,需要等待好幾秒才能拿到結果,因為它需要返回一個巨大的列表,花費了非常多的時間在記憶體分配與計算上。但在 Python 3 中,同樣的呼叫馬上就能拿到結果。因為函式返回的不再是列表,而是一個型別為 `range` 的懶惰物件,只有在你迭代它、或是對它進行切片時,它才會返回真正的數字給你。
**所以說,為了提高效能,內建函式 `range` “變懶”了。** 而為了避免過於頻繁的記憶體分配,在日常編碼中,我們的函式同樣也需要變懶,這包括:
- 更多的使用 `yield` 關鍵字,返回生成器物件
- 儘量使用生成器表示式替代列表推導表示式
- 生成器表示式:`(i for i in range(100))` 👍
- 列表推導表示式:`[i for i in range(100)]`
- 儘量使用模組提供的懶惰物件:
- 使用 `re.finditer` 替代 `re.findall`
- 直接使用可迭代的檔案物件: `for line in fp`,而不是 `for line in fp.readlines()`
#### 2. 在列表頭部操作多的場景使用 deque 模組
列表是基於陣列結構Array實現的當你在列表的頭部插入新成員`list.insert(0, item)`)時,它後面的所有其他成員都需要被移動,操作的時間複雜度是 `O(n)`。這導致在列表的頭部插入成員遠比在尾部追加(`list.append(item)` 時間複雜度為 `O(1)`)要慢。
如果你的程式碼需要執行很多次這類操作,請考慮使用 [collections.deque](https://docs.python.org/3.7/library/collections.html#collections.deque) 型別來替代列表。因為 deque 是基於雙端佇列實現的,無論是在頭部還是尾部追加元素,時間複雜度都是 `O(1)`
#### 3. 使用集合/字典來判斷成員是否存在
當你需要判斷成員是否存在於某個容器時,用集合比列表更合適。因為 `item in [...]` 操作的時間複雜度是 `O(n)`,而 `item in {...}` 的時間複雜度是 `O(1)`。這是因為字典與集合都是基於雜湊表Hash Table資料結構實現的。
```python
# 這個例子不是特別恰當,因為當目標集合特別小時,使用集合還是列表對效率的影響微乎其微
# 但這不是重點 :)
VALID_NAMES = ["piglei", "raymond", "bojack", "caroline"]
# 轉換為集合型別專門用於成員判斷
VALID_NAMES_SET = set(VALID_NAMES)
def validate_name(name):
if name not in VALID_NAMES_SET:
# 此處使用了 Python 3.6 新增的 f-strings 特性
raise ValueError(f"{name} is not a valid name!")
```
> Hint: 強烈建議閱讀 [TimeComplexity - Python Wiki](https://wiki.python.org/moin/TimeComplexity),瞭解更多關於常見容器型別的時間複雜度相關內容。
>
> 如果你對字典的實現細節感興趣,也強烈建議觀看 Raymond Hettinger 的演講 [Modern Dictionaries(YouTube)](https://www.youtube.com/watch?v=p33CVV29OG8&t=1403s)
## 高層看容器
Python 是一門“[鴨子型別](https://en.wikipedia.org/wiki/Duck_typing)”語言:*“當看到一隻鳥走起來像鴨子、游泳起來像鴨子、叫起來也像鴨子,那麼這隻鳥就可以被稱為鴨子。”* 所以,當我們說某個物件是什麼型別時,在根本上其實指的是: **這個物件滿足了該型別的特定介面規範,可以被當成這個型別來使用。** 而對於所有內建容器型別來說,同樣如此。
開啟位於 [collections](https://docs.python.org/3.7/library/collections.html) 模組下的 [abc](https://docs.python.org/3/library/collections.abc.html)*(“抽象類 Abstract Base Classes”的首字母縮寫* 子模組,可以找到所有與容器相關的介面(抽象類)[[注2]](#annot2)定義。讓我們分別看看那些內建容器型別都滿足了什麼介面:
- **列表list**:滿足 `Iterable`、`Sequence`、`MutableSequence` 等介面
- **元組tuple**:滿足 `Iterable`、`Sequence`
- **字典dict**:滿足 `Iterable`、`Mapping`、`MutableMapping` [[注3]](#annot3)
- **集合set**:滿足 `Iterable`、`Set`、`MutableSet` [[注4]](#annot4)
每個內建容器型別,其實就是滿足了多個介面定義的組合實體。比如所有的容器型別都滿足 `“可被迭代的”Iterable` 這個介面,這意味著它們都是“可被迭代”的。但是反過來,不是所有“可被迭代”的物件都是容器。就像字串雖然可以被迭代,但我們通常不會把它當做“容器”來看待。
瞭解這個事實後,我們將**在 Python 裡重新認識**面向物件程式設計中最重要的原則之一:**面向介面而非具體實現來程式設計。**
讓我們透過一個例子,看看如何理解 Python 裡的“面向介面程式設計”。
### 寫擴充套件性更好的程式碼
某日,我們接到一個需求:*有一個列表,裡面裝著很多使用者評論,為了在頁面正常展示,需要將所有超過一定長度的評論用省略號替代*。
這個需求很好做,很快我們就寫出了第一個版本的程式碼:
```python
# 注為了加強示例程式碼的說明性本文中的部分程式碼片段使用了Python 3.5
# 版本新增的 Type Hinting 特性
def add_ellipsis(comments: typing.List[str], max_length: int = 12):
"""如果評論列表裡的內容超過 max_length剩下的字元用省略號代替
"""
index = 0
for comment in comments:
comment = comment.strip()
if len(comment) > max_length:
comments[index] = comment[:max_length] + '...'
index += 1
return comments
comments = [
"Implementation note",
"Changed",
"ABC for generator",
]
print("\n".join(add_ellipsis(comments)))
# OUTPUT:
# Implementati...
# Changed
# ABC for gene...
```
上面的程式碼裡,`add_ellipsis` 函式接收一個列表作為引數,然後遍歷它,替換掉需要修改的成員。這一切看上去很合理,因為我們接到的最原始需求就是:“有一個 **列表**,裡面...”。**但如果有一天,我們拿到的評論不再是被繼續裝在列表裡,而是在不可變的元組裡呢?**
那樣的話,現有的函式設計就會逼迫我們寫出 `add_ellipsis(list(comments))` 這種即慢又難看的程式碼了。😨
#### 面向容器介面程式設計
我們需要改進函式來避免這個問題。因為 `add_ellipsis` 函式強依賴了列表型別,所以當引數型別變為元組時,現在的函式就不再適用了*(原因:給 `comments[index]` 賦值的地方會丟擲 `TypeError` 異常)。* 如何改善這部分的設計?祕訣就是:**讓函式依賴“可迭代物件”這個抽象概念,而非實體列表型別。**
使用生成器特性,函式可以被改成這樣:
```python
def add_ellipsis_gen(comments: typing.Iterable[str], max_length: int = 12):
"""如果可迭代評論裡的內容超過 max_length剩下的字元用省略號代替
"""
for comment in comments:
comment = comment.strip()
if len(comment) > max_length:
yield comment[:max_length] + '...'
else:
yield comment
print("\n".join(add_ellipsis_gen(comments)))
```
在新函式裡,我們將依賴的引數型別從列表改成了可迭代的抽象類。這樣做有很多好處,一個最明顯的就是:無論評論是來自列表、元組或是某個檔案,新函式都可以輕鬆滿足:
```python
# 處理放在元組裡的評論
comments = ("Implementation note", "Changed", "ABC for generator")
print("\n".join(add_ellipsis_gen(comments)))
# 處理放在檔案裡的評論
with open("comments") as fp:
for comment in add_ellipsis_gen(fp):
print(comment)
```
將依賴由某個具體的容器型別改為抽象介面後,函式的適用面變得更廣了。除此之外,新函式在執行效率等方面也都更有優勢。現在讓我們再回到之前的問題。**從高層來看,什麼定義了容器?**
答案是: **各個容器型別實現的介面協議定義了容器。** 不同的容器型別在我們的眼裡,應該是 `是否可以迭代`、`是否可以修改`、`有沒有長度` 等各種特性的組合。我們需要在編寫相關程式碼時,**更多的關注容器的抽象屬性,而非容器型別本身**,這樣可以幫助我們寫出更優雅、擴充套件性更好的程式碼。
> Hint在 [itertools](https://docs.python.org/3/library/itertools.html) 與 [more-itertools](https://pypi.org/project/more-itertools/) 模組裡可以找到更多關於處理可迭代物件的寶藏。
## 常用技巧
### 1. 使用元組改善分支程式碼
有時,我們的程式碼裡會出現超過三個分支的 `if/else` 。就像下面這樣:
```python
import time
def from_now(ts):
"""接收一個過去的時間戳,返回距離當前時間的相對時間文字描述
"""
now = time.time()
seconds_delta = int(now - ts)
if seconds_delta < 1:
return "less than 1 second ago"
elif seconds_delta < 60:
return "{} seconds ago".format(seconds_delta)
elif seconds_delta < 3600:
return "{} minutes ago".format(seconds_delta // 60)
elif seconds_delta < 3600 * 24:
return "{} hours ago".format(seconds_delta // 3600)
else:
return "{} days ago".format(seconds_delta // (3600 * 24))
now = time.time()
print(from_now(now))
print(from_now(now - 24))
print(from_now(now - 600))
print(from_now(now - 7500))
print(from_now(now - 87500))
# OUTPUT:
# less than 1 second ago
# 24 seconds ago
# 10 minutes ago
# 2 hours ago
# 1 days ago
```
上面這個函式挑不出太多毛病,很多很多人都會寫出類似的程式碼。但是,如果你仔細觀察它,可以在分支程式碼部分找到一些明顯的“**邊界**”。 比如,當函式判斷某個時間是否應該用“秒數”展示時,用到了 `60`。而判斷是否應該用分鐘時,用到了 `3600`
**從邊界提煉規律是最佳化這段程式碼的關鍵。** 如果我們將所有的這些邊界放在一個有序元組中,然後配合二分查詢模組 [bisect](https://docs.python.org/3.7/library/bisect.html)。整個函式的控制流就能被大大簡化:
```python
import bisect
# BREAKPOINTS 必須是已經排好序的,不然無法進行二分查詢
BREAKPOINTS = (1, 60, 3600, 3600 * 24)
TMPLS = (
# unit, template
(1, "less than 1 second ago"),
(1, "{units} seconds ago"),
(60, "{units} minutes ago"),
(3600, "{units} hours ago"),
(3600 * 24, "{units} days ago"),
)
def from_now(ts):
"""接收一個過去的時間戳,返回距離當前時間的相對時間文字描述
"""
seconds_delta = int(time.time() - ts)
unit, tmpl = TMPLS[bisect.bisect(BREAKPOINTS, seconds_delta)]
return tmpl.format(units=seconds_delta // unit)
```
除了用元組可以最佳化過多的 `if/else` 分支外,有些情況下字典也能被用來做同樣的事情。關鍵在於從現有程式碼找到重複的邏輯與規律,並多多嘗試。
### 2. 在更多地方使用動態解包
動態解包操作是指使用 `*``**` 運算子將可迭代物件“解開”的行為,在 Python 2 時代,這個操作只能被用在函式引數部分,並且對出現順序和數量都有非常嚴格的要求,使用場景非常單一。
```python
def calc(a, b, multiplier=1):
return (a + b) * multiplier
# Python2 中只支援在函式引數部分進行動態解包
print calc(*[1, 2], **{"multiplier": 10})
# OUTPUT: 30
```
不過Python 3 尤其是 3.5 版本後,`*` 和 `**` 的使用場景被大大擴充了。舉個例子,在 Python 2 中,如果我們需要合併兩個字典,需要這麼做:
```python
def merge_dict(d1, d2):
# 因為字典是可被修改的物件,為了避免修改原物件,此處需要複製一個 d1 的淺複製
result = d1.copy()
result.update(d2)
return result
user = merge_dict({"name": "piglei"}, {"movies": ["Fight Club"]})
```
但是在 Python 3.5 以後的版本,你可以直接用 `**` 運算子來快速完成字典的合併操作:
```
user = {**{"name": "piglei"}, **{"movies": ["Fight Club"]}}
```
除此之外,你還可以在普通賦值語句中使用 `*` 運算子來動態的解包可迭代物件。如果你想詳細瞭解相關內容,可以閱讀下面推薦的 PEP。
> Hint推進動態解包場景擴充的兩個 PEP
> - [PEP 3132 -- Extended Iterable Unpacking | Python.org](https://www.python.org/dev/peps/pep-3132/)
> - [PEP 448 -- Additional Unpacking Generalizations | Python.org](https://www.python.org/dev/peps/pep-0448/)
### 3. 使用 next() 函式
`next()` 是一個非常實用的內建函式,它接收一個迭代器作為引數,然後返回該迭代器的下一個元素。使用它配合生成器表示式,可以高效的實現*“從列表中查詢第一個滿足條件的成員”* 之類的需求。
```python
numbers = [3, 7, 8, 2, 21]
# 獲取並 **立即返回** 列表裡的第一個偶數
print(next(i for i in numbers if i % 2 == 0))
# OUTPUT: 8
```
### 4. 使用有序字典來去重
字典和集合的結構特點保證了它們的成員不會重複所以它們經常被用來去重。但是使用它們倆去重後的結果會丟失原有列表的順序。這是由底層資料結構“雜湊表Hash Table”的特點決定的。
```python
>>> l = [10, 2, 3, 21, 10, 3]
# 去重但是丟失了順序
>>> set(l)
{3, 10, 2, 21}
```
如果既需要去重又必須保留順序怎麼辦?我們可以使用 `collections.OrderedDict` 模組:
```python
>>> from collections import OrderedDict
>>> list(OrderedDict.fromkeys(l).keys())
[10, 2, 3, 21]
```
> Hint: 在 Python 3.6 中,預設的字典型別修改了實現方式,已經變成有序的了。並且在 Python 3.7 中,該功能已經從 **語言的實現細節** 變成了為 **可依賴的正式語言特性**
>
> 但是我覺得讓整個 Python 社群習慣這一點還需要一些時間,畢竟目前“字典是無序的”還是被印在無數本 Python 書上。所以,我仍然建議在一切需要有序字典的地方使用 OrderedDict。
## 常見誤區
### 1. 當心那些已經枯竭的迭代器
在文章前面,我們提到了使用“懶惰”生成器的種種好處。但是,所有事物都有它的兩面性。生成器的最大的缺點之一就是:**它會枯竭**。當你完整遍歷過它們後,之後的重複遍歷就不能拿到任何新內容了。
```python
numbers = [1, 2, 3]
numbers = (i * 2 for i in numbers)
# 第一次迴圈會輸出 2, 4, 6
for number in numbers:
print(number)
# 這次迴圈什麼都不會輸出,因為迭代器已經枯竭了
for number in numbers:
print(number)
```
而且不光是生成器表示式Python 3 裡的 map、filter 內建函式也都有一樣的特點。忽視這個特點很容易導致程式碼中出現一些難以察覺的 Bug。
Instagram 就在專案從 Python 2 到 Python 3 的遷移過程中碰到了這個問題。它們在 PyCon 2017 上分享了對付這個問題的故事。訪問文章 [Instagram 在 PyCon 2017 的演講摘要](https://www.zlovezl.cn/articles/instagram-pycon-2017/),搜尋“迭代器”可以檢視詳細內容。
### 2. 別在迴圈體內修改被迭代物件
這是一個很多 Python 初學者會犯的錯誤。比如,我們需要一個函式來刪掉列表裡的所有偶數:
```python
def remove_even(numbers):
"""去掉列表裡所有的偶數
"""
for i, number in enumerate(numbers):
if number % 2 == 0:
# 有問題的程式碼
del numbers[i]
numbers = [1, 2, 7, 4, 8, 11]
remove_even(numbers)
print(numbers)
# OUTPUT: [1, 7, 8, 11]
```
注意到結果裡那個多出來的 “8” 了嗎?當你在遍歷一個列表的同時修改它,就會出現這樣的事情。因為被迭代的物件 `numbers` 在迴圈過程中被修改了。**遍歷的下標在不斷增長,而列表本身的長度同時又在不斷縮減。這樣就會導致列表裡的一些成員其實根本就沒有被遍歷到。**
所以對於這類操作,請使用一個新的空列表儲存結果,或者利用 `yield` 返回一個生成器。而不是修改被迭代的列表或是字典物件本身。
## 總結
在這篇文章中,我們首先從“容器型別”的定義出發,在底層和高層兩個層面探討了容器型別。之後遵循系列文章傳統,提供了一些編寫容器相關程式碼時的技巧。
讓我們最後再總結一下要點:
- 瞭解容器型別的底層實現,可以幫助你寫出效能更好的程式碼
- 提煉需求裡的抽象概念,面向介面而非實現程式設計
- 多使用“懶惰”的物件,少生成“迫切”的列表
- 使用元組和字典可以簡化分支程式碼結構
- 使用 `next()` 函式配合迭代器可以高效完成很多事情,但是也需要注意“枯竭”問題
- collections、itertools 模組裡有非常多有用的工具,快去看看吧!
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[>>>下一篇【5.讓函式返回結果的技巧】](5-function-returning-tips.md)
[<<<上一篇【3.使用數字與字串的技巧】](3-tips-on-numbers-and-strings.md)
## 系列其他文章
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:善用變數改善程式碼質量](https://www.zlovezl.cn/articles/python-using-variables-well/)
- [Python 工匠:編寫條件分支程式碼的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:使用數字與字串的技巧](https://www.zlovezl.cn/articles/tips-on-numbers-and-strings/)
## 註解
1. <a id="annot1"></a>Python 這門語言除了 CPython 外,還有許許多多的其他版本實現。如無特別說明,本文以及 “Python 工匠” 系列裡出現的所有 Python 都特指 Python 的 C 語言實現 CPython
2. <a id="annot2"></a>Python 裡沒有類似其他程式語言裡的“Interface 介面”型別,只有類似的“抽象類”概念。為了表達方便,後面的內容均統一使用“介面”來替代“抽象類”。
3. <a id="annot3"></a>有沒有隻實現了 Mapping 但又不是 MutableMapping 的型別?試試 [MappingProxyType({})](https://docs.python.org/3/library/types.html#types.MappingProxyType)
4. <a id="annot4"></a>有沒有隻實現了 Set 但又不是 MutableSet 的型別?試試 [frozenset()](https://docs.python.org/3/library/stdtypes.html#frozenset)

View File

@ -0,0 +1,410 @@
# Python 工匠:讓函式返回結果的技巧
## 序言
> 這是 “Python 工匠”系列的第 5 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/03/dominik-scythe-283337-unsplash-w1280.jpg" width="100%" />
</div>
毫無疑問,函式是 Python 語言裡最重要的概念之一。在程式設計時,我們將真實世界裡的大問題分解為小問題,然後透過一個個函式交出答案。函式即是重複程式碼的剋星,也是對抗程式碼複雜度的最佳武器。
如同大部分故事都會有結局,絕大多數函式也都是以**返回結果**作為結束。函式返回結果的手法,決定了呼叫它時的體驗。所以,瞭解如何優雅的讓函式返回結果,是編寫好函式的必備知識。
### Python 的函式返回方式
Python 函式透過呼叫 `return` 語句來返回結果。使用 `return value` 可以返回單個值,用 `return value1, value2` 則能讓函式同時返回多個值。
如果一個函式體內沒有任何 `return` 語句,那麼這個函式的返回值預設為 `None`。除了透過 `return` 語句返回內容,在函式內還可以使用丟擲異常*raise Exception*的方式來“返回結果”。
接下來,我將列舉一些與函式返回相關的常用程式設計建議。
### 內容目錄
* [程式設計建議](#程式設計建議)
* [1. 單個函式不要返回多種型別](#1-單個函式不要返回多種型別)
* [2. 使用 partial 構造新函式](#2-使用-partial-構造新函式)
* [3. 丟擲異常,而不是返回結果與錯誤](#3-丟擲異常而不是返回結果與錯誤)
* [4. 謹慎使用 None 返回值](#4-謹慎使用-none-返回值)
* [1. 作為操作類函式的預設返回值](#1-作為操作類函式的預設返回值)
* [2. 作為某些“意料之中”的可能沒有的值](#2-作為某些意料之中的可能沒有的值)
* [3. 作為呼叫失敗時代表“錯誤結果”的值](#3-作為呼叫失敗時代表錯誤結果的值)
* [5. 合理使用“空物件模式”](#5-合理使用空物件模式)
* [6. 使用生成器函式代替返回列表](#6-使用生成器函式代替返回列表)
* [7. 限制遞迴的使用](#7-限制遞迴的使用)
* [總結](#總結)
* [附錄](#附錄)
## 程式設計建議
### 1. 單個函式不要返回多種型別
Python 語言非常靈活,我們能用它輕鬆完成一些在其他語言裡很難做到的事情。比如:*讓一個函式同時返回不同型別的結果。*從而實現一種看起來非常實用的“多功能函式”。
就像下面這樣:
```python
def get_users(user_id=None):
if user_id is not None:
return User.get(user_id)
else:
return User.filter(is_active=True)
# 返回單個使用者
get_users(user_id=1)
# 返回多個使用者
get_users()
```
當我們需要獲取單個使用者時,就傳遞 `user_id` 引數,否則就不傳引數拿到所有活躍使用者列表。一切都由一個函式 `get_users` 來搞定。這樣的設計似乎很合理。
然而在函式的世界裡,以編寫具備“多功能”的瑞士軍刀型函式為榮不是一件好事。這是因為好的函式一定是 [“單一職責Single responsibility](https://en.wikipedia.org/wiki/Single_responsibility_principle) 的。**單一職責意味著一個函式只做好一件事,目的明確。**這樣的函式也更不容易在未來因為需求變更而被修改。
而返回多種型別的函式一定是違反“單一職責”原則的,**好的函式應該總是提供穩定的返回值,把呼叫方的處理成本降到最低。**像上面的例子,我們應該編寫兩個獨立的函式 `get_user_by_id(user_id)`、`get_active_users()` 來替代。
### 2. 使用 partial 構造新函式
假設這麼一個場景,在你的程式碼裡有一個引數很多的函式 `A`,適用性很強。而另一個函式 `B` 則是完全透過呼叫 `A` 來完成工作,是一種類似快捷方式的存在。
比方在這個例子裡, `double` 函式就是完全透過 `multiply` 來完成計算的:
```python
def multiply(x, y):
return x * y
def double(value):
# 返回另一個函式呼叫結果
return multiply(2, value)
```
對於上面這種場景,我們可以使用 `functools` 模組裡的 [`partial()`](https://docs.python.org/3.6/library/functools.html#functools.partial) 函式來簡化它。
`partial(func, *args, **kwargs)` 基於傳入的函式與可變(位置/關鍵字)引數來構造一個新函式。**所有對新函式的呼叫,都會在合併了當前呼叫引數與構造引數後,代理給原始函式處理。**
利用 `partial` 函式,上面的 `double` 函式定義可以被修改為單行表示式,更簡潔也更直接。
```python
import functools
double = functools.partial(multiply, 2)
```
> 建議閱讀:[partial 函式官方文件](https://docs.python.org/3.6/library/functools.html#functools.partial)
### 3. 丟擲異常,而不是返回結果與錯誤
我在前面提過Python 裡的函式可以返回多個值。基於這個能力,我們可以編寫一類特殊的函式:**同時返回結果與錯誤資訊的函式。**
```python
def create_item(name):
if len(name) > MAX_LENGTH_OF_NAME:
return None, 'name of item is too long'
if len(CURRENT_ITEMS) > MAX_ITEMS_QUOTA:
return None, 'items is full'
return Item(name=name), ''
def create_from_input():
name = input()
item, err_msg = create_item(name)
if err_msg:
print(f'create item failed: {err_msg}')
else:
print(f'item<{name}> created')
```
在示例中,`create_item` 函式的作用是建立新的 Item 物件。同時,為了在出錯時給呼叫方提供錯誤詳情,它利用了多返回值特性,把錯誤資訊作為第二個結果返回。
乍看上去,這樣的做法很自然。尤其是對那些有 `Go` 語言程式設計經驗的人來說更是如此。但是在 Python 世界裡,這並非解決此類問題的最佳辦法。因為這種做法會增加呼叫方進行錯誤處理的成本,尤其是當很多函式都遵循這個規範而且存在多層呼叫時。
Python 具備完善的*異常Exception*機制,並且在某種程度上鼓勵我們使用異常([官方文件關於 EAFP 的說明](https://docs.python.org/3/glossary.html#term-eafp))。所以,**使用異常來進行錯誤流程處理才是更地道的做法。**
引入自定義異常後,上面的程式碼可以被改寫成這樣:
```python
class CreateItemError(Exception):
"""建立 Item 失敗時丟擲的異常"""
def create_item(name):
"""建立一個新的 Item
:raises: 當無法建立時丟擲 CreateItemError
"""
if len(name) > MAX_LENGTH_OF_NAME:
raise CreateItemError('name of item is too long')
if len(CURRENT_ITEMS) > MAX_ITEMS_QUOTA:
raise CreateItemError('items is full')
return Item(name=name)
def create_for_input():
name = input()
try:
item = create_item(name)
except CreateItemError as e:
print(f'create item failed: {err_msg}')
else:
print(f'item<{name}> created')
```
使用“丟擲異常”替代“返回 (結果, 錯誤資訊)”後,整個錯誤流程處理乍看上去變化不大,但實際上有著非常多不同,一些細節:
- 新版本函式擁有更穩定的返回值型別,它永遠只會返回 `Item` 型別或是丟擲異常
- 雖然我在這裡鼓勵使用異常,但“異常”總是會無法避免的讓人 **感到驚訝**,所以,最好在函式文件裡說明可能丟擲的異常型別
- 異常不同於返回值,它在被捕獲前會不斷往呼叫棧上層彙報。所以 `create_item` 的一級呼叫方完全可以省略異常處理,交由上層處理。這個特點給了我們更多的靈活性,但同時也帶來了更大的風險。
> Hint如何在程式語言裡處理錯誤是一個至今仍然存在爭議的主題。比如像上面不推薦的多返回值方式正是缺乏異常的 Go 語言中最核心的錯誤處理機制。另外,即使是異常機制本身,不同程式語言之間也存在著差別。
>
> 異常,或是不異常,都是由語言設計者進行多方取捨後的結果,更多時候不存在絕對性的優劣之分。**但是,單就 Python 語言而言,使用異常來表達錯誤無疑是更符合 Python 哲學,更應該受到推崇的。**
### 4. 謹慎使用 None 返回值
`None` 值通常被用來表示**“某個應該存在但是缺失的東西”**,它在 Python 裡是獨一無二的存在。很多程式語言裡都有與 None 類似的設計,比如 JavaScript 裡的 `null`、Go 裡的 `nil` 等。因為 None 所擁有的獨特 *虛無* 氣質,它經常被作為函式返回值使用。
當我們使用 None 作為函式返回值時,通常是下面 3 種情況。
#### 1. 作為操作類函式的預設返回值
當某個操作類函式不需要任何返回值時,通常就會返回 None。同時None 也是不帶任何 `return` 語句函式的預設返回值。
對於這種函式,使用 None 是沒有任何問題的,標準庫裡的 `list.append()`、`os.chdir()` 均屬此類。
#### 2. 作為某些“意料之中”的可能沒有的值
有一些函式,它們的目的通常是去嘗試性的做某件事情。視情況不同,最終可能有結果,也可能沒有結果。**而對呼叫方來說,“沒有結果”完全是意料之中的事情**。對這類函式來說,使用 None 作為“沒結果”時的返回值也是合理的。
在 Python 標準庫裡,正則表示式模組 `re` 下的 `re.search`、`re.match` 函式均屬於此類,這兩個函式在可以找到匹配結果時返回 `re.Match` 物件,找不到時則返回 `None`
#### 3. 作為呼叫失敗時代表“錯誤結果”的值
有時,`None` 也會經常被我們用來作為函式呼叫失敗時的預設返回值,比如下面這個函式:
```python
def create_user_from_name(username):
"""透過使用者名稱建立一個 User 例項"""
if validate_username(username):
return User.from_username(username)
else:
return None
user = create_user_from_name(username)
if user:
user.do_something()
```
當 username 不合法時,函式 `create_user_from_name` 將會返回 None。但在這個場景下這樣做其實並不好。
不過你也許會覺得這個函式完全合情合理,甚至你會覺得它和我們提到的上一個“沒有結果”時的用法非常相似。那麼如何區分這兩種不同情形呢?關鍵在於:**函式簽名(名稱與引數)與 None 返回值之間是否存在一種“意料之中”的暗示。**
讓我解釋一下,每當你讓函式返回 None 值時,請**仔細閱讀函式名**,然後問自己一個問題:*假如我是該函式的使用者,從這個名字來看,“拿不到任何結果”是否是該函式名稱含義裡的一部分?*
分別用這兩個函式來舉例:
- `re.search()`:從函式名來看,`search`,代表著從目標字串裡去**搜尋**匹配結果,而搜尋行為,一向是可能有也可能沒有結果的,所以該函式適合返回 None
- `create_user_from_name()`:從函式名來看,代表基於一個名字來構建使用者,並不能讀出一種`可能返回、可能不返回`的含義。所以不適合返回 None
對於那些不能從函式名裡讀出 None 值暗示的函式來說,有兩種修改方式。第一種,如果你堅持使用 None 返回值,那麼請修改函式的名稱。比如可以將函式 `create_user_from_name()` 改名為 `create_user_or_none()`
第二種方式則更常見的多:用丟擲異常*raise Exception*來代替 None 返回值。因為,如果返回不了正常結果並非函式意義裡的一部分,這就代表著函數出現了*“意料以外的狀況”*,而這正是 **Exceptions 異常** 所掌管的領域。
使用異常改寫後的例子:
```python
class UnableToCreateUser(Exception):
"""當無法建立使用者時丟擲"""
def create_user_from_name(username):
""透過使用者名稱建立一個 User 例項"
:raises: 當無法建立使用者時丟擲 UnableToCreateUser
"""
if validate_username(username):
return User.from_username(username)
else:
raise UnableToCreateUser(f'unable to create user from {username}')
try:
user = create_user_from_name(username)
except UnableToCreateUser:
# Error handling
else:
user.do_something()
```
與 None 返回值相比,丟擲異常除了擁有我們在上個場景提到的那些特點外,還有一個額外的優勢:**可以在異常資訊裡提供出現意料之外結果的原因**,這是隻返回一個 None 值做不到的。
### 5. 合理使用“空物件模式”
我在前面提到函式可以用 `None` 值或異常來返回錯誤結果,但這兩種方式都有一個共同的缺點。那就是所有需要使用函式返回值的地方,都必須加上一個 `if``try/except` 防禦語句,來判斷結果是否正常。
讓我們看一個可執行的完整示例:
```python
import decimal
class CreateAccountError(Exception):
"""Unable to create a account error"""
class Account:
"""一個虛擬的銀行賬號"""
def __init__(self, username, balance):
self.username = username
self.balance = balance
@classmethod
def from_string(cls, s):
"""從字串初始化一個賬號"""
try:
username, balance = s.split()
balance = decimal.Decimal(float(balance))
except ValueError:
raise CreateAccountError('input must follow pattern "{ACCOUNT_NAME} {BALANCE}"')
if balance < 0:
raise CreateAccountError('balance can not be negative')
return cls(username=username, balance=balance)
def caculate_total_balance(accounts_data):
"""計算所有賬號的總餘額
"""
result = 0
for account_string in accounts_data:
try:
user = Account.from_string(account_string)
except CreateAccountError:
pass
else:
result += user.balance
return result
accounts_data = [
'piglei 96.5',
'cotton 21',
'invalid_data',
'roland $invalid_balance',
'alfred -3',
]
print(caculate_total_balance(accounts_data))
```
在這個例子裡,每當我們呼叫 `Account.from_string` 時,都必須使用 `try/except` 來捕獲可能發生的異常。如果專案裡需要呼叫很多次該函式,這部分工作就變得非常繁瑣了。針對這種情況,可以使用[“空物件模式Null object pattern”](https://en.wikipedia.org/wiki/Null_object_pattern)來改善這個控制流。
Martin Fowler 在他的經典著作[《重構》](https://martinfowler.com/books/refactoring.html) 中用一個章節詳細說明過這個模式。簡單來說,**就是使用一個符合正常結果介面的“空型別”來替代空值返回/丟擲異常,以此來降低呼叫方處理結果的成本。**
引入“空物件模式”後,上面的示例可以被修改成這樣:
```python
class Account:
# def __init__ 已省略... ...
@classmethod
def from_string(cls, s):
"""從字串初始化一個賬號
:returns: 如果輸入合法,返回 Account object否則返回 NullAccount
"""
try:
username, balance = s.split()
balance = decimal.Decimal(float(balance))
except ValueError:
return NullAccount()
if balance < 0:
return NullAccount()
return cls(username=username, balance=balance)
class NullAccount:
username = ''
balance = 0
@classmethod
def from_string(cls, s):
raise NotImplementedError
```
在新版程式碼裡,我定義了 `NullAccount` 這個新型別,用來作為 `from_string` 失敗時的錯誤結果返回。這樣修改後的最大變化體現在 `caculate_total_balance` 部分:
```python
def caculate_total_balance(accounts_data):
"""計算所有賬號的總餘額
"""
return sum(Account.from_string(s).balance for s in accounts_data)
```
調整之後,呼叫方不必再顯式使用 try 語句來處理錯誤,而是可以假設 `Account.from_string` 函式總是會返回一個合法的 Account 物件,從而大大簡化整個計算邏輯。
> Hint在 Python 世界裡,“空物件模式”並不少見,比如大名鼎鼎的 Django 框架裡的 [AnonymousUser](https://docs.djangoproject.com/en/2.1/ref/contrib/auth/#anonymoususer-object) 就是一個典型的 null object。
### 6. 使用生成器函式代替返回列表
在函式裡返回列表特別常見,通常,我們會先初始化一個列表 `results = []`,然後在迴圈體內使用 `results.append(item)` 函式填充它,最後在函式的末尾返回。
對於這類模式,我們可以用生成器函式來簡化它。粗暴點說,就是用 `yield item` 替代 `append` 語句。使用生成器的函式通常更簡潔、也更具通用性。
```python
def foo_func(items):
for item in items:
# ... 處理 item 後直接使用 yield 返回
yield item
```
我在 [系列第 4 篇文章“容器的門道”](https://www.zlovezl.cn/articles/mastering-container-types/) 裡詳細分析過這個模式,更多細節可以訪問文章,搜尋 “寫擴充套件性更好的程式碼” 檢視。
### 7. 限制遞迴的使用
當函式返回自身呼叫時,也就是 `遞迴` 發生時。遞迴是一種在特定場景下非常有用的程式設計技巧但壞訊息是Python 語言對遞迴支援的非常有限。
這份“有限的支援”體現在很多方面。首先Python 語言不支援[“尾遞迴最佳化”](https://en.wikipedia.org/wiki/Tail_call)。另外 Python 對最大遞迴層級數也有著嚴格的限制。
所以我建議:**儘量少寫遞迴**。如果你想用遞迴解決問題,先想想它是不是能方便的用迴圈來替代。如果答案是肯定的,那麼就用迴圈來改寫吧。如果迫不得已,一定需要使用遞迴時,請考慮下面幾個點:
- 函式輸入資料規模是否穩定,是否一定不會超過 `sys.getrecursionlimit()` 規定的最大層數限制
- 是否可以透過使用類似 [functools.lru_cache](https://docs.python.org/3/library/functools.html#functools.lru_cache) 的快取工具函式來降低遞迴層數
## 總結
在這篇文章中,我虛擬了一些與 Python 函式返回有關的場景,並針對每個場景提供了我的最佳化建議。最後再總結一下要點:
- 讓函式擁有穩定的返回值,一個函式只做好一件事
- 使用 `functools.partial` 定義快捷函式
- 丟擲異常也是返回結果的一種方式,使用它來替代返回錯誤資訊
- 函式是否適合返回 None由函式簽名的“含義”所決定
- 使用“空物件模式”可以簡化呼叫方的錯誤處理邏輯
- 多使用生成器函式,儘量用迴圈替代遞迴
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[>>>下一篇【6.異常處理的三個好習慣】](6-three-rituals-of-exceptions-handling.md)
[<<<上一篇【4.容器的門道】](4-mastering-container-types.md)
## 附錄
- 題圖來源: Dominik Scythe on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:善用變數改善程式碼質量](https://www.zlovezl.cn/articles/python-using-variables-well/)
- [Python 工匠:編寫條件分支程式碼的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:使用數字與字串的技巧](https://www.zlovezl.cn/articles/tips-on-numbers-and-strings/)

View File

@ -0,0 +1,326 @@
# Python 工匠: 異常處理的三個好習慣
## 前言
> 這是 “Python 工匠”系列的第 6 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/03/bernard-hermant-665508-unsplash_w1280.jpg" width="100%" />
</div>
如果你用 Python 程式設計,那麼你就無法避開異常,因為異常在這門語言裡無處不在。打個比方,當你在指令碼執行時按 `ctrl+c` 退出,直譯器就會產生一個 `KeyboardInterrupt` 異常。而 `KeyError`、`ValueError`、`TypeError` 等更是日常程式設計裡隨處可見的老朋友。
異常處理工作由“捕獲”和“丟擲”兩部分組成。“捕獲”指的是使用 `try ... except` 包裹特定語句,妥當的完成錯誤流程處理。而恰當的使用 `raise` 主動“丟擲”異常,更是優雅程式碼裡必不可少的組成部分。
在這篇文章裡,我會分享與異常處理相關的 3 個好習慣。繼續閱讀前,我希望你已經瞭解了下面這些知識點:
- 異常的基本語法與用法*(建議閱讀官方文件 [“Errors and Exceptions”](https://docs.python.org/3.6/tutorial/errors.html)*
- 為什麼要使用異常代替錯誤返回*(建議閱讀[《讓函式返回結果的技巧》](https://www.zlovezl.cn/articles/function-returning-tips/)*
- 為什麼在寫 Python 時鼓勵使用異常 *(建議閱讀 [“Write Cleaner Python: Use Exceptions”](https://jeffknupp.com/blog/2013/02/06/write-cleaner-python-use-exceptions/)*
## 三個好習慣
### 1. 只做最精確的異常捕獲
假如你不夠了解異常機制,就難免會對它有一種天然恐懼感。你可能會覺得:*異常是一種不好的東西,好的程式就應該捕獲所有的異常,讓一切都平平穩穩的執行。*而抱著這種想法寫出的程式碼,裡面通常會出現大段含糊的異常捕獲邏輯。
讓我們用一段可執行指令碼作為樣例:
```python
# -*- coding: utf-8 -*-
import requests
import re
def save_website_title(url, filename):
"""獲取某個地址的網頁標題,然後將其寫入到檔案中
:returns: 如果成功儲存,返回 True否則列印錯誤返回 False
"""
try:
resp = requests.get(url)
obj = re.search(r'<title>(.*)</title>', resp.text)
if not obj:
print('save failed: title tag not found in page content')
return False
title = obj.grop(1)
with open(filename, 'w') as fp:
fp.write(title)
return True
except Exception:
print(f'save failed: unable to save title of {url} to {filename}')
return False
def main():
save_website_title('https://www.qq.com', 'qq_title.txt')
if __name__ == '__main__':
main()
```
腳本里的 `save_website_title` 函式做了好幾件事情。它首先透過網路獲取網頁內容,然後利用正則匹配出標題,最後將標題寫在本地檔案裡。而這裡有兩個步驟很容易出錯:**網路請求** 與 **本地檔案操作**。所以在程式碼裡,我們用一個大大的 `try ... except` 語句塊,將這幾個步驟都包裹了起來。**安全第一** ⛑。
那麼,這段看上去簡潔易懂的程式碼,裡面藏著什麼問題呢?
如果你旁邊剛好有一臺安裝了 Python 的電腦,那麼你可以試著跑一遍上面的指令碼。你會發現,上面的程式碼是不能成功執行的。而且你還會發現,無論你如何修改網址和目標檔案的值,程式仍然會報錯 *“save failed: unable to...”*。為什麼呢?
問題就藏在這個碩大無比的 `try ... except` 語句塊裡。假如你把眼睛貼近螢幕,非常仔細的檢查這段程式碼。你會發現在編寫函式時,我犯了一個**小錯誤**,我把獲取正則匹配串的方法錯打成了 `obj.grop(1)`,少了一個 'u'`obj.group(1)`)。
但正是因為那個過於龐大、含糊的異常捕獲,這個由打錯方法名導致的原本該被丟擲的 `AttibuteError` 卻被吞噬了。從而給我們的 debug 過程增加了不必要的麻煩。
異常捕獲的目的,不是去捕獲儘可能多的異常。假如我們從一開始就堅持:**只做最精準的異常捕獲**。那麼這樣的問題就根本不會發生,精準捕獲包括:
- 永遠只捕獲那些可能會丟擲異常的語句塊
- 儘量只捕獲精確的異常型別,而不是模糊的 `Exception`
依照這個原則,我們的樣例應該被改成這樣:
```python
from requests.exceptions import RequestException
def save_website_title(url, filename):
try:
resp = requests.get(url)
except RequestException as e:
print(f'save failed: unable to get page content: {e}')
return False
# 這段正則操作本身就是不應該丟擲異常的,所以我們沒必要使用 try 語句塊
# 假如 group 被誤打成了 grop 也沒關係,程式馬上就會透過 AttributeError 來
# 告訴我們。
obj = re.search(r'<title>(.*)</title>', resp.text)
if not obj:
print('save failed: title tag not found in page content')
return False
title = obj.group(1)
try:
with open(filename, 'w') as fp:
fp.write(title)
except IOError as e:
print(f'save failed: unable to write to file {filename}: {e}')
return False
else:
return True
```
### 2. 別讓異常破壞抽象一致性
大約四五年前,當時的我正在開發某移動應用的後端 API 專案。如果你也有過開發後端 API 的經驗,那麼你一定知道,這樣的系統都需要制定一套**“API 錯誤碼規範”**,來為客戶端處理呼叫錯誤時提供方便。
一個錯誤碼返回大概長這個樣子:
```javascript
// HTTP Status Code: 400
// Content-Type: application/json
{
"code": "UNABLE_TO_UPVOTE_YOUR_OWN_REPLY",
"detail": "你不能推薦自己的回覆"
}
```
在制定好錯誤碼規範後,接下來的任務就是如何實現它。當時的專案使用了 Django 框架,而 Django 的錯誤頁面正是使用了異常機制實現的。打個比方,如果你想讓一個請求返回 404 狀態碼,那麼只要在該請求處理過程中執行 `raise Http404` 即可。
所以,我們很自然的從 Django 獲得了靈感。首先,我們在專案內定義了錯誤碼異常類:`APIErrorCode`。然後依據“錯誤碼規範”,寫了很多繼承該類的錯誤碼。當需要返回錯誤資訊給使用者時,只需要做一次 `raise` 就能搞定。
```python
raise error_codes.UNABLE_TO_UPVOTE
raise error_codes.USER_HAS_BEEN_BANNED
... ...
```
毫無意外,所有人都很喜歡用這種方式來返回錯誤碼。因為它用起來非常方便,無論呼叫棧多深,只要你想給使用者返回錯誤碼,呼叫 `raise error_codes.ANY_THING` 就好。
隨著時間推移,專案也變得越來越龐大,丟擲 `APIErrorCode` 的地方也越來越多。有一天,我正準備複用一個底層圖片處理函式時,突然碰到了一個問題。
我看到了一段讓我非常糾結的程式碼:
```python
# 在某個處理影象的模組內部
# <PROJECT_ROOT>/util/image/processor.py
def process_image(...):
try:
image = Image.open(fp)
except Exception:
# 說明(非專案原註釋):該異常將會被 Django 的中介軟體捕獲,往前端返回
# "上傳的圖片格式有誤" 資訊
raise error_codes.INVALID_IMAGE_UPLOADED
... ...
```
`process_image` 函式會嘗試解析一個檔案物件,如果該物件不能被作為圖片正常開啟,就丟擲 `error_codes.INVALID_IMAGE_UPLOADED APIErrorCode 子類)` 異常,從而給呼叫方返回錯誤程式碼 JSON。
讓我給你從頭理理這段程式碼。最初編寫 `process_image` 時,我雖然把它放在了 `util.image` 模組裡,但當時調這個函式的地方就只有 *“處理使用者上傳圖片的 POST 請求”* 而已。為了偷懶,我讓函式直接丟擲 `APIErrorCode` 異常來完成了錯誤處理工作。
再來說當時的問題。那時我需要寫一個在後臺執行的批處理圖片指令碼,而它剛好可以複用 `process_image` 函式所實現的功能。但這時不對勁的事情出現了,如果我想複用該函式,那麼:
- 我必須去捕獲一個名為 `INVALID_IMAGE_UPLOADED` 的異常
- **哪怕我的圖片根本就不是來自於使用者上傳**
- 我必須引入 `APIErrorCode` 異常類作為依賴來捕獲異常
- **哪怕我的指令碼和 Django API 根本沒有任何關係**
**這就是異常類抽象層級不一致導致的結果。**APIErrorCode 異常類的意義,在於表達一種能夠直接被終端使用者(人)識別並消費的“錯誤程式碼”。**它在整個專案裡,屬於最高層的抽象之一。**但是出於方便,我們卻在底層模組裡引入並丟擲了它。這打破了 `image.processor` 模組的抽象一致性,影響了它的可複用性和可維護性。
這類情況屬於“模組丟擲了**高於**所屬抽象層級的異常”。避免這類錯誤需要注意以下幾點:
- 讓模組只丟擲與當前抽象層級一致的異常
- 比如 `image.processer` 模組應該丟擲自己封裝的 `ImageOpenError` 異常
- 在必要的地方進行異常包裝與轉換
- 比如,應該在貼近高層抽象(檢視 View 函式)的地方,將影象處理模組的 `ImageOpenError` 低階異常包裝轉換為 `APIErrorCode` 高階異常
修改後的程式碼:
```python
# <PROJECT_ROOT>/util/image/processor.py
class ImageOpenError(Exception):
pass
def process_image(...):
try:
image = Image.open(fp)
except Exception as e:
raise ImageOpenError(exc=e)
... ...
# <PROJECT_ROOT>/app/views.py
def foo_view_function(request):
try:
process_image(fp)
except ImageOpenError:
raise error_codes.INVALID_IMAGE_UPLOADED
```
除了應該避免丟擲**高於**當前抽象級別的異常外,我們同樣應該避免洩露**低於**當前抽象級別的異常。
如果你用過 `requests` 模組,你可能已經發現它請求頁面出錯時所丟擲的異常,並不是它在底層所使用的 `urllib3` 模組的原始異常,而是透過 `requests.exceptions` 包裝過一次的異常。
```python
>>> try:
... requests.get('https://www.invalid-host-foo.com')
... except Exception as e:
... print(type(e))
...
<class 'requests.exceptions.ConnectionError'>
```
這樣做同樣是為了保證異常類的抽象一致性。因為 urllib3 模組是 requests 模組依賴的底層實現細節,而這個細節有可能在未來版本發生變動。所以必須對它丟擲的異常進行恰當的包裝,避免未來的底層變更對 `requests` 使用者端錯誤處理邏輯產生影響。
### 3. 異常處理不應該喧賓奪主
在前面我們提到異常捕獲要精準、抽象級別要一致。但在現實世界中,如果你嚴格遵循這些流程,那麼很有可能會碰上另外一個問題:**異常處理邏輯太多,以至於擾亂了程式碼核心邏輯**。具體表現就是,程式碼裡充斥著大量的 `try`、`except`、`raise` 語句,讓核心邏輯變得難以辨識。
讓我們看一段例子:
```python
def upload_avatar(request):
"""使用者上傳新頭像"""
try:
avatar_file = request.FILES['avatar']
except KeyError:
raise error_codes.AVATAR_FILE_NOT_PROVIDED
try:
resized_avatar_file = resize_avatar(avatar_file)
except FileTooLargeError as e:
raise error_codes.AVATAR_FILE_TOO_LARGE
except ResizeAvatarError as e:
raise error_codes.AVATAR_FILE_INVALID
try:
request.user.avatar = resized_avatar_file
request.user.save()
except Exception:
raise error_codes.INTERNAL_SERVER_ERROR
return HttpResponse({})
```
這是一個處理使用者上傳頭像的檢視函式。這個函式內做了三件事情,並且針對每件事都做了異常捕獲。如果做某件事時發生了異常,就返回對使用者友好的錯誤到前端。
這樣的處理流程縱然合理,但是顯然程式碼裡的異常處理邏輯有點“喧賓奪主”了。一眼看過去全是程式碼縮排,很難提煉出程式碼的核心邏輯。
早在 2.5 版本時Python 語言就已經提供了對付這類場景的工具“上下文管理器context manager”。上下文管理器是一種配合 `with` 語句使用的特殊 Python 物件,透過它,可以讓異常處理工作變得更方便。
那麼,如何利用上下文管理器來改善我們的異常處理流程呢?讓我們直接看程式碼吧。
```python
class raise_api_error:
"""captures specified exception and raise ApiErrorCode instead
:raises: AttributeError if code_name is not valid
"""
def __init__(self, captures, code_name):
self.captures = captures
self.code = getattr(error_codes, code_name)
def __enter__(self):
# 該方法將在進入上下文時呼叫
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# 該方法將在退出上下文時呼叫
# exc_type, exc_val, exc_tb 分別表示該上下文內丟擲的
# 異常型別、異常值、錯誤棧
if exc_type is None:
return False
if exc_type == self.captures:
raise self.code from exc_val
return False
```
在上面的程式碼裡,我們定義了一個名為 `raise_api_error` 的上下文管理器,它在進入上下文時什麼也不做。但是在退出上下文時,會判斷當前上下文中是否丟擲了型別為 `self.captures` 的異常,如果有,就用 `APIErrorCode` 異常類替代它。
使用該上下文管理器後,整個函式可以變得更清晰簡潔:
```python
def upload_avatar(request):
"""使用者上傳新頭像"""
with raise_api_error(KeyError, 'AVATAR_FILE_NOT_PROVIDED'):
avatar_file = request.FILES['avatar']
with raise_api_error(ResizeAvatarError, 'AVATAR_FILE_INVALID'),\
raise_api_error(FileTooLargeError, 'AVATAR_FILE_TOO_LARGE'):
resized_avatar_file = resize_avatar(avatar_file)
with raise_api_error(Exception, 'INTERNAL_SERVER_ERROR'):
request.user.avatar = resized_avatar_file
request.user.save()
return HttpResponse({})
```
> Hint建議閱讀 [PEP 343 -- The "with" Statement | Python.org](https://www.python.org/dev/peps/pep-0343/),瞭解與上下文管理器有關的更多知識。
>
> 模組 [contextlib](https://docs.python.org/3/library/contextlib.html) 也提供了非常多與編寫上下文管理器相關的工具函式與樣例。
## 總結
在這篇文章中,我分享了與異常處理相關的三個建議。最後再總結一下要點:
- 只捕獲可能會丟擲異常的語句,避免含糊的捕獲邏輯
- 保持模組異常類的抽象一致性,必要時對底層異常類進行包裝
- 使用“上下文管理器”可以簡化重複的異常處理邏輯
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[>>>下一篇【7.編寫地道迴圈的兩個建議】](7-two-tips-on-loop-writing.md)
[<<<上一篇【5.讓函式返回結果的技巧】](5-function-returning-tips.md)
## 附錄
- 題圖來源: Photo by Bernard Hermant on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:善用變數改善程式碼質量](https://www.zlovezl.cn/articles/python-using-variables-well/)
- [Python 工匠:編寫條件分支程式碼的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:讓程式返回結果的技巧](https://www.zlovezl.cn/articles/function-returning-tips/)

View File

@ -0,0 +1,324 @@
# Python 工匠:編寫地道迴圈的兩個建議
## 前言
> 這是 “Python 工匠”系列的第 7 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/04/lai-man-nung-1205465-unsplash_w1280.jpg" width="100%" />
</div>
迴圈是一種常用的程式控制結構。我們常說,機器相比人類的最大優點之一,就是機器可以不眠不休的重複做某件事情,但人卻不行。而**“迴圈”**,則是實現讓機器不斷重複工作的關鍵概念。
在迴圈語法方面Python 表現的即傳統又不傳統。它雖然拋棄了常見的 `for (init; condition; incrment)` 三段式結構,但還是選擇了 `for``while` 這兩個經典的關鍵字來表達迴圈。絕大多數情況下,我們的迴圈需求都可以用 `for <item> in <iterable>` 來滿足,`while <condition>` 相比之下用的則更少些。
雖然迴圈的語法很簡單,但是要寫好它確並不容易。在這篇文章裡,我們將探討什麼是“地道”的迴圈程式碼,以及如何編寫它們。
## 什麼是“地道”的迴圈?
“地道”這個詞,通常被用來形容某人做某件事情時,非常符合當地傳統,做的非常好。打個比方,你去參加一個朋友聚會,同桌的有一位廣東人,對方一開口,句句都是標準京腔、完美兒化音。那你可以對她說:“您的北京話說的真**地道**”。
既然“地道”這個詞形容的經常是口音、做菜的口味這類實實在在的東西,那“地道”的迴圈程式碼又是什麼意思呢?讓我拿一個經典的例子來解釋一下。
如果你去問一位剛學習 Python 一個月的人:“*如何在遍歷一個列表的同時獲取當前下標?*”。他可能會交出這樣的程式碼:
```python
index = 0
for name in names:
print(index, name)
index += 1
```
上面的迴圈雖然沒錯,但它確一點都不“地道”。一個擁有三年 Python 開發經驗的人會說,程式碼應該這麼寫:
```python
for i, name in enumerate(names):
print(i, name)
```
[`enumerate()`](https://docs.python.org/3/library/functions.html#enumerate) 是 Python 的一個內建函式,它接收一個“可迭代”物件作為引數,然後返回一個不斷生成 `(當前下標, 當前元素)` 的新可迭代物件。這個場景使用它最適合不過。
所以,在上面的例子裡,我們會認為第二段迴圈程式碼比第一段更“地道”。因為它用更直觀的程式碼,更聰明的完成了工作。
### enumerate() 所代表的程式設計思路
不過,判斷某段迴圈程式碼是否地道,並不僅僅是以知道或不知道某個內建方法作為標準。我們可以從上面的例子挖掘出更深層的東西。
如你所見Python 的 `for` 迴圈只有 `for <item> in <iterable>` 這一種結構,而結構裡的前半部分 - *賦值給 item* - 沒有太多花樣可玩。所以後半部分的 **可迭代物件** 是我們唯一能夠大做文章的東西。而以 `enumerate()` 函式為代表的*“修飾函式”*,剛好提供了一種思路:**透過修飾可迭代物件來最佳化迴圈本身。**
這就引出了我的第一個建議。
## 建議1使用函式修飾被迭代物件來最佳化迴圈
使用修飾函式處理可迭代物件,可以在各種方面影響迴圈程式碼。而要找到合適的例子來演示這個方法,並不用去太遠,內建模組 [itertools](https://docs.python.org/3.6/library/itertools.html) 就是一個絕佳的例子。
簡單來說itertools 是一個包含很多面向可迭代物件的工具函式集。我在之前的系列文章[《容器的門道》](https://www.zlovezl.cn/articles/mastering-container-types/)裡提到過它。
如果要學習 itertools那麼 [Python 官方文件](https://docs.python.org/3.6/library/itertools.html) 是你的首選,裡面有非常詳細的模組相關資料。但在這篇文章裡,側重點將和官方文件稍有不同。我會透過一些常見的程式碼場景,來詳細解釋它是如何改善迴圈程式碼的。
### 1. 使用 product 扁平化多層巢狀迴圈
雖然我們都知道*“扁平的程式碼比巢狀的好”*。但有時針對某類需求,似乎一定得寫多層巢狀迴圈才行。比如下面這段:
```python
def find_twelve(num_list1, num_list2, num_list3):
"""從 3 個數字列表中,尋找是否存在和為 12 的 3 個數
"""
for num1 in num_list1:
for num2 in num_list2:
for num3 in num_list3:
if num1 + num2 + num3 == 12:
return num1, num2, num3
```
對於這種需要巢狀遍歷多個物件的多層迴圈程式碼,我們可以使用 [product()](https://docs.python.org/3.6/library/itertools.html#itertools.product) 函式來最佳化它。`product()` 可以接收多個可迭代物件,然後根據它們的笛卡爾積不斷生成結果。
```python
from itertools import product
def find_twelve_v2(num_list1, num_list2, num_list3):
for num1, num2, num3 in product(num_list1, num_list2, num_list3):
if num1 + num2 + num3 == 12:
return num1, num2, num3
```
相比之前的程式碼,使用 `product()` 的函式只用了一層 for 迴圈就完成了任務,程式碼變得更精煉了。
### 2. 使用 islice 實現迴圈內隔行處理
有一份包含 Reddit 帖子標題的外部資料檔案,裡面的內容格式是這樣的:
```
python-guide: Python best practices guidebook, written for humans.
---
Python 2 Death Clock
---
Run any Python Script with an Alexa Voice Command
---
<... ...>
```
可能是為了美觀,在這份檔案裡的每兩個標題之間,都有一個 `"---"` 分隔符。現在,我們需要獲取檔案裡所有的標題列表,所以在遍歷檔案內容的過程中,必須跳過這些無意義的分隔符。
參考之前對 `enumerate()` 函式的瞭解,我們可以透過在迴圈內加一段基於當前迴圈序號的 `if` 判斷來做到這一點:
```python
def parse_titles(filename):
"""從隔行資料檔案中讀取 reddit 主題名稱
"""
with open(filename, 'r') as fp:
for i, line in enumerate(fp):
# 跳過無意義的 '---' 分隔符
if i % 2 == 0:
yield line.strip()
```
但對於這類在迴圈內進行隔行處理的需求來說,如果使用 itertools 裡的 [islice()](https://docs.python.org/3.6/library/itertools.html#itertools.islice) 函式修飾被迴圈物件,可以讓迴圈體程式碼變得更簡單直接。
`islice(seq, start, end, step)` 函式和陣列切片操作* list[start:stop:step] *有著幾乎一模一樣的引數。如果需要在迴圈內部進行隔行處理的話,只要設定第三個遞進步長引數 step 值為 2 即可*(預設為 1*。
```python
from itertools import islice
def parse_titles_v2(filename):
with open(filename, 'r') as fp:
# 設定 step=2跳過無意義的 '---' 分隔符
for line in islice(fp, 0, None, 2):
yield line.strip()
```
### 3. 使用 takewhile 替代 break 語句
有時,我們需要在每次迴圈開始時,判斷迴圈是否需要提前結束。比如下面這樣:
```python
for user in users:
# 當第一個不合格的使用者出現後,不再進行後面的處理
if not is_qualified(user):
break
# 進行處理 ... ...
```
對於這類需要提前中斷的迴圈,我們可以使用 [takewhile()](https://docs.python.org/3.6/library/itertools.html#itertools.takewhile) 函式來簡化它。`takewhile(predicate, iterable)` 會在迭代 `iterable` 的過程中不斷使用當前物件作為引數呼叫 `predicate` 函式並測試返回結果,如果函式返回值為真,則生成當前物件,迴圈繼續。否則立即中斷當前迴圈。
使用 `takewhile` 的程式碼樣例:
```
from itertools import takewhile
for user in takewhile(is_qualified, users):
# 進行處理 ... ...
```
itertools 裡面還有一些其他有意思的工具函式,他們都可以用來和迴圈搭配使用,比如使用 chain 函式扁平化雙層巢狀迴圈、使用 zip_longest 函式一次同時迴圈多個物件等等。
篇幅有限,我在這裡不再一一介紹。如果有興趣,可以自行去官方文件詳細瞭解。
### 4. 使用生成器編寫自己的修飾函式
除了 itertools 提供的那些函式外,我們還可以非常方便的使用生成器來定義自己的迴圈修飾函式。
讓我們拿一個簡單的函式舉例:
```python
def sum_even_only(numbers):
"""對 numbers 裡面所有的偶數求和"""
result = 0
for num in numbers:
if num % 2 == 0:
result += num
return result
```
在上面的函式裡,迴圈體內為了過濾掉所有奇數,引入了一條額外的 `if` 判斷語句。如果要簡化迴圈體內容,我們可以定義一個生成器函式來專門進行偶數過濾:
```python
def even_only(numbers):
for num in numbers:
if num % 2 == 0:
yield num
def sum_even_only_v2(numbers):
"""對 numbers 裡面所有的偶數求和"""
result = 0
for num in even_only(numbers):
result += num
return result
```
`numbers` 變數使用 `even_only` 函式裝飾後,`sum_even_only_v2` 函式內部便不用繼續關注“偶數過濾”邏輯了,只需要簡單完成求和即可。
> Hint當然上面的這個函式其實並不實用。在現實世界裡這種簡單需求最適合直接用生成器/列表表示式搞定:`sum(num for num in numbers if num % 2 == 0)`
## 建議2按職責拆解迴圈體內複雜程式碼塊
我一直覺得迴圈是一個比較神奇的東西,每當你寫下一個新的迴圈程式碼塊,就好像開闢了一片黑魔法陣,陣內的所有內容都會開始無休止的重複執行。
但我同時發現,這片黑魔法陣除了能帶來好處,**它還會引誘你不斷往陣內塞入越來越多的程式碼,包括過濾掉無效元素、預處理資料、列印日誌等等。甚至一些原本不屬於同一抽象的內容,也會被塞入到同一片黑魔法陣內。**
你可能會覺得這一切理所當然,我們就是迫切需要陣內的魔法效果。如果不把這一大堆邏輯塞滿到迴圈體內,還能把它們放哪去呢?
讓我們來看看下面這個業務場景。在網站中,有一個每 30 天執行一次的週期指令碼,它的任務是是查詢過去 30 天內,在每週末特定時間段登入過的使用者,然後為其傳送獎勵積分。
程式碼如下:
```python
import time
import datetime
def award_active_users_in_last_30days():
"""獲取所有在過去 30 天週末晚上 8 點到 10 點登入過的使用者,為其傳送獎勵積分
"""
days = 30
for days_delta in range(days):
dt = datetime.date.today() - datetime.timedelta(days=days_delta)
# 5: Saturday, 6: Sunday
if dt.weekday() not in (5, 6):
continue
time_start = datetime.datetime(dt.year, dt.month, dt.day, 20, 0)
time_end = datetime.datetime(dt.year, dt.month, dt.day, 23, 0)
# 轉換為 unix 時間戳,之後的 ORM 查詢需要
ts_start = time.mktime(time_start.timetuple())
ts_end = time.mktime(time_end.timetuple())
# 查詢使用者並挨個傳送 1000 獎勵積分
for record in LoginRecord.filter_by_range(ts_start, ts_end):
# 這裡可以新增複雜邏輯
send_awarding_points(record.user_id, 1000)
```
上面這個函式主要由兩層迴圈構成。外層迴圈的職責,主要是獲取過去 30 天內符合要求的時間,並將其轉換為 UNIX 時間戳。之後由內層迴圈使用這兩個時間戳進行積分發送。
如之前所說,外層迴圈所開闢的黑魔法陣內被塞的滿滿當當。但透過觀察後,我們可以發現 **整個迴圈體其實是由兩個完全無關的任務構成的:“挑選日期與準備時間戳” 以及 “傳送獎勵積分”**
### 複雜迴圈體如何應對新需求
這樣的程式碼有什麼壞處呢?讓我來告訴你。
某日,產品找過來說,有一些使用者週末半夜不睡覺,還在刷我們的網站,我們得給他們發通知讓他們以後早點睡覺。於是新需求出現了:**“給過去 30 天內在週末凌晨 3 點到 5 點登入過的使用者傳送一條通知”**。
新問題也隨之而來。敏銳如你,肯定一眼可以發現,這個新需求在使用者篩選部分的要求,和之前的需求非常非常相似。但是,如果你再開啟之前那團迴圈體看看,你會發現程式碼根本沒法複用,因為在迴圈內部,不同的邏輯完全被 **耦合** 在一起了。☹️
在計算機的世界裡,我們經常用**“耦合”**這個詞來表示事物之間的關聯關係。上面的例子中,*“挑選時間”*和*“傳送積分”*這兩件事情身處同一個迴圈體內,建立了非常強的耦合關係。
為了更好的進行程式碼複用,我們需要把函式裡的*“挑選時間”*部分從迴圈體中解耦出來。而我們的老朋友,**“生成器函式”**是進行這項工作的不二之選。
### 使用生成器函式解耦迴圈體
要把 *“挑選時間”* 部分從迴圈內解耦出來,我們需要定義新的生成器函式 `gen_weekend_ts_ranges()`,專門用來生成需要的 UNIX 時間戳:
```python
def gen_weekend_ts_ranges(days_ago, hour_start, hour_end):
"""生成過去一段時間內週六日特定時間段範圍,並以 UNIX 時間戳返回
"""
for days_delta in range(days_ago):
dt = datetime.date.today() - datetime.timedelta(days=days_delta)
# 5: Saturday, 6: Sunday
if dt.weekday() not in (5, 6):
continue
time_start = datetime.datetime(dt.year, dt.month, dt.day, hour_start, 0)
time_end = datetime.datetime(dt.year, dt.month, dt.day, hour_end, 0)
# 轉換為 unix 時間戳,之後的 ORM 查詢需要
ts_start = time.mktime(time_start.timetuple())
ts_end = time.mktime(time_end.timetuple())
yield ts_start, ts_end
```
有了這個生成器函式後,舊需求“傳送獎勵積分”和新需求“傳送通知”,就都可以在迴圈體內複用它來完成任務了:
```python
def award_active_users_in_last_30days_v2():
"""傳送獎勵積分"""
for ts_start, ts_end in gen_weekend_ts_ranges(30, hour_start=20, hour_end=23):
for record in LoginRecord.filter_by_range(ts_start, ts_end):
send_awarding_points(record.user_id, 1000)
def notify_nonsleep_users_in_last_30days():
"""傳送通知"""
for ts_start, ts_end in gen_weekend_ts_range(30, hour_start=3, hour_end=6):
for record in LoginRecord.filter_by_range(ts_start, ts_end):
notify_user(record.user_id, 'You should sleep more')
```
## 總結
在這篇文章裡,我們首先簡單解釋了“地道”迴圈程式碼的定義。然後提出了第一個建議:使用修飾函式來改善迴圈。之後我虛擬了一個業務場景,描述了按職責拆解迴圈內程式碼的重要性。
一些要點總結:
- 使用函式修飾被迴圈物件本身,可以改善迴圈體內的程式碼
- itertools 裡面有很多工具函式都可以用來改善迴圈
- 使用生成器函式可以輕鬆定義自己的修飾函式
- 迴圈內部,是一個極易發生“程式碼膨脹”的場地
- 請使用生成器函式將迴圈內不同職責的程式碼塊解耦出來,獲得更好的靈活性
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[>>>下一篇【8.使用裝飾器的技巧】](8-tips-on-decorators.md)
[<<<上一篇【6.異常處理的三個好習慣】](6-three-rituals-of-exceptions-handling.md)
## 附錄
- 題圖來源: Photo by Lai man nung on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:容器的門道](https://www.zlovezl.cn/articles/mastering-container-types/)
- [Python 工匠:編寫條件分支程式碼的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:異常處理的三個好習慣](https://www.zlovezl.cn/articles/three-rituals-of-exceptions-handling/)

View File

@ -0,0 +1,361 @@
# Python 工匠:使用裝飾器的技巧
## 前言
> 這是 “Python 工匠”系列的第 8 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/05/clem-onojeghuo-142120-unsplash_w1280.jpg" width="100%" />
</div>
裝飾器*Decorator* 是 Python 裡的一種特殊工具,它為我們提供了一種在函式外部修改函式的靈活能力。它有點像一頂畫著獨一無二 `@` 符號的神奇帽子,只要將它戴在函式頭頂上,就能悄無聲息的改變函式本身的行為。
你可能已經和裝飾器打過不少交道了。在做面向物件程式設計時,我們就經常會用到 `@staticmethod``@classmethod` 兩個內建裝飾器。此外,如果你接觸過 [click](https://click.palletsprojects.com/en/7.x/) 模組就更不會對裝飾器感到陌生。click 最為人所稱道的引數定義介面 `@click.option(...)` 就是利用裝飾器實現的。
除了用裝飾器,我們也經常需要自己寫一些裝飾器。在這篇文章裡,我將從 `最佳實踐``常見錯誤` 兩個方面,來與你分享有關裝飾器的一些小知識。
## 最佳實踐
### 1. 嘗試用類來實現裝飾器
絕大多數裝飾器都是基於函式和 [閉包](https://en.wikipedia.org/wiki/Closure_(computer_programming)) 實現的但這並非製造裝飾器的唯一方式。事實上Python 對某個物件是否能透過裝飾器(`@decorator`)形式使用只有一個要求:**decorator 必須是一個“可被呼叫callable的物件**。
```python
# 使用 callable 可以檢測某個物件是否“可被呼叫”
>>> def foo(): pass
...
>>> type(foo)
<class 'function'>
>>> callable(foo)
True
```
函式自然是“可被呼叫”的物件。但除了函式外我們也可以讓任何一個類class變得“可被呼叫”callable。辦法很簡單只要自定義類的 `__call__` 魔法方法即可。
```
class Foo:
def __call__(self):
print("Hello, __call___")
foo = Foo()
# OUTPUT: True
print(callable(foo))
# 呼叫 foo 例項
# OUTPUT: Hello, __call__
foo()
```
基於這個特性,我們可以很方便的使用類來實現裝飾器。
下面這段程式碼,會定義一個名為 `@delay(duration)` 的裝飾器,使用它裝飾過的函式在每次執行前,都會等待額外的 `duration` 秒。同時,我們也希望為使用者提供無需等待馬上執行的 `eager_call` 介面。
```python
import time
import functools
class DelayFunc:
def __init__(self, duration, func):
self.duration = duration
self.func = func
def __call__(self, *args, **kwargs):
print(f'Wait for {self.duration} seconds...')
time.sleep(self.duration)
return self.func(*args, **kwargs)
def eager_call(self, *args, **kwargs):
print('Call without delay')
return self.func(*args, **kwargs)
def delay(duration):
"""裝飾器:推遲某個函式的執行。同時提供 .eager_call 方法立即執行
"""
# 此處為了避免定義額外函式,直接使用 functools.partial 幫助構造
# DelayFunc 例項
return functools.partial(DelayFunc, duration)
```
如何使用裝飾器的樣例程式碼:
```
@delay(duration=2)
def add(a, b):
return a + b
# 這次呼叫將會延遲 2 秒
add(1, 2)
# 這次呼叫將會立即執行
add.eager_call(1, 2)
```
`@delay(duration)` 就是一個基於類來實現的裝飾器。當然,如果你非常熟悉 Python 裡的函式和閉包,上面的 `delay` 裝飾器其實也完全可以只用函式來實現。所以,為什麼我們要用類來做這件事呢?
與純函式相比,我覺得使用類實現的裝飾器在**特定場景**下有幾個優勢:
- 實現有狀態的裝飾器時,操作類屬性比操作閉包內變數更符合直覺、不易出錯
- 實現為函式擴充介面的裝飾器時,使用類包裝函式,比直接為函式物件追加屬性更易於維護
- 更容易實現一個同時相容裝飾器與上下文管理器協議的物件(參考 [unitest.mock.patch](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch)
### 2. 使用 wrapt 模組編寫更扁平的裝飾器
在寫裝飾器的過程中,你有沒有碰到過什麼不爽的事情?不管你有沒有,反正我有。我經常在寫程式碼的時候,被下面兩件事情搞得特別難受:
1. 實現帶引數的裝飾器時,層層巢狀的函式程式碼特別難寫、難讀
2. 因為函式和類方法的不同,為前者寫的裝飾器經常沒法直接套用在後者上
比如,在下面的例子裡,我實現了一個生成隨機數並注入為函式引數的裝飾器。
```python
import random
def provide_number(min_num, max_num):
"""裝飾器:隨機生成一個在 [min_num, max_num] 範圍的整數,追加為函式的第一個位置引數
"""
def wrapper(func):
def decorated(*args, **kwargs):
num = random.randint(min_num, max_num)
# 將 num 作為第一個引數追加後呼叫函式
return func(num, *args, **kwargs)
return decorated
return wrapper
@provide_number(1, 100)
def print_random_number(num):
print(num)
# 輸出 1-100 的隨機整數
# OUTPUT: 72
print_random_number()
```
`@provide_number` 裝飾器功能看上去很不錯,但它有著我在前面提到的兩個問題:**巢狀層級深、無法在類方法上使用。**如果直接用它去裝飾類方法,會出現下面的情況:
```
class Foo:
@provide_number(1, 100)
def print_random_number(self, num):
print(num)
# OUTPUT: <__main__.Foo object at 0x104047278>
Foo().print_random_number()
```
`Foo` 類例項中的 `print_random_number` 方法將會輸出類例項 `self` ,而不是我們期望的隨機數 `num`
之所以會出現這個結果,是因為類方法*method*和函式*function*二者在工作機制上有著細微不同。如果要修復這個問題,`provider_number` 裝飾器在修改類方法的位置引數時,必須聰明的跳過藏在 `*args` 裡面的類例項 `self` 變數,才能正確的將 `num` 作為第一個引數注入。
這時,就應該是 [wrapt](https://pypi.org/project/wrapt/) 模組閃亮登場的時候了。`wrapt` 模組是一個專門幫助你編寫裝飾器的工具庫。利用它,我們可以非常方便的改造 `provide_number` 裝飾器,完美解決*“巢狀層級深”*和*“無法通用”*兩個問題,
```python
import wrapt
def provide_number(min_num, max_num):
@wrapt.decorator
def wrapper(wrapped, instance, args, kwargs):
# 引數含義:
#
# - wrapped被裝飾的函式或類方法
# - instance
# - 如果被裝飾者為普通類方法,該值為類例項
# - 如果被裝飾者為 classmethod 類方法,該值為類
# - 如果被裝飾者為類/函式/靜態方法,該值為 None
#
# - args呼叫時的位置引數注意沒有 * 符號)
# - kwargs呼叫時的關鍵字引數注意沒有 ** 符號)
#
num = random.randint(min_num, max_num)
# 無需關注 wrapped 是類方法或普通函式,直接在頭部追加引數
args = (num,) + args
return wrapped(*args, **kwargs)
return wrapper
<... 應用裝飾器部分程式碼省略 ...>
# OUTPUT: 48
Foo().print_random_number()
```
使用 `wrapt` 模組編寫的裝飾器,相比原來擁有下面這些優勢:
- 巢狀層級少:使用 `@wrapt.decorator` 可以將兩層巢狀減少為一層
- 更簡單:處理位置與關鍵字引數時,可以忽略類例項等特殊情況
- 更靈活:針對 `instance` 值進行條件判斷後,更容易讓裝飾器變得通用
## 常見錯誤
### 1. “裝飾器”並不是“裝飾器模式”
[“設計模式”](https://en.wikipedia.org/wiki/Software_design_pattern)是一個在計算機世界裡鼎鼎大名的詞。假如你是一名 Java 程式設計師,而你一點設計模式都不懂,那麼我打賭你找工作的面試過程一定會度過的相當艱難。
但寫 Python 時,我們極少談起“設計模式”。雖然 Python 也是一門支援面向物件的程式語言,但它的 [鴨子型別](https://en.wikipedia.org/wiki/Duck_typing) 設計以及出色的動態特性決定了,大部分設計模式對我們來說並不是必需品。所以,很多 Python 程式設計師在工作很長一段時間後,可能並沒有真正應用過幾種設計模式。
不過 [*“裝飾器模式Decorator Pattern”*](https://en.wikipedia.org/wiki/Decorator_pattern) 是個例外。因為 Python 的“裝飾器”和“裝飾器模式”有著一模一樣的名字,我不止一次聽到有人把它們倆當成一回事,認為使用“裝飾器”就是在實踐“裝飾器模式”。但事實上,**它們是兩個完全不同的東西。**
“裝飾器模式”是一個完全基於“面向物件”衍生出的程式設計手法。它擁有幾個關鍵組成:**一個統一的介面定義**、**若干個遵循該介面的類**、**類與類之間一層一層的包裝**。最終由它們共同形成一種*“裝飾”*的效果。
而 Python 裡的“裝飾器”和“面向物件”沒有任何直接聯絡,**它完全可以只是發生在函式和函式間的把戲。**事實上,“裝飾器”並沒有提供某種無法替代的功能,它僅僅就是一顆[“語法糖”](https://en.wikipedia.org/wiki/Syntactic_sugar)而已。下面這段使用了裝飾器的程式碼:
```python
@log_time
@cache_result
def foo(): pass
```
基本完全等同於下面這樣:
```
def foo(): pass
foo = log_time(cache_result(foo))
```
**裝飾器最大的功勞,在於讓我們在某些特定場景時,可以寫出更符合直覺、易於閱讀的程式碼**。它只是一顆“糖”,並不是某個面向物件領域的複雜程式設計模式。
> Hint: 在 Python 官網上有一個 [實現了裝飾器模式的例子](https://wiki.python.org/moin/DecoratorPattern),你可以讀讀這個例子來更好的瞭解它。
### 2. 記得用 functools.wraps() 裝飾內層函式
下面是一個簡單的裝飾器,專門用來列印函式呼叫耗時:
```python
import time
def timer(wrapped):
"""裝飾器:記錄並列印函式耗時"""
def decorated(*args, **kwargs):
st = time.time()
ret = wrapped(*args, **kwargs)
print('execution take: {} seconds'.format(time.time() - st))
return ret
return decorated
@timer
def random_sleep():
"""隨機睡眠一小會"""
time.sleep(random.random())
```
`timer` 裝飾器雖然沒有錯誤,但是使用它裝飾函式後,函式的原始簽名就會被破壞。也就是說你再也沒辦法正確拿到 `random_sleep` 函式的名稱、文件內容了,所有簽名都會變成內層函式 `decorated` 的值:
```python
print(random_sleep.__name__)
# 輸出 'decorated'
print(random_sleep.__doc__)
# 輸出 None
```
這雖然只是個小問題,但在某些時候也可能會導致難以察覺的 bug。幸運的是標準庫 `functools` 為它提供瞭解決方案,你只需要在定義裝飾器時,用另外一個裝飾器再裝飾一下內層 `decorated` 函式就行。
聽上去有點繞,但其實就是新增一行程式碼而已:
```python
def timer(wrapped):
# 將 wrapper 函式的真實簽名賦值到 decorated 上
@functools.wraps(wrapped)
def decorated(*args, **kwargs):
# <...> 已省略
return decorated
```
這樣處理後,`timer` 裝飾器就不會影響它所裝飾的函數了。
```python
print(random_sleep.__name__)
# 輸出 'random_sleep'
print(random_sleep.__doc__)
# 輸出 '隨機睡眠一小會'
```
### 3. 修改外層變數時記得使用 nonlocal
裝飾器是對函式物件的一個高階應用。在編寫裝飾器的過程中,你會經常碰到內層函式需要修改外層函式變數的情況。就像下面這個裝飾器一樣:
```python
import functools
def counter(func):
"""裝飾器:記錄並列印呼叫次數"""
count = 0
@functools.wraps(func)
def decorated(*args, **kwargs):
# 次數累加
count += 1
print(f"Count: {count}")
return func(*args, **kwargs)
return decorated
@counter
def foo():
pass
foo()
```
為了統計函式呼叫次數,我們需要在 `decorated` 函式內部修改外層函式定義的 `count` 變數的值。但是,上面這段程式碼是有問題的,在執行它時直譯器會報錯:
```raw
Traceback (most recent call last):
File "counter.py", line 22, in <module>
foo()
File "counter.py", line 11, in decorated
count += 1
UnboundLocalError: local variable 'count' referenced before assignment
```
這個錯誤是由 `counter``decorated` 函式互相巢狀的作用域引起的。
當直譯器執行到 `count += 1` 時,並不知道 `count` 是一個在外層作用域定義的變數,它把 `count` 當做一個區域性變數,並在當前作用域內查詢。最終卻沒有找到有關 `count` 變數的任何定義,然後丟擲錯誤。
為了解決這個問題,我們需要透過 `nonlocal` 關鍵字告訴直譯器:**“count 變數並不屬於當前的 local 作用域,去外面找找吧”**,之前的錯誤就可以得到解決。
```python
def decorated(*args, **kwargs):
nonlocal count
count += 1
# <... 已省略 ...>
```
> Hint如果要了解更多有關 nonlocal 關鍵字的歷史,可以查閱 [PEP-3104](https://www.python.org/dev/peps/pep-3104/)
## 總結
在這篇文章裡,我與你分享了有關裝飾器的一些技巧與小知識。
一些要點總結:
- 一切 callable 的物件都可以被用來實現裝飾器
- 混合使用函式與類,可以更好的實現裝飾器
- wrapt 模組很有用,用它可以幫助我們用更簡單的程式碼寫出複雜裝飾器
- “裝飾器”只是語法糖,它不是“裝飾器模式”
- 裝飾器會改變函式的原始簽名,你需要 `functools.wraps`
- 在內層函式修改外層函式的變數時,需要使用 `nonlocal` 關鍵字
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[>>>下一篇【9.一個關於模組的小故事】](9-a-story-on-cyclic-imports.md)
[<<<上一篇【7.編寫地道迴圈的兩個建議】](7-two-tips-on-loop-writing.md)
## 附錄
- 題圖來源: Photo by Clem Onojeghuo on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:編寫條件分支程式碼的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:異常處理的三個好習慣](https://www.zlovezl.cn/articles/three-rituals-of-exceptions-handling/)
- [Python 工匠:編寫地道迴圈的兩個建議](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)

View File

@ -0,0 +1,198 @@
# Python 工匠:一個關於模組的小故事
## 前言
> 這是 “Python 工匠”系列的第 9 篇文章。[[檢視系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/05/ricardo-gomez-angel-669574-unsplash_w1280.jpg" width="100%" />
</div>
模組Module是我們用來組織 Python 程式碼的基本單位。很多功能強大的複雜站點,都由成百上千個獨立模組共同組成。
雖然模組有著不可替代的用處,但它有時也會給我們帶來麻煩。比如,當你接手一個新專案後,剛展開專案目錄。第一眼就看到了攀枝錯節、難以理解的模組結構,那你肯定會想: *“這專案也太難搞了。”* 😂
在這篇文章裡,我準備了一個和模組有關的小故事與你分享。
## 一個關於模組的小故事
小 R 是一個剛從學校畢業的計算機專業學生。半個月前,他面試進了一家網際網路公司做 Python 開發,負責一個與使用者活動積分有關的小專案。專案的主要功能是查詢站點活躍使用者,併為他們傳送有關活動積分的通知: *“親愛的使用者,您好,您當前的活動積分為 x”*
專案主要由 `notify_users.py` 指令碼和 `fancy_site` 包組成,結構與各檔案內容如下:
```text
├── fancy_site
│   ├── __init__.py
│   ├── marketing.py # 與市場活動有關的內容
│   └── users.py # 與使用者有關的內容
└── notify_users.py # 指令碼:傳送積分通知
```
檔案 `notify_users.py`
```python
from fancy_site.users import list_active_users
from fancy_site.marketing import query_user_points
def main():
"""獲取所有的活躍使用者,將積分情況傳送給他們"""
users = get_active_users()
points = list_user_points(users)
for user in users:
user.add_notification(... ...)
# <... 已省略 ...>
```
檔案 `fancy_site/users.py`
```python
from typing import List
class User:
# <... 已省略 ...>
def add_notification(self, message: str):
"""為使用者傳送新通知"""
pass
def list_active_users() -> List[User]:
"""查詢所有活躍使用者"""
pass
```
檔案:`fancy_site/marketing.py`
```python
from typing import List
from .users import User
def query_user_points(users: List[User]) -> List[int]:
"""批次查詢使用者活動積分"""
def send_sms(phone_number: str, message: str):
"""為某手機號傳送簡訊"""
```
只要在專案目錄下執行 `python notify_user.py`,就能實現給所有活躍使用者傳送通知。
### 需求變更
但有一天,產品經理找過來說,光給使用者發站內信通知還不夠,容易被使用者忽略。除了站內信以外,我們還需要同時給使用者推送一條簡訊通知。
琢磨了五秒鐘後,小 R 跟產品經理說:*“這個需求可以做!”*。畢竟給手機號傳送簡訊的 `send_sms()` 函式早就已經有人寫好了。他只要先給 `add_notification` 方法新增一個可選引數 `enable_sms=False`,當傳值為 `True` 時呼叫 `fancy_site.marketing` 模組裡的 `send_sms` 函式就行。
一切聽上去根本沒有什麼難度可言,十分鐘後,小 R 就把 `user.py` 改成了下面這樣:
```python
# 匯入 send_sms 模組的傳送簡訊函式
from .marketing import send_sms
class User:
# <...> 相關初始化程式碼已省略
def add_notification(self, message: str, enable_sms=False):
"""為使用者新增新通知"""
if enable_sms:
send_sms(user.mobile_number, ... ...)
```
但是,當他修改完程式碼,再次執行 `notify_users.py` 指令碼時,程式卻報錯了:
```raw
Traceback (most recent call last):
File "notify_users.py", line 2, in <module>
from fancy_site.users import list_active_users
File .../fancy_site/users.py", line 3, in <module>
from .marketing import send_sms
File ".../fancy_site/marketing.py", line 3, in <module>
from .users import User
ImportError: cannot import name 'User' from 'fancy_site.users' (.../fancy_site/users.py)
```
錯誤資訊說,無法從 `fancy_site.users` 模組匯入 `User` 物件。
### 解決環形依賴問題
小 R 仔細分析了一下錯誤,發現錯誤是因為 `users``marketing` 模組之間產生的環形依賴關係導致的。
當程式在 `notify_users.py` 檔案匯入 `fancy_site.users` 模組時,`users` 模組發現自己需要從 `marketing` 模組那裡匯入 `send_sms` 函式。而直譯器在載入 `marketing` 模組的過程中,又反過來發現自己需要依賴 `users` 模組裡面的 `User` 物件。
如此一來,整個模組依賴關係成為了環狀,程式自然也就沒法執行下去了。
![modules_before](https://www.zlovezl.cn/static/uploaded/2019/05/modules_before.png)
不過,沒有什麼問題能夠難倒一個可以正常訪問 Google 的程式設計師。小 R 隨便上網一搜,發現這樣的問題很好解決。因為 Python 的 import 語句非常靈活,他只需要 **把在 users 模組內匯入 send_sms 函式的語句挪到 `add_notification` 方法內,延緩 import 語句的執行就行啦。**
```python
class User:
# <...> 相關初始化程式碼已省略
def add_notification(self, message: str, send_sms=False):
"""為使用者新增新通知"""
# 延緩 import 語句執行
from .marketing import send_sms
```
改動一行程式碼後,大功告成。小 R 簡單測試後,發現一切正常,然後把程式碼推送了上去。不過小 R 還沒來得及為自己點個贊,意料之外的事情發生了。
這段明明幾乎完美的程式碼改動在 **Code Review** 的時候被審計人小 C 拒絕了。
### 小 C 的疑問
小 R 的同事小 C 是一名有著多年經驗的 Python 程式設計師,他對小 R 說:“使用延遲 import雖然可以馬上解決包匯入問題。但這個小問題背後隱藏了更多的資訊。比如**你有沒有想過 send_sms 函式,是不是已經不適合放在 marketing 模組裡了?”**
被小 C 這麼一問,聰明的小 R 馬上意識到了問題所在。要在 `users` 模組內傳送簡訊,重點不在於用延遲匯入解決環形依賴。而是要以此為契機,**發現當前模組間依賴關係的不合理,拆分/合併模組,建立新的分層與抽象,最終消除環形依賴。**
認識清楚問題後,他很快提交了新的程式碼修改。在新程式碼中,他建立了一個專門負責通知與訊息類的工具模組 `msg_utils`,然後把 `send_sms` 函式挪到了裡面。之後 `users` 模組內就可以毫無困難的從 `msg_utils` 模組中匯入 `send_sms` 函數了。
```python
from .msg_utils import send_sms
```
新的模組依賴關係如下圖所示:
![modules_afte](https://www.zlovezl.cn/static/uploaded/2019/05/modules_after.png)
在新的模組結構中,整個專案被整齊的分為三層,模組間的依賴關係也變得只有**單向流動**。之前在函式內部 `import` 的“延遲匯入”技巧,自然也就沒有用武之地了。
小 R 修改後的程式碼獲得了大家的認可,很快就被合併到了主分支。故事暫告一段落,那麼這個故事告訴了我們什麼道理呢?
## 總結
模組間的迴圈依賴是一個在大型 Python 專案中很常見的問題,越複雜的專案越容易碰到這個問題。當我們在參與這些專案時,**如果對模組結構、分層、抽象缺少應有的重視。那麼專案很容易就會慢慢變得複雜無比、難以維護。**
所以,合理的模組結構與分層非常重要。它可以大大降低開發人員的心智負擔和專案維護成本。這也是我為什麼要和你分享這個簡單故事的原因。“在函式內延遲 import” 的做法當然沒有錯,但我們更應該關注的是:**整個專案內的模組依賴關係與分層是否合理。**
最後,讓我們再嘗試從 小 R 的故事裡強行總結出幾個道理吧:
- 合理的模組結構與分層可以降低專案的開發維護成本
- 合理的模組結構不是一成不變的,應該隨著專案發展調整
- 遇到問題時,不要選**“簡單但有缺陷”**的那個方案,要選**“麻煩但正確”**的那個
- 整個專案內的模組間依賴關係流向,應該是單向的,不能有環形依賴存在
看完文章的你,有沒有什麼想吐槽的?請留言或者在 [專案 Github Issues](https://github.com/piglei/one-python-craftsman) 告訴我吧。
[>>>下一篇【10.做一個精通規則的玩家】](10-a-good-player-know-the-rules.md)
[<<<上一篇【8.使用裝飾器的技巧】](8-tips-on-decorators.md)
## 附錄
- 題圖來源: Photo by Ricardo Gomez Angel on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:編寫條件分支程式碼的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:異常處理的三個好習慣](https://www.zlovezl.cn/articles/three-rituals-of-exceptions-handling/)
- [Python 工匠:編寫地道迴圈的兩個建議](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)