在機器學習中,咱們常常須要使用類和函數定義模型的各個部分,例如定義讀取數據的函數、預處理數據的函數、模型架構和訓練過程的函數等等。那麼什麼樣的函數纔是漂亮的、賞心悅目的代碼呢?在本文中,Jeff Knupp 從命名到代碼量等六方面探討了如何養成美妙的函數。html
與多數現代編程語言同樣,在 Python 中,函數是抽象和封裝的基本方法之一。你在開發階段或許已經寫過數百個函數,但並不是每一個函數都生而平等。寫出「糟糕的」函數會直接影響代碼的可讀性和可維護性。那麼,什麼樣的函數是「糟糕的」函數呢?更重要的是,要怎麼寫出「好的」函數呢?python
數學中充滿了函數,儘管咱們可能記不住它們。首先來回憶一下你們最喜歡的話題——微積分。你可能記得這個方程式: f(x) = 2x + 3. 這是一個叫作「f」的函數,含有一個未知數 x,「返回」2*x+3。這個函數可能和咱們在 Python 中看到的不同,但它的基本思想和計算機語言中的函數是同樣的。數據庫
函數在數學中歷史悠久,但在計算機科學中更加神通廣大。儘管如此,函數仍是存在一些缺陷。接下來咱們將討論一下什麼是「好的」函數,以及在出現什麼樣的徵兆時咱們須要重構函數。編程
好的 Python 函數與蹩腳 Python 函數的區別是什麼?「好」函數的定義之多讓人驚訝。從咱們的目的出發,我會把好的 Python 函數定義爲符合如下清單中大部分規則的函數(有些比較難實現):小程序
對不少人來講,這個列表可能有些過於嚴格。但我保證,若是你的函數符合這些規則,你的代碼看起來會很是漂亮。下面我將分步講解各個規則,而後總結這些規則如何構成一個「好」函數。緩存
關於這個問題,我最喜歡的一句話(出自 Phil Karlton,總被誤覺得是 Donald Knuth 說的)是:安全
在計算機科學中只有兩個難題:緩存失效和命名問題。數據結構
聽起來有點匪夷所思,但整個不錯的命名真的很難。下面就有一個糟糕的函數命名:架構
def get_knn(from_df):
我基本上在任何地方都見過糟糕的命名,但這個例子來自數據科學(或者說,機器學習),從業者老是在 Jupyter notebook 上寫代碼,而後嘗試將那些不一樣的單元變成一個可理解的程序。機器學習
該函數命名的第一個問題是使用首字母縮寫/縮略詞。比起縮略詞和並未普及的首字母縮寫,完整的英語單詞會更好。使用縮寫的惟一緣由是爲了節省打字時間,但現代的編輯器都有自動補全功能,因此你只需鍵入一次全名。之因此說縮寫是一個問題,是由於它們一般只能用於特定領域。在上面的代碼中,knn 是指「K-Nearest Neighbors」,df 指的是「DataFrame」——無處不在的 Pandas 數據結構。若是另一個不太熟悉這些縮寫的編程人員正在閱讀代碼,那 TA 就會一頭霧水。
關於這個函數名稱,還有另外兩個小問題:單詞「get」可有可無。對於大多數命名比較好的函數,很明顯函數會返回一些東西,其名字會反映這一點。from_df 也是沒必要要的。若是參數的名稱描述不夠清楚的話,函數的文檔註釋或者類型註釋將描述參數類型。
那咱們如何從新命名這個函數呢?例如:
def k_nearest_neighbors(dataframe):
如今,即便是外行也知道這個函數在計算什麼了,參數的名稱(dataframe)也清楚地告訴咱們應該傳遞什麼類型的參數。
「單一功能原則」來自 Bob Martin「大叔」的一本書,不只適用於類和模塊,也一樣適用於函數(Martin 最初的目標)。該原則強調,函數應該具備「單一功能」。也就是說,一個函數應該只作一件事。這麼作的一大緣由是:若是每一個函數只作一件事,那麼只有在函數作那件事的方式必須改變時,該函數才須要改變。當一個函數能夠被刪除時,事情就好辦了:若是其餘地方發生改動,再也不須要該函數的單一功能,那麼只需將其刪除。
舉個例子來解釋一下。如下是一個不止作一件「事」的函數:
def calculate_and print_stats(list_of_numbers): sum = 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)
這一函數作兩件事:計算一組關於數字列表的統計數據,並將它們打印到 STDOUT。該函數違反了只有一個緣由能讓函數改變的原則。顯然有兩個緣由可讓該函數作出改變:新的或不一樣的數據須要計算或輸出的格式須要改變。最好將該函數寫成兩個獨立的函數:一個用來執行並返回計算結果;另外一個用來接收結果並將其打印出來。函數有多重功能的一個致命漏洞是函數名稱中含有單詞「and」
這種分離還能夠簡化針對函數行爲的測試,並且它們不只被分離成一個模塊中的兩個函數,還可能在適當狀況下存在於不一樣的模塊中。這使得測試更加清潔、維護更加簡單。
只作兩件事的函數其實很是罕見。更常見的狀況是一個函數負責許多許多任務。再次強調一下,爲可讀性、可測試性起見,咱們應該將這些「多面手」函數分紅一個一個的小函數,每一個小函數只負責一項任務。
不少 Python 開發者都知道 PEP-8,它定義了 Python 編程的風格指南,但不多有人瞭解定義了文檔註釋風格的 PEP-257。在這裏並不會詳細介紹 PEP-257,讀者可詳細閱讀該指南所約定的文檔註釋風格。
首先文檔註釋是在定義模塊、函數、類或方法的第一段字符串聲明,這一段字符串應該須要描述清楚函數的做用、輸入參數和返回參數等。PEP-257 的主要信息以下:
在編寫函數時,遵循這些規則很容易。咱們只須要養成編寫文檔註釋的習慣,並在實際寫函數主體以前完成它們。若是你不能清晰地描述這個函數的做用是什麼,那麼你須要更多地考慮爲何要寫這個函數。
函數能夠且應該被視爲一個獨立的小程序。它們以參數的形式獲取一些輸入,並返回一些輸出值。固然,參數是可選的,可是從 Python 內部機制來看,返回值是不可選的。即便你嘗試建立一個不會返回值的函數,咱們也不能選擇不在內部採用返回值,由於 Python 的解釋器會強制返回一個 None。不相信的讀者能夠用如下代碼測試:
❯ 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
運行上面的代碼,你會看到 b 的值確實是 None。因此即便咱們編寫一個不包含 return 語句的函數,它仍然會返回某些東西。不過函數也應該要返回一些東西,由於它也是一個小程序。沒有輸出的程序又會有多少用,咱們又如何測試它呢?
我甚至但願發表如下聲明:每個函數都應該返回一個有用的值,即便這個值僅可用來測試。咱們寫的代碼應該須要獲得測試,而不帶返回值的函數很難測試它的正確性,上面的函數可能須要重定向 I/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') 可以正常運行,由於字符串方法 (strip(), lower(), endswith()) 會返回一個字符串以做爲調用函數的結果。
如下是人們在被問及爲何他們寫的函數沒有返回值時給出的一些常見緣由:
「函數所作的就是相似 I/O 的操做,例如將一個值保存到數據庫中,這種函數不能返回有用的輸出。」
我並不一樣意這種觀點,由於在操做成功完成時,函數能夠返回 True。
「我須要返回多個值,由於只返回一個值並不能表明什麼。」
固然也能夠返回包含多個值的一個元組。簡而言之,即便在現有的代碼庫中,從函數返回一個值確定是一個好主意,而且不太可能破壞任何東西。
函數的長度直接影響了可讀性,於是會影響可維護性。所以要保證你的函數長度足夠短。50 行的函數對我而言是個合理的長度。
若是函數遵循單一功能原則,通常而言其長度會很是短。若是函數是純函數或冪等函數(下面會討論),它的長度也會較短。這些想法對於構造簡潔的代碼頗有幫助。
那麼若是一個函數太長該怎麼辦?代碼重構(refactor)!代碼重構極可能是你寫代碼時一直在作的事情,即便你對這個術語並不熟悉。它的含義是:在不改變程序行爲的前提下改變程序的結構。所以從一個長函數提取幾行代碼並轉換爲屬於該函數的函數也是一種代碼重構。這也是將長函數縮短最快和最經常使用的方法。只要適當給這些新函數命名,代碼的閱讀將變得更加容易。
冪等函數(idempotent function)在給定相同變量參數集時會返回相同的值,不管它被調用多少次。函數的結果不依賴於非局部變量、參數的易變性或來自任何 I/O 流的數據。如下的 add_three(number) 函數是冪等的:
def add_three(number): """Return _number_ \+ 3.""" return number + 3
不管什麼時候調用 add_three(7),其返回值都是 10。如下展現了非冪等的函數示例:
def add_three(): """Return 3 + the number entered by the user.""" number = int(input('Enter a number: ')) return number + 3
這函數不是冪等的,由於函數的返回值依賴於 I/O,即用戶輸入的數字。每次調用這個函數時,它均可能返回不一樣的值。若是它被調用兩次,則用戶能夠第一次輸入 3,第二次輸入 7,使得對 add_three() 的調用分別返回 6 和 10。
可測試性和可維護性。冪等函數易於測試,由於它們在使用相同參數的狀況下會返回一樣的結果。測試就是檢查對函數的不一樣調用所返回的值是否符合預期。此外,對冪等函數的測試很快,這在單元測試(Unit Testing)中很是重要,但常常被忽視。重構冪等函數也很簡單。無論你如何改變函數之外的代碼,使用一樣的參數調用函數所返回的值都是同樣的。
在函數編程中,若是函數是冪等函數且沒有明顯的反作用(side effect),則它就是純函數。記住,冪等函數表示在給定參數集的狀況下該函數老是返回相同的結果,不能使用任何外部因素來計算結果。可是,這並不意味着冪等函數沒法影響非局部變量(non-local variable)或 I/O stream 等。例如,若是上文中 add_three(number) 的冪等版本在返回結果以前先輸出告終果,它仍然是冪等的,由於它訪問了 I/O stream,這不會影響函數的返回值。調用 print() 是反作用:除返回值之外,與程序或系統中其他部分的交互。
咱們來擴展一下 addthree(number) 這個例子。咱們能夠用如下代碼片斷來查看 addthree(number) 函數被調用的次數:
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
如今咱們向控制檯輸出結果(一項反作用),並修改了非局部變量(又一項反作用),可是因爲這些反作用不影響函數的返回值,所以該函數仍然是冪等的。
純函數沒有反作用。它不只不使用任何「外來數據」來計算值,也不與系統/程序的其它部分進行交互,除了計算和返回值。所以,儘管咱們新定義的 add_three(number) 還是冪等函數,但它再也不是純函數。
純函數不記錄語句或 print() 調用,不使用數據庫或互聯網鏈接,不訪問或修改非局部變量。它們不調用任何其它的非純函數。
總之,純函數沒法(在計算機科學背景中)作到愛因斯坦所說的「幽靈般的遠距效應」(spooky action at a distance)。它們不以任何形式修改程序或系統的其他部分。在命令式編程中(寫 Python 代碼就是命令式編程),它們是最安全的函數。它們很是好測試和維護,甚至在這方面優於純粹的冪等函數。測試純函數的速度與執行速度幾乎同樣快。並且測試很簡單:沒有數據庫鏈接或其它外部資源,不要求設置代碼,測試結束後也不須要清理什麼。
顯然,冪等和純函數是錦上添花,但並不是必需。即,因爲上述優勢,咱們喜歡寫純函數或冪等函數,但並非全部時候均可以寫出它們。關鍵在於,咱們本能地在開始部署代碼的時候就想着剔除反作用和外部依賴。這使得咱們所寫的每一行代碼都更容易測試,即便並無寫純函數或冪等函數。
寫出好的函數的奧祕再也不是祕密。只需按照一些完備的最佳實踐和經驗法則。但願這篇文章可以幫助到你們。
相關推薦:Python學習手冊