碼如其人,同學(xué)你能寫(xiě)一手漂亮的Python函數(shù)嗎
與多數(shù)現(xiàn)代編程語(yǔ)言一樣,在 Python 中,函數(shù)是抽象和封裝的基本方法之一。你在開(kāi)發(fā)階段或許已經(jīng)寫(xiě)過(guò)數(shù)百個(gè)函數(shù),但并非每個(gè)函數(shù)都生而平等。寫(xiě)出「糟糕的」函數(shù)會(huì)直接影響代碼的可讀性和可維護(hù)性。那么,什么樣的函數(shù)是「糟糕的」函數(shù)呢?更重要的是,要怎么寫(xiě)出「好的」函數(shù)呢?
簡(jiǎn)單回顧
數(shù)學(xué)中充滿了函數(shù),盡管我們可能記不住它們。首先來(lái)回憶一下大家最喜歡的話題——微積分。你可能記得這個(gè)方程式: f(x) = 2x + 3. 這是一個(gè)叫做「f」的函數(shù),含有一個(gè)未知數(shù) x,「返回」2*x+3。這個(gè)函數(shù)可能和我們?cè)?Python 中看到的不一樣,但它的基本思想和計(jì)算機(jī)語(yǔ)言中的函數(shù)是一樣的。
函數(shù)在數(shù)學(xué)中歷史悠久,但在計(jì)算機(jī)科學(xué)中更加神通廣大。盡管如此,函數(shù)還是存在一些缺陷。接下來(lái)我們將討論一下什么是「好的」函數(shù),以及在出現(xiàn)什么樣的征兆時(shí)我們需要重構(gòu)函數(shù)。
決定函數(shù)好壞的關(guān)鍵
好的 Python 函數(shù)與蹩腳 Python 函數(shù)的區(qū)別是什么?「好」函數(shù)的定義之多讓人驚訝。從我們的目的出發(fā),我會(huì)把好的 Python 函數(shù)定義為符合以下清單中大部分規(guī)則的函數(shù)(有些比較難實(shí)現(xiàn)):
- 命名合理
- 具有單一功能
- 包含文檔注釋
- 返回一個(gè)值
- 代碼不超過(guò) 50 行
- 冪等,盡可能是純函數(shù)
對(duì)很多人來(lái)說(shuō),這個(gè)列表可能有些過(guò)于嚴(yán)格。但我保證,如果你的函數(shù)符合這些規(guī)則,你的代碼看起來(lái)會(huì)非常漂亮。下面我將分步講解各個(gè)規(guī)則,然后總結(jié)這些規(guī)則如何構(gòu)成一個(gè)「好」函數(shù)。
命名
關(guān)于這個(gè)問(wèn)題,我最喜歡的一句話(出自 Phil Karlton,總被誤以為是 Donald Knuth 說(shuō)的)是:
在計(jì)算機(jī)科學(xué)中只有兩個(gè)難題:緩存失效和命名問(wèn)題。
聽(tīng)起來(lái)有點(diǎn)匪夷所思,但整個(gè)不錯(cuò)的命名真的很難。下面就有一個(gè)糟糕的函數(shù)命名:
- def get_knn(from_df):
我基本上在任何地方都見(jiàn)過(guò)糟糕的命名,但這個(gè)例子來(lái)自數(shù)據(jù)科學(xué)(或者說(shuō),機(jī)器學(xué)習(xí)),從業(yè)者總是在 Jupyter notebook 上寫(xiě)代碼,然后嘗試將那些不同的單元變成一個(gè)可理解的程序。
該函數(shù)命名的第一個(gè)問(wèn)題是使用首字母縮寫(xiě)/縮略詞。比起縮略詞和并未普及的首字母縮寫(xiě),完整的英語(yǔ)單詞會(huì)更好。使用縮寫(xiě)的唯一原因是為了節(jié)省打字時(shí)間,但現(xiàn)代的編輯器都有自動(dòng)補(bǔ)全功能,所以你只需鍵入一次全名。之所以說(shuō)縮寫(xiě)是一個(gè)問(wèn)題,是因?yàn)樗鼈兺ǔV荒苡糜谔囟I(lǐng)域。在上面的代碼中,knn 是指「K-Nearest Neighbors」,df 指的是「DataFrame」——無(wú)處不在的 Pandas 數(shù)據(jù)結(jié)構(gòu)。如果另外一個(gè)不太熟悉這些縮寫(xiě)的編程人員正在閱讀代碼,那 TA 就會(huì)一頭霧水。
關(guān)于這個(gè)函數(shù)名稱,還有另外兩個(gè)小問(wèn)題:?jiǎn)卧~「get」無(wú)關(guān)緊要。對(duì)于大多數(shù)命名比較好的函數(shù),很明顯函數(shù)會(huì)返回一些東西,其名字會(huì)反映這一點(diǎn)。from_df 也是不必要的。如果參數(shù)的名稱描述不夠清楚的話,函數(shù)的文檔注釋或者類型注釋將描述參數(shù)類型。
那我們?nèi)绾沃匦旅@個(gè)函數(shù)呢?例如:
- def k_nearest_neighbors(dataframe):
現(xiàn)在,即使是外行也知道這個(gè)函數(shù)在計(jì)算什么了,參數(shù)的名稱(dataframe)也清楚地告訴我們應(yīng)該傳遞什么類型的參數(shù)。
單一功能原則
「單一功能原則」來(lái)自 Bob Martin「大叔」的一本書(shū),不僅適用于類和模塊,也同樣適用于函數(shù)(Martin 最初的目標(biāo))。該原則強(qiáng)調(diào),函數(shù)應(yīng)該具有「單一功能」。也就是說(shuō),一個(gè)函數(shù)應(yīng)該只做一件事。這么做的一大原因是:如果每個(gè)函數(shù)只做一件事,那么只有在函數(shù)做那件事的方式必須改變時(shí),該函數(shù)才需要改變。當(dāng)一個(gè)函數(shù)可以被刪除時(shí),事情就好辦了:如果其他地方發(fā)生改動(dòng),不再需要該函數(shù)的單一功能,那么只需將其刪除。
舉個(gè)例子來(lái)解釋一下。以下是一個(gè)不止做一件「事」的函數(shù):
- def calculate_and print_stats(list_of_numbers):
- sumsum = sum(list_of_numbers)
- mean = statistics.mean(list_of_numbers)
- median = statistics.median(list_of_numbers)
- mode = statistics.mode(list_of_numbers)
- print('-----------------Stats-----------------')
- print('SUM: {}'.format(sum) print('MEAN: {}'.format(mean)
- print('MEDIAN: {}'.format(median)
- print('MODE: {}'.format(mode)
這一函數(shù)做兩件事:計(jì)算一組關(guān)于數(shù)字列表的統(tǒng)計(jì)數(shù)據(jù),并將它們打印到 STDOUT。該函數(shù)違反了只有一個(gè)原因能讓函數(shù)改變的原則。顯然有兩個(gè)原因可以讓該函數(shù)做出改變:新的或不同的數(shù)據(jù)需要計(jì)算或輸出的格式需要改變。最好將該函數(shù)寫(xiě)成兩個(gè)獨(dú)立的函數(shù):一個(gè)用來(lái)執(zhí)行并返回計(jì)算結(jié)果;另一個(gè)用來(lái)接收結(jié)果并將其打印出來(lái)。函數(shù)有多重功能的一個(gè)致命漏洞是函數(shù)名稱中含有單詞「and」
這種分離還可以簡(jiǎn)化針對(duì)函數(shù)行為的測(cè)試,而且它們不僅被分離成一個(gè)模塊中的兩個(gè)函數(shù),還可能在適當(dāng)情況下存在于不同的模塊中。這使得測(cè)試更加清潔、維護(hù)更加簡(jiǎn)單。
只做兩件事的函數(shù)其實(shí)非常罕見(jiàn)。更常見(jiàn)的情況是一個(gè)函數(shù)負(fù)責(zé)許多許多任務(wù)。再次強(qiáng)調(diào)一下,為可讀性、可測(cè)試性起見(jiàn),我們應(yīng)該將這些「多面手」函數(shù)分成一個(gè)一個(gè)的小函數(shù),每個(gè)小函數(shù)只負(fù)責(zé)一項(xiàng)任務(wù)。
文檔注釋
很多 Python 開(kāi)發(fā)者都知道 PEP-8,它定義了 Python 編程的風(fēng)格指南,但很少有人了解定義了文檔注釋風(fēng)格的 PEP-257。在這里并不會(huì)詳細(xì)介紹 PEP-257,讀者可詳細(xì)閱讀該指南所約定的文檔注釋風(fēng)格。
- PEP-8:https://www.python.org/dev/peps/pep-0008/
- PEP-257:https://www.python.org/dev/peps/pep-0257/
首先文檔注釋是在定義模塊、函數(shù)、類或方法的第一段字符串聲明,這一段字符串應(yīng)該需要描述清楚函數(shù)的作用、輸入?yún)?shù)和返回參數(shù)等。PEP-257 的主要信息如下:
- 每一個(gè)函數(shù)都需要一個(gè)文檔描述;
- 使用合適的語(yǔ)法和標(biāo)點(diǎn),書(shū)寫(xiě)完整的句子;
- 最開(kāi)始需要用一句話總結(jié)函數(shù)的主要作用;
- 使用規(guī)定性的語(yǔ)言而不是描述性的語(yǔ)言。
在編寫(xiě)函數(shù)時(shí),遵循這些規(guī)則很容易。我們只需要養(yǎng)成編寫(xiě)文檔注釋的習(xí)慣,并在實(shí)際寫(xiě)函數(shù)主體之前完成它們。如果你不能清晰地描述這個(gè)函數(shù)的作用是什么,那么你需要更多地考慮為什么要寫(xiě)這個(gè)函數(shù)。
返回值
函數(shù)可以且應(yīng)該被視為一個(gè)獨(dú)立的小程序。它們以參數(shù)的形式獲取一些輸入,并返回一些輸出值。當(dāng)然,參數(shù)是可選的,但是從 Python 內(nèi)部機(jī)制來(lái)看,返回值是不可選的。即使你嘗試創(chuàng)建一個(gè)不會(huì)返回值的函數(shù),我們也不能選擇不在內(nèi)部采用返回值,因?yàn)?Python 的解釋器會(huì)強(qiáng)制返回一個(gè) None。不相信的讀者可以用以下代碼測(cè)試:
- ❯ python3
- Python 3.7.0 (default, Jul 23 2018, 20:22:55)
- [Clang 9.1.0 (clang-902.0.39.2)] on darwin
- Type "help", "copyright", "credits" or "license" *for *more information.
- >>> def add(a, b):
- ... print(a + b)
- ...
- >>> b = add(1, 2)
- 3
- >>> b
- >>> b is None
- True
運(yùn)行上面的代碼,你會(huì)看到 b 的值確實(shí)是 None。所以即使我們編寫(xiě)一個(gè)不包含 return 語(yǔ)句的函數(shù),它仍然會(huì)返回某些東西。不過(guò)函數(shù)也應(yīng)該要返回一些東西,因?yàn)樗彩且粋€(gè)小程序。沒(méi)有輸出的程序又會(huì)有多少用,我們又如何測(cè)試它呢?
我甚至希望發(fā)表以下聲明:每一個(gè)函數(shù)都應(yīng)該返回一個(gè)有用的值,即使這個(gè)值僅可用來(lái)測(cè)試。我們寫(xiě)的代碼應(yīng)該需要得到測(cè)試,而不帶返回值的函數(shù)很難測(cè)試它的正確性,上面的函數(shù)可能需要重定向 I/O 才能得到測(cè)試。此外,返回值能改變方法的調(diào)用,如下代碼展示了這種概念:
- with open('foo.txt', 'r') as input_file:
- for line in input_file:
- if line.strip().lower().endswith('cat'):
- # ... do something useful with these lines
代碼行 if line.strip().lower().endswith('cat') 能夠正常運(yùn)行,因?yàn)樽址椒?(strip(), lower(), endswith()) 會(huì)返回一個(gè)字符串以作為調(diào)用函數(shù)的結(jié)果。
以下是人們?cè)诒粏?wèn)及為什么他們寫(xiě)的函數(shù)沒(méi)有返回值時(shí)給出的一些常見(jiàn)原因:
「函數(shù)所做的就是類似 I/O 的操作,例如將一個(gè)值保存到數(shù)據(jù)庫(kù)中,這種函數(shù)不能返回有用的輸出。」 |
我并不同意這種觀點(diǎn),因?yàn)樵诓僮鞒晒ν瓿蓵r(shí),函數(shù)可以返回 True。
「我需要返回多個(gè)值,因?yàn)橹环祷匾粋€(gè)值并不能代表什么。」 |
當(dāng)然也可以返回包含多個(gè)值的一個(gè)元組。簡(jiǎn)而言之,即使在現(xiàn)有的代碼庫(kù)中,從函數(shù)返回一個(gè)值肯定是一個(gè)好主意,并且不太可能破壞任何東西。
函數(shù)長(zhǎng)度
函數(shù)的長(zhǎng)度直接影響了可讀性,因而會(huì)影響可維護(hù)性。因此要保證你的函數(shù)長(zhǎng)度足夠短。50 行的函數(shù)對(duì)我而言是個(gè)合理的長(zhǎng)度。
如果函數(shù)遵循單一功能原則,一般而言其長(zhǎng)度會(huì)非常短。如果函數(shù)是純函數(shù)或冪等函數(shù)(下面會(huì)討論),它的長(zhǎng)度也會(huì)較短。這些想法對(duì)于構(gòu)造簡(jiǎn)潔的代碼很有幫助。
那么如果一個(gè)函數(shù)太長(zhǎng)該怎么辦?代碼重構(gòu)(refactor)!代碼重構(gòu)很可能是你寫(xiě)代碼時(shí)一直在做的事情,即使你對(duì)這個(gè)術(shù)語(yǔ)并不熟悉。它的含義是:在不改變程序行為的前提下改變程序的結(jié)構(gòu)。因此從一個(gè)長(zhǎng)函數(shù)提取幾行代碼并轉(zhuǎn)換為屬于該函數(shù)的函數(shù)也是一種代碼重構(gòu)。這也是將長(zhǎng)函數(shù)縮短最快和最常用的方法。只要適當(dāng)給這些新函數(shù)命名,代碼的閱讀將變得更加容易。
冪等性和函數(shù)純度
冪等函數(shù)(idempotent function)在給定相同變量參數(shù)集時(shí)會(huì)返回相同的值,無(wú)論它被調(diào)用多少次。函數(shù)的結(jié)果不依賴于非局部變量、參數(shù)的易變性或來(lái)自任何 I/O 流的數(shù)據(jù)。以下的 add_three(number) 函數(shù)是冪等的:
- def add_three(number):
- """Return *number* + 3."""
- return number + 3
無(wú)論何時(shí)調(diào)用 add_three(7),其返回值都是 10。以下展示了非冪等的函數(shù)示例:
- def add_three():
- """Return 3 + the number entered by the user."""
- number = int(input('Enter a number: '))
- return number + 3
這函數(shù)不是冪等的,因?yàn)楹瘮?shù)的返回值依賴于 I/O,即用戶輸入的數(shù)字。每次調(diào)用這個(gè)函數(shù)時(shí),它都可能返回不同的值。如果它被調(diào)用兩次,則用戶可以第一次輸入 3,第二次輸入 7,使得對(duì) add_three() 的調(diào)用分別返回 6 和 10。
為什么冪等很重要?
可測(cè)試性和可維護(hù)性。冪等函數(shù)易于測(cè)試,因?yàn)樗鼈冊(cè)谑褂孟嗤瑓?shù)的情況下會(huì)返回同樣的結(jié)果。測(cè)試就是檢查對(duì)函數(shù)的不同調(diào)用所返回的值是否符合預(yù)期。此外,對(duì)冪等函數(shù)的測(cè)試很快,這在單元測(cè)試(Unit Testing)中非常重要,但經(jīng)常被忽視。重構(gòu)冪等函數(shù)也很簡(jiǎn)單。不管你如何改變函數(shù)以外的代碼,使用同樣的參數(shù)調(diào)用函數(shù)所返回的值都是一樣的。
什么是「純」函數(shù)?
在函數(shù)編程中,如果函數(shù)是冪等函數(shù)且沒(méi)有明顯的副作用(side effect),則它就是純函數(shù)。記住,冪等函數(shù)表示在給定參數(shù)集的情況下該函數(shù)總是返回相同的結(jié)果,不能使用任何外部因素來(lái)計(jì)算結(jié)果。但是,這并不意味著冪等函數(shù)無(wú)法影響非局部變量(non-local variable)或 I/O stream 等。例如,如果上文中 add_three(number) 的冪等版本在返回結(jié)果之前先輸出了結(jié)果,它仍然是冪等的,因?yàn)樗L問(wèn)了 I/O stream,這不會(huì)影響函數(shù)的返回值。調(diào)用 print() 是副作用:除返回值以外,與程序或系統(tǒng)中其余部分的交互。
我們來(lái)擴(kuò)展一下 add_three(number) 這個(gè)例子。我們可以用以下代碼片段來(lái)查看 add_three(number) 函數(shù)被調(diào)用的次數(shù):
- add_three_calls = 0
- def add_three(number):
- """Return *number* + 3."""
- global add_three_calls
- print(f'Returning {number + 3}')
- add_three_calls += 1
- return number + 3
- def num_calls():
- """Return the number of times *add_three* was called."""
- return add_three_calls
現(xiàn)在我們向控制臺(tái)輸出結(jié)果(一項(xiàng)副作用),并修改了非局部變量(又一項(xiàng)副作用),但是由于這些副作用不影響函數(shù)的返回值,因此該函數(shù)仍然是冪等的。
純函數(shù)沒(méi)有副作用。它不僅不使用任何「外來(lái)數(shù)據(jù)」來(lái)計(jì)算值,也不與系統(tǒng)/程序的其它部分進(jìn)行交互,除了計(jì)算和返回值。因此,盡管我們新定義的 add_three(number) 仍是冪等函數(shù),但它不再是純函數(shù)。
純函數(shù)不記錄語(yǔ)句或 print() 調(diào)用,不使用數(shù)據(jù)庫(kù)或互聯(lián)網(wǎng)連接,不訪問(wèn)或修改非局部變量。它們不調(diào)用任何其它的非純函數(shù)。
總之,純函數(shù)無(wú)法(在計(jì)算機(jī)科學(xué)背景中)做到愛(ài)因斯坦所說(shuō)的「幽靈般的遠(yuǎn)距效應(yīng)」(spooky action at a distance)。它們不以任何形式修改程序或系統(tǒng)的其余部分。在命令式編程中(寫(xiě) Python 代碼就是命令式編程),它們是最安全的函數(shù)。它們非常好測(cè)試和維護(hù),甚至在這方面優(yōu)于純粹的冪等函數(shù)。測(cè)試純函數(shù)的速度與執(zhí)行速度幾乎一樣快。而且測(cè)試很簡(jiǎn)單:沒(méi)有數(shù)據(jù)庫(kù)連接或其它外部資源,不要求設(shè)置代碼,測(cè)試結(jié)束后也不需要清理什么。
顯然,冪等和純函數(shù)是錦上添花,但并非必需。即,由于上述優(yōu)點(diǎn),我們喜歡寫(xiě)純函數(shù)或冪等函數(shù),但并不是所有時(shí)候都可以寫(xiě)出它們。關(guān)鍵在于,我們本能地在開(kāi)始部署代碼的時(shí)候就想著剔除副作用和外部依賴。這使得我們所寫(xiě)的每一行代碼都更容易測(cè)試,即使并沒(méi)有寫(xiě)純函數(shù)或冪等函數(shù)。
總結(jié)
寫(xiě)出好的函數(shù)的奧秘不再是秘密。只需按照一些完備的最佳實(shí)踐和經(jīng)驗(yàn)法則。希望這篇文章能夠幫助到大家。
原文鏈接:https://hackernoon.com/write-better-python-functions-c3a9a36382a6
【本文是51CTO專欄機(jī)構(gòu)“機(jī)器之心”的原創(chuàng)譯文,微信公眾號(hào)“機(jī)器之心( id: almosthuman2014)”】