天天看點

Python 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄

序言

這是 “Python 工匠”系列的第 5 篇文章。
Python 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄

毫無疑問,函數是 Python 語言裡最重要的概念之一。在程式設計時,我們将真實世界裡的大問題分解為小問題,然後通過一個個函數交出答案。函數即是重複代碼的克星,也是對抗代碼複雜度的最佳武器。

如同大部分故事都會有結局,絕大多數函數也都是以傳回結果作為結束。函數傳回結果的手法,決定了調用它時的體驗。是以,了解如何優雅的讓函數傳回結果,是編寫好函數的必備知識。

Python 的函數傳回方式

Python

函數通過調用

return

語句來傳回結果。使用

returnvalue

可以傳回單個值,用

returnvalue1

,

value2

則能讓函數同時傳回多個值。

如果一個函數體内沒有任何

return

語句,那麼這個函數的傳回值預設為

None

。除了通過

return

語句傳回内容,在函數内還可以使用抛出異常(raise Exception)的方式來“傳回結果”。

接下來,我将列舉一些與函數傳回相關的常用程式設計建議。

程式設計建議

1. 單個函數不要傳回多種類型

Python 語言非常靈活,我們能用它輕松完成一些在其他語言裡很難做到的事情。比如:讓一個函數同時傳回不同類型的結果。進而實作一種看起來非常實用的“多功能函數”。

就像下面這樣:

Python 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄

當我們需要擷取單個使用者時,就傳遞

user_id

參數,否則就不傳參數拿到所有活躍使用者清單。一切都由一個函數

get_users

來搞定。這樣的設計似乎很合理。

然而在函數的世界裡,以編寫具備“多功能”的瑞士軍刀型函數為榮不是一件好事。這是因為好的函數一定是 “單一職責(Single responsibility)” 的。單一職責意味着一個函數隻做好一件事,目的明确。這樣的函數也更不容易在未來因為需求變更而被修改。

而傳回多種類型的函數一定是違反“單一職責”原則的,好的函數應該總是提供穩定的傳回值,把調用方的處理成本降到最低。像上面的例子,我們應該編寫兩個獨立的函數

get_user_by_id(user_id)

get_active_users()

來替代。

2. 使用 partial 構造新函數

假設這麼一個場景,在你的代碼裡有一個參數很多的函數 A,适用性很強。而另一個函數 B 則是完全通過調用 A 來完成工作,是一種類似快捷方式的存在。

比方在這個例子裡,

double

函數就是完全通過

multiply

來完成計算的:

Python 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄

對于上面這種場景,我們可以使用

functools

子產品裡的

partial()

函數來簡化它。

partial(func,*args,**kwargs)

基于傳入的函數與可變(位置/關鍵字)參數來構造一個新函數。所有對新函數的調用,都會在合并了目前調用參數與構造參數後,代理給原始函數處理。

利用

partial

函數,上面的

double

函數定義可以被修改為單行表達式,更簡潔也更直接。

Python 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄
建議閱讀:partial 函數官方文檔

3. 抛出異常,而不是傳回結果與錯誤

我在前面提過,Python 裡的函數可以傳回多個值。基于這個能力,我們可以編寫一類特殊的函數:同時傳回結果與錯誤資訊的函數。

Python 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄

在示例中,

create_item

函數的作用是建立新的 Item 對象。同時,為了在出錯時給調用方提供錯誤詳情,它利用了多傳回值特性,把錯誤資訊作為第二個結果傳回。

乍看上去,這樣的做法很自然。尤其是對那些有 Go 語言程式設計經驗的人來說更是如此。但是在 Python 世界裡,這并非解決此類問題的最佳辦法。因為這種做法會增加調用方進行錯誤處理的成本,尤其是當很多函數都遵循這個規範而且存在多層調用時。

Python 具備完善的異常(Exception)機制,并且在某種程度上鼓勵我們使用異常(官方文檔關于 EAFP 的說明)。是以,使用異常來進行錯誤流程處理才是更道地的做法。

引入自定義異常後,上面的代碼可以被改寫成這樣:

Python 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄

使用“抛出異常”替代“傳回 (結果, 錯誤資訊)”後,整個錯誤流程處理乍看上去變化不大,但實際上有着非常多不同,一些細節:

  • 新版本函數擁有更穩定的傳回值類型,它永遠隻會傳回

    Item

    類型或是抛出異常
  • 雖然我在這裡鼓勵使用異常,但“異常”總是會無法避免的讓人 感到驚訝,是以,最好在函數文檔裡說明可能抛出的異常類型
  • 異常不同于傳回值,它在被捕獲前會不斷往調用棧上層彙報。是以

    create_item

    的一級調用方完全可以省略異常處理,交由上層處理。這個特點給了我們更多的靈活性,但同時也帶來了更大的風險。

Hint:如何在程式設計語言裡處理錯誤,是一個至今仍然存在争議的主題。比如像上面不推薦的多傳回值方式,正是缺乏異常的 Go 語言中最核心的錯誤處理機制。另外,即使是異常機制本身,不同程式設計語言之間也存在着差别。

異常,或是不異常,都是由語言設計者進行多方取舍後的結果,更多時候不存在絕對性的優劣之分。但是,單就 Python 語言而言,使用異常來表達錯誤無疑是更符合 Python 哲學,更應該受到推崇的。

