Python 工匠:讓函數(shù)返回結(jié)果的技巧
序言
這是 “Python 工匠”系列的第 5 篇文章。

毫無(wú)疑問(wèn),函數(shù)是 Python 語(yǔ)言里最重要的概念之一。在編程時(shí),我們將真實(shí)世界里的大問(wèn)題分解為小問(wèn)題,然后通過(guò)一個(gè)個(gè)函數(shù)交出答案。函數(shù)即是重復(fù)代碼的克星,也是對(duì)抗代碼復(fù)雜度的最佳武器。
如同大部分故事都會(huì)有結(jié)局,絕大多數(shù)函數(shù)也都是以返回結(jié)果作為結(jié)束。函數(shù)返回結(jié)果的手法,決定了調(diào)用它時(shí)的體驗(yàn)。所以,了解如何優(yōu)雅的讓函數(shù)返回結(jié)果,是編寫好函數(shù)的必備知識(shí)。
Python 的函數(shù)返回方式
Python 函數(shù)通過(guò)調(diào)用 return 語(yǔ)句來(lái)返回結(jié)果。使用 returnvalue 可以返回單個(gè)值,用 returnvalue1,value2 則能讓函數(shù)同時(shí)返回多個(gè)值。
如果一個(gè)函數(shù)體內(nèi)沒(méi)有任何 return 語(yǔ)句,那么這個(gè)函數(shù)的返回值默認(rèn)為 None。除了通過(guò) return 語(yǔ)句返回內(nèi)容,在函數(shù)內(nèi)還可以使用拋出異常(raise Exception)的方式來(lái)“返回結(jié)果”。
接下來(lái),我將列舉一些與函數(shù)返回相關(guān)的常用編程建議。

編程建議
1. 單個(gè)函數(shù)不要返回多種類型
Python 語(yǔ)言非常靈活,我們能用它輕松完成一些在其他語(yǔ)言里很難做到的事情。比如:讓一個(gè)函數(shù)同時(shí)返回不同類型的結(jié)果。從而實(shí)現(xiàn)一種看起來(lái)非常實(shí)用的“多功能函數(shù)”。
就像下面這樣:

當(dāng)我們需要獲取單個(gè)用戶時(shí),就傳遞 user_id 參數(shù),否則就不傳參數(shù)拿到所有活躍用戶列表。一切都由一個(gè)函數(shù) get_users 來(lái)搞定。這樣的設(shè)計(jì)似乎很合理。
然而在函數(shù)的世界里,以編寫具備“多功能”的瑞士軍刀型函數(shù)為榮不是一件好事。這是因?yàn)楹玫暮瘮?shù)一定是 “單一職責(zé)(Single responsibility)” 的。單一職責(zé)意味著一個(gè)函數(shù)只做好一件事,目的明確。這樣的函數(shù)也更不容易在未來(lái)因?yàn)樾枨笞兏恍薷摹?/p>
而返回多種類型的函數(shù)一定是違反“單一職責(zé)”原則的,好的函數(shù)應(yīng)該總是提供穩(wěn)定的返回值,把調(diào)用方的處理成本降到最低。像上面的例子,我們應(yīng)該編寫兩個(gè)獨(dú)立的函數(shù) get_user_by_id(user_id)、 get_active_users()來(lái)替代。
2. 使用 partial 構(gòu)造新函數(shù)
假設(shè)這么一個(gè)場(chǎng)景,在你的代碼里有一個(gè)參數(shù)很多的函數(shù) A,適用性很強(qiáng)。而另一個(gè)函數(shù) B 則是完全通過(guò)調(diào)用 A 來(lái)完成工作,是一種類似快捷方式的存在。
比方在這個(gè)例子里, double 函數(shù)就是完全通過(guò) multiply 來(lái)完成計(jì)算的:

對(duì)于上面這種場(chǎng)景,我們可以使用 functools 模塊里的 partial() 函數(shù)來(lái)簡(jiǎn)化它。
partial(func,*args,**kwargs)基于傳入的函數(shù)與可變(位置/關(guān)鍵字)參數(shù)來(lái)構(gòu)造一個(gè)新函數(shù)。所有對(duì)新函數(shù)的調(diào)用,都會(huì)在合并了當(dāng)前調(diào)用參數(shù)與構(gòu)造參數(shù)后,代理給原始函數(shù)處理。
利用 partial 函數(shù),上面的 double 函數(shù)定義可以被修改為單行表達(dá)式,更簡(jiǎn)潔也更直接。

建議閱讀:partial 函數(shù)官方文檔
3. 拋出異常,而不是返回結(jié)果與錯(cuò)誤
我在前面提過(guò),Python 里的函數(shù)可以返回多個(gè)值?;谶@個(gè)能力,我們可以編寫一類特殊的函數(shù):同時(shí)返回結(jié)果與錯(cuò)誤信息的函數(shù)。

