1. 程式人生 > >優化Python函式

優化Python函式

主要優化點:

* 命名合理

* 具有單一功能

* 包含文件註釋

* 返回一個值

* 程式碼不超過 50 行

* 冪等,儘可能是純函式

 

-- 命名

關於這個問題,我最喜歡的一句話(出自 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-8:https://www.python.org/dev/peps/pep-0008/

PEP-257:https://www.python.org/dev/peps/pep-0257/

首先文件註釋是在定義模組、函式、類或方法的第一段字串宣告,這一段字串應該需要描述清楚函式的作用、輸入引數和返回引數等。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 才能得到測試。此外,返回值能改變方法的呼叫,如下程式碼展示了這種概念:

程式碼行 if line.strip().lower().endswith('cat') 能夠正常執行,因為字串方法 (strip(), lower(), endswith()) 會返回一個字串以作為呼叫函式的結果。


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


以下是人們在被問及為什麼他們寫的函式沒有返回值時給出的一些常見原因:

「函式所做的就是類似 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() 是副作用:除返回值以外,與程式或系統中其餘部分的互動。

我們來擴充套件一下 add_three(number) 這個例子。我們可以用以下程式碼片段來檢視 add_three(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 程式碼就是指令式程式設計),它們是最安全的函式。它們非常好測試和維護,甚至在這方面優於純粹的冪等函式。測試純函式的速度與執行速度幾乎一樣快。而且測試很簡單:沒有資料庫連線或其它外部資源,不要求設定程式碼,測試結束後也不需要清理什麼。

顯然,冪等和純函式是錦上添花,但並非必需。即,由於上述優點,我們喜歡寫純函式或冪等函式,但並不是所有時候都可以寫出它們。關鍵在於,我們本能地在開始部署程式碼的時候就想著剔除副作用和外部依賴。這使得我們所寫的每一行程式碼都更容易測試,即使並沒有寫純函式或冪等函式。