4. 謹慎使用 None 傳回值

None

值通常被用來表示“某個應該存在但是缺失的東西”,它在 Python 裡是獨一無二的存在。很多程式設計語言裡都有與 None 類似的設計,比如 JavaScript 裡的

null

、Go 裡的

nil

等。因為 None 所擁有的獨特 虛無 氣質,它經常被作為函數傳回值使用。

當我們使用 None 作為函數傳回值時,通常是下面 3 種情況。

4.1. 作為操作類函數的預設傳回值

當某個操作類函數不需要任何傳回值時,通常就會傳回

None

。同時,

None

也是不帶任何

return

語句函數的預設傳回值。

對于這種函數,使用

None

是沒有任何問題的,标準庫裡的

list.append()

os.chdir()

均屬此類。

4.2. 作為某些“意料之中”的可能沒有的值

有一些函數,它們的目的通常是去嘗試性的做某件事情。視情況不同,最終可能有結果,也可能沒有結果。而對調用方來說,“沒有結果”完全是意料之中的事情。對這類函數來說,使用

None

作為“沒結果”時的傳回值也是合理的。

在 Python 标準庫裡,正規表達式子產品

re

下的

re.search

re.match

函數均屬于此類,這兩個函數在可以找到比對結果時傳回

re.Match

對象,找不到時則傳回

None

4.3. 作為調用失敗時代表“錯誤結果”的值

有時,

None

也會經常被我們用來作為函數調用失敗時的預設傳回值,比如下面這個函數:

Python 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄

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 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄

與 None 傳回值相比,抛出異常除了擁有我們在上個場景提到的那些特點外,還有一個額外的優勢:可以在異常資訊裡提供出現意料之外結果的原因,這是隻傳回一個 None 值做不到的。

5. 合理使用“空對象模式”

我在前面提到函數可以用

None

值或異常來傳回錯誤結果,但這兩種方式都有一個共同的缺點。那就是所有需要使用函數傳回值的地方,都必須加上一個

if

try/except

防禦語句,來判斷結果是否正常。

讓我們看一個可運作的完整示例:

Python 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄
補充圖中顯示不到的為:{BALANCE}" ')

在這個例子裡,每當我們調用

Account.from_string

時,都必須使用

try/except

來捕獲可能發生的異常。如果項目裡需要調用很多次該函數,這部分工作就變得非常繁瑣了。針對這種情況,可以使用“空對象模式(Null object pattern)”來改善這個控制流。

Martin Fowler 在他的經典著作《重構》 中用一個章節詳細說明過這個模式。簡單來說,就是使用一個符合正常結果接口的“空類型”來替代空值傳回/抛出異常,以此來降低調用方處理結果的成本。

引入“空對象模式”後,上面的示例可以被修改成這樣:

Python 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄

在新版代碼裡,我定義了

NullAccount

這個新類型,用來作為

from_string

失敗時的錯誤結果傳回。這樣修改後的最大變化展現在

caculate_total_balance

部分:

Python 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄

調整之後,調用方不必再顯式使用

try

語句來處理錯誤,而是可以假設

Account.from_string

函數總是會傳回一個合法的

Account

對象,進而大大簡化整個計算邏輯。

Hint:在 Python 世界裡,“空對象模式”并不少見,比如大名鼎鼎的 Django 架構裡的 AnonymousUser 就是一個典型的 null object。

6. 使用生成器函數代替傳回清單

在函數裡傳回清單特别常見,通常,我們會先初始化一個清單

results=[]

,然後在循環體内使用

results.append(item)

函數填充它,最後在函數的末尾傳回。

對于這類模式,我們可以用生成器函數來簡化它。粗暴點說,就是用

yielditem

替代

append

語句。使用生成器的函數通常更簡潔、也更具通用性。

Python 工匠:讓函數傳回結果的技巧序言程式設計建議總結附錄

我在 系列第 4 篇文章“容器的門道” 裡詳細分析過這個模式,更多細節可以通路文章,搜尋 “寫擴充性更好的代碼” 檢視。

7. 限制遞歸的使用

當函數傳回自身調用時,也就是 遞歸 發生時。遞歸是一種在特定場景下非常有用的程式設計技巧,但壞消息是:Python 語言對遞歸支援的非常有限。

這份“有限的支援”展現在很多方面。首先,Python 語言不支援“尾遞歸優化”。另外 Python 對最大遞歸層級數也有着嚴格的限制。

是以我建議:盡量少寫遞歸。如果你想用遞歸解決問題,先想想它是不是能友善的用循環來替代。如果答案是肯定的,那麼就用循環來改寫吧。如果迫不得已,一定需要使用遞歸時,請考慮下面幾個點:

  • 函數輸入資料規模是否穩定,是否一定不會超過

    sys.getrecursionlimit()

    規定的最大層數限制
  • 是否可以通過使用類似

    functools.lru_cache

    的緩存工具函數來降低遞歸層數

總結

  • 讓函數擁有穩定的傳回值,一個函數隻做好一件事
  • 使用

    functools.partial

    定義快捷函數
  • 抛出異常也是傳回結果的一種方式,使用它來替代傳回錯誤資訊
  • 函數是否适合傳回 None,由函數簽名的“含義”所決定
  • 使用“空對象模式”可以簡化調用方的錯誤處理邏輯
  • 多使用生成器函數,盡量用循環替代遞歸

附錄