在示例中, create_item 函數(shù)的作用是創(chuàng)建新的 Item 對(duì)象。同時(shí),為了在出錯(cuò)時(shí)給調(diào)用方提供錯(cuò)誤詳情,它利用了多返回值特性,把錯(cuò)誤信息作為第二個(gè)結(jié)果返回。
乍看上去,這樣的做法很自然。尤其是對(duì)那些有 Go 語(yǔ)言編程經(jīng)驗(yàn)的人來(lái)說(shuō)更是如此。但是在 Python 世界里,這并非解決此類問(wèn)題的最佳辦法。因?yàn)檫@種做法會(huì)增加調(diào)用方進(jìn)行錯(cuò)誤處理的成本,尤其是當(dāng)很多函數(shù)都遵循這個(gè)規(guī)范而且存在多層調(diào)用時(shí)。
Python 具備完善的異常(Exception)機(jī)制,并且在某種程度上鼓勵(lì)我們使用異常(官方文檔關(guān)于 EAFP 的說(shuō)明)。所以,使用異常來(lái)進(jìn)行錯(cuò)誤流程處理才是更地道的做法。
引入自定義異常后,上面的代碼可以被改寫成這樣:

使用“拋出異?!碧娲胺祷?(結(jié)果, 錯(cuò)誤信息)”后,整個(gè)錯(cuò)誤流程處理乍看上去變化不大,但實(shí)際上有著非常多不同,一些細(xì)節(jié): - 新版本函數(shù)擁有更穩(wěn)定的返回值類型,它永遠(yuǎn)只會(huì)返回 Item 類型或是拋出異常 - 雖然我在這里鼓勵(lì)使用異常,但“異?!笨偸菚?huì)無(wú)法避免的讓人 感到驚訝,所以,最好在函數(shù)文檔里說(shuō)明可能拋出的異常類型 - 異常不同于返回值,它在被捕獲前會(huì)不斷往調(diào)用棧上層匯報(bào)。所以 create_item 的一級(jí)調(diào)用方完全可以省略異常處理,交由上層處理。這個(gè)特點(diǎn)給了我們更多的靈活性,但同時(shí)也帶來(lái)了更大的風(fēng)險(xiǎn)。
Hint:如何在編程語(yǔ)言里處理錯(cuò)誤,是一個(gè)至今仍然存在爭(zhēng)議的主題。比如像上面不推薦的多返回值方式,正是缺乏異常的 Go 語(yǔ)言中最核心的錯(cuò)誤處理機(jī)制。另外,即使是異常機(jī)制本身,不同編程語(yǔ)言之間也存在著差別。 異常,或是不異常,都是由語(yǔ)言設(shè)計(jì)者進(jìn)行多方取舍后的結(jié)果,更多時(shí)候不存在絕對(duì)性的優(yōu)劣之分。但是,單就 Python 語(yǔ)言而言,使用異常來(lái)表達(dá)錯(cuò)誤無(wú)疑是更符合 Python 哲學(xué),更應(yīng)該受到推崇的。
4. 謹(jǐn)慎使用 None 返回值
None 值通常被用來(lái)表示“某個(gè)應(yīng)該存在但是缺失的東西”,它在 Python 里是獨(dú)一無(wú)二的存在。很多編程語(yǔ)言里都有與 None 類似的設(shè)計(jì),比如 JavaScript 里的 null、Go 里的 nil 等。因?yàn)?None 所擁有的獨(dú)特 虛無(wú) 氣質(zhì),它經(jīng)常被作為函數(shù)返回值使用。
當(dāng)我們使用 None 作為函數(shù)返回值時(shí),通常是下面 3 種情況。
4.1. 作為操作類函數(shù)的默認(rèn)返回值
當(dāng)某個(gè)操作類函數(shù)不需要任何返回值時(shí),通常就會(huì)返回 None。同時(shí),None 也是不帶任何 return 語(yǔ)句函數(shù)的默認(rèn)返回值。
對(duì)于這種函數(shù),使用 None 是沒(méi)有任何問(wèn)題的,標(biāo)準(zhǔn)庫(kù)里的 list.append()、 os.chdir() 均屬此類。
4.2. 作為某些“意料之中”的可能沒(méi)有的值
有一些函數(shù),它們的目的通常是去嘗試性的做某件事情。視情況不同,最終可能有結(jié)果,也可能沒(méi)有結(jié)果。而對(duì)調(diào)用方來(lái)說(shuō),“沒(méi)有結(jié)果”完全是意料之中的事情。對(duì)這類函數(shù)來(lái)說(shuō),使用 None 作為“沒(méi)結(jié)果”時(shí)的返回值也是合理的。
在 Python 標(biāo)準(zhǔn)庫(kù)里,正則表達(dá)式模塊 re 下的 re.search、 re.match 函數(shù)均屬于此類,這兩個(gè)函數(shù)在可以找到匹配結(jié)果時(shí)返回 re.Match 對(duì)象,找不到時(shí)則返回 None。
4.3. 作為調(diào)用失敗時(shí)代表“錯(cuò)誤結(jié)果”的值
有時(shí), None 也會(huì)經(jīng)常被我們用來(lái)作為函數(shù)調(diào)用失敗時(shí)的默認(rèn)返回值,比如下面這個(gè)函數(shù):

當(dāng) username 不合法時(shí),函數(shù) create_user_from_name 將會(huì)返回 None。但在這個(gè)場(chǎng)景下,這樣做其實(shí)并不好。
不過(guò)你也許會(huì)覺(jué)得這個(gè)函數(shù)完全合情合理,甚至你會(huì)覺(jué)得它和我們提到的上一個(gè)“沒(méi)有結(jié)果”時(shí)的用法非常相似。那么如何區(qū)分這兩種不同情形呢?關(guān)鍵在于:函數(shù)簽名(名稱與參數(shù))與 None 返回值之間是否存在一種“意料之中”的暗示。
讓我解釋一下,每當(dāng)你讓函數(shù)返回 None 值時(shí),請(qǐng)仔細(xì)閱讀函數(shù)名,然后問(wèn)自己一個(gè)問(wèn)題:假如我是該函數(shù)的使用者,從這個(gè)名字來(lái)看,“拿不到任何結(jié)果”是否是該函數(shù)名稱含義里的一部分?
分別用這兩個(gè)函數(shù)來(lái)舉例: - re.search():從函數(shù)名來(lái)看, search,代表著從目標(biāo)字符串里去搜索匹配結(jié)果,而搜索行為,一向是可能有也可能沒(méi)有結(jié)果的,所以該函數(shù)適合返回 None - create_user_from_name():從函數(shù)名來(lái)看,代表基于一個(gè)名字來(lái)構(gòu)建用戶,并不能讀出一種 可能返回、可能不返回的含義。所以不適合返回 None
對(duì)于那些不能從函數(shù)名里讀出 None 值暗示的函數(shù)來(lái)說(shuō),有兩種修改方式。第一種,如果你堅(jiān)持使用 None 返回值,那么請(qǐng)修改函數(shù)的名稱。比如可以將函數(shù) create_user_from_name() 改名為 create_user_or_none()。
第二種方式則更常見(jiàn)的多:用拋出異常(raise Exception)來(lái)代替 None 返回值。因?yàn)?,如果返回不了正常結(jié)果并非函數(shù)意義里的一部分,這就代表著函數(shù)出現(xiàn)了“意料以外的狀況”,而這正是 Exceptions 異常 所掌管的領(lǐng)域。
使用異常改寫后的例子:

與 None 返回值相比,拋出異常除了擁有我們?cè)谏蟼€(gè)場(chǎng)景提到的那些特點(diǎn)外,還有一個(gè)額外的優(yōu)勢(shì):可以在異常信息里提供出現(xiàn)意料之外結(jié)果的原因,這是只返回一個(gè) None 值做不到的。
5. 合理使用“空對(duì)象模式”
我在前面提到函數(shù)可以用 None 值或異常來(lái)返回錯(cuò)誤結(jié)果,但這兩種方式都有一個(gè)共同的缺點(diǎn)。那就是所有需要使用函數(shù)返回值的地方,都必須加上一個(gè) if 或 try/except 防御語(yǔ)句,來(lái)判斷結(jié)果是否正常。
讓我們看一個(gè)可運(yùn)行的完整示例:

補(bǔ)充圖中顯示不到的為:{BALANCE}" ')
在這個(gè)例子里,每當(dāng)我們調(diào)用 Account.from_string 時(shí),都必須使用 try/except 來(lái)捕獲可能發(fā)生的異常。如果項(xiàng)目里需要調(diào)用很多次該函數(shù),這部分工作就變得非常繁瑣了。針對(duì)這種情況,可以使用“空對(duì)象模式(Null object pattern)”來(lái)改善這個(gè)控制流。
Martin Fowler 在他的經(jīng)典著作《重構(gòu)》 中用一個(gè)章節(jié)詳細(xì)說(shuō)明過(guò)這個(gè)模式。簡(jiǎn)單來(lái)說(shuō),就是使用一個(gè)符合正常結(jié)果接口的“空類型”來(lái)替代空值返回/拋出異常,以此來(lái)降低調(diào)用方處理結(jié)果的成本。
引入“空對(duì)象模式”后,上面的示例可以被修改成這樣:

在新版代碼里,我定義了 NullAccount 這個(gè)新類型,用來(lái)作為 from_string 失敗時(shí)的錯(cuò)誤結(jié)果返回。這樣修改后的最大變化體現(xiàn)在 caculate_total_balance 部分:

調(diào)整之后,調(diào)用方不必再顯式使用 try語(yǔ)句來(lái)處理錯(cuò)誤,而是可以假設(shè) Account.from_string 函數(shù)總是會(huì)返回一個(gè)合法的 Account 對(duì)象,從而大大簡(jiǎn)化整個(gè)計(jì)算邏輯。
Hint:在 Python 世界里,“空對(duì)象模式”并不少見(jiàn),比如大名鼎鼎的 Django 框架里的 AnonymousUser 就是一個(gè)典型的 null object。
6. 使用生成器函數(shù)代替返回列表
在函數(shù)里返回列表特別常見(jiàn),通常,我們會(huì)先初始化一個(gè)列表 results=[],然后在循環(huán)體內(nèi)使用 results.append(item) 函數(shù)填充它,最后在函數(shù)的末尾返回。
對(duì)于這類模式,我們可以用生成器函數(shù)來(lái)簡(jiǎn)化它。粗暴點(diǎn)說(shuō),就是用 yielditem 替代 append 語(yǔ)句。使用生成器的函數(shù)通常更簡(jiǎn)潔、也更具通用性。

我在 系列第 4 篇文章“容器的門道” 里詳細(xì)分析過(guò)這個(gè)模式,更多細(xì)節(jié)可以訪問(wèn)文章,搜索 “寫擴(kuò)展性更好的代碼” 查看。
7. 限制遞歸的使用
當(dāng)函數(shù)返回自身調(diào)用時(shí),也就是 遞歸 發(fā)生時(shí)。遞歸是一種在特定場(chǎng)景下非常有用的編程技巧,但壞消息是:Python 語(yǔ)言對(duì)遞歸支持的非常有限。
這份“有限的支持”體現(xiàn)在很多方面。首先,Python 語(yǔ)言不支持“尾遞歸優(yōu)化”。另外 Python 對(duì)最大遞歸層級(jí)數(shù)也有著嚴(yán)格的限制。
所以我建議:盡量少寫遞歸。如果你想用遞歸解決問(wèn)題,先想想它是不是能方便的用循環(huán)來(lái)替代。如果答案是肯定的,那么就用循環(huán)來(lái)改寫吧。如果迫不得已,一定需要使用遞歸時(shí),請(qǐng)考慮下面幾個(gè)點(diǎn):
函數(shù)輸入數(shù)據(jù)規(guī)模是否穩(wěn)定,是否一定不會(huì)超過(guò) sys.getrecursionlimit() 規(guī)定的最大層數(shù)限制
是否可以通過(guò)使用類似 functools.lru_cache 的緩存工具函數(shù)來(lái)降低遞歸層數(shù)

總結(jié)
在這篇文章中,我虛擬了一些與 Python 函數(shù)返回有關(guān)的場(chǎng)景,并針對(duì)每個(gè)場(chǎng)景提供了我的優(yōu)化建議。最后再總結(jié)一下要點(diǎn): - 讓函數(shù)擁有穩(wěn)定的返回值,一個(gè)函數(shù)只做好一件事 - 使用 functools.partial 定義快捷函數(shù) - 拋出異常也是返回結(jié)果的一種方式,使用它來(lái)替代返回錯(cuò)誤信息 - 函數(shù)是否適合返回 None,由函數(shù)簽名的“含義”所決定 - 使用“空對(duì)象模式”可以簡(jiǎn)化調(diào)用方的錯(cuò)誤處理邏輯 - 多使用生成器函數(shù),盡量用循環(huán)替代遞歸
看完文章的你,有沒(méi)有什么想吐槽的?請(qǐng)留言或者在 項(xiàng)目 Github Issues 告訴我吧。

附錄
題圖來(lái)源: Dominik Scythe on Unsplash
更多系列文章地址:https://github.com/piglei/one-python-craftsman

藍(lán)鯨智云
本文由騰訊藍(lán)鯨智云編輯發(fā)布,騰訊藍(lán)鯨智云(簡(jiǎn)稱藍(lán)鯨)軟件體系是一套基于PaaS的技術(shù)解決方案,致力于打造行業(yè)領(lǐng)先的一站式自動(dòng)化運(yùn)維平臺(tái)。目前已經(jīng)推出社區(qū)版、企業(yè)版,歡迎體驗(yàn)。
官網(wǎng):https://bk.tencent.com/
下載鏈接:https://bk.tencent.com/download/
社區(qū):https://bk.tencent.com/s-mart/community/question