1. 函數語言程式設計概述
1.1. 什麼是函數語言程式設計?
函數語言程式設計使用一系列的函式解決問題。函式僅接受輸入併產生輸出,不包含任何能影響產生輸出的內部狀態。任何情況下,使用相同的引數呼叫函式始終能產生同樣的結果。
在一個函式式的程式中,輸入的資料“流過”一系列的函式,每一個函式根據它的輸入產生輸出。函式式風格避免編寫有“邊界效應”(side effects)的函式:修改內部狀態,或者是其他無法反應在輸出上的變化。完全沒有邊界效應的函式被稱為“純函式式的”(purely functional)。避免邊界效應意味著不使用在程式執行時可變的資料結構,輸出只依賴於輸入。
可以認為函數語言程式設計剛好站在了物件導向程式設計的對立面。物件通常包含內部狀態(欄位),和許多能修改這些狀態的函式,程式則由不斷修改狀態構成;函數語言程式設計則極力避免狀態改動,並通過在函式間傳遞資料流進行工作。但這並不是說無法同時使用函數語言程式設計和麵向物件程式設計,事實上,複雜的系統一般會採用物件導向技術建模,但混合使用函式式風格還能讓你額外享受函式式風格的優點。
1.2. 為什麼使用函數語言程式設計?
函式式的風格通常被認為有如下優點:
- 邏輯可證
這是一個學術上的優點:沒有邊界效應使得更容易從邏輯上證明程式是正確的(而不是通過測試)。
- 模組化
函數語言程式設計推崇簡單原則,一個函式只做一件事情,將大的功能拆分成儘可能小的模組。小的函式更易於閱讀和檢查錯誤。
- 元件化
小的函式更容易加以組合形成新的功能。
- 易於除錯
細化的、定義清晰的函式使得除錯更加簡單。當程式不正常執行時,每一個函式都是檢查資料是否正確的介面,能更快速地排除沒有問題的程式碼,定位到出現問題的地方。
- 易於測試
不依賴於系統狀態的函式無須在測試前構造測試樁,使得編寫單元測試更加容易。
- 更高的生產率
函數語言程式設計產生的程式碼比其他技術更少(往往是其他技術的一半左右),並且更容易閱讀和維護。
1.3. 如何辨認函式式風格?
支援函數語言程式設計的語言通常具有如下特徵,大量使用這些特徵的程式碼即可被認為是函式式的:
- 函式是一等公民
函式能作為引數傳遞,或者是作為返回值返回。這個特性使得模板方法模式非常易於編寫,這也促使了這個模式被更頻繁地使用。
以一個簡單的集合排序為例,假設lst是一個數集,並擁有一個排序方法sort需要將如何確定順序作為引數。
如果函式不能作為引數,那麼lst的sort方法只能接受普通物件作為引數。這樣一來我們需要首先定義一個介面,然後定義一個實現該介面的類,最後將該類的一個例項傳給sort方法,由sort呼叫這個例項的compare方法,就像這樣:
1 2 3 4 5 6 7 8 9 |
#虛擬碼 interface Comparator { compare(o1, o2) } lst = list(range(5)) lst.sort(Comparator() { compare(o1, o2) { return o2 - o1 //逆序 }) |
可見,我們定義了一個新的介面、新的型別(這裡是一個匿名類),並new了一個新的物件只為了呼叫一個方法。如果這個方法可以直接作為引數傳遞會怎樣呢?看起來應該像這樣:
1 2 3 4 |
def compare(o1, o2): return o2 - o1 #逆序 lst = list(range(5)) lst.sort(compare) |
請注意,前一段程式碼已經使用了匿名類技巧從而省下了不少程式碼,但仍然不如直接傳遞函式簡單、自然。
- 匿名函式(lambda)
lambda提供了快速編寫簡單函式的能力。對於偶爾為之的行為,lambda讓你不再需要在編碼時跳轉到其他位置去編寫函式。
lambda表示式定義一個匿名的函式,如果這個函式僅在編碼的位置使用到,你可以現場定義、直接使用:
1 |
lst.sort(lambda o1, o2: o1.compareTo(o2)) |
相信從這個小小的例子你也能感受到強大的生產效率:)
- 封裝控制結構的內建模板函式
為了避開邊界效應,函式式風格儘量避免使用變數,而僅僅為了控制流程而定義的迴圈變數和流程中產生的臨時變數無疑是最需要避免的。
假如我們需要對剛才的數集進行過濾得到所有的正數,使用指令式風格的程式碼應該像是這樣:
1 2 3 4 |
lst2 = list() for i in range(len(lst)): #模擬經典for迴圈 if lst[i] > 0: lst2.append(lst[i]) |
這段程式碼把從建立新列表、迴圈、取出元素、判斷、新增至新列表的整個流程完整的展示了出來,儼然把直譯器當成了需要手把手指導的傻瓜。然而,“過濾”這個動作是很常見的,為什麼直譯器不能掌握過濾的流程,而我們只需要告訴它過濾規則呢?
在Python裡,過濾由一個名為filter的內建函式實現。有了這個函式,直譯器就學會了如何“過濾”,而我們只需要把規則告訴它:
1 |
lst2 = filter(lambda n: n > 0, lst) |
這個函式帶來的好處不僅僅是少寫了幾行程式碼這麼簡單。
封裝控制結構後,程式碼中就只需要描述功能而不是做法,這樣的程式碼更清晰,更可讀。因為避開了控制結構的干擾,第二段程式碼顯然能讓你更容易瞭解它的意圖。
另外,因為避開了索引,使得程式碼中不太可能觸發下標越界這種異常,除非你手動製造一個。
函數語言程式設計語言通常封裝了數個類似“過濾”這樣的常見動作作為模板函式。唯一的缺點是這些函式需要少量的學習成本,但這絕對不能掩蓋使用它們帶來的好處。
- 閉包(closure)
閉包是繫結了外部作用域的變數(但不是全域性變數)的函式。大部分情況下外部作用域指的是外部函式。
閉包包含了自身函式體和所需外部函式中的“變數名的引用”。引用變數名意味著繫結的是變數名,而不是變數實際指向的物件;如果給變數重新賦值,閉包中能訪問到的將是新的值。
閉包使函式更加靈活和強大。即使程式執行至離開外部函式,如果閉包仍然可見,則被繫結的變數仍然有效;每次執行至外部函式,都會重新建立閉包,繫結的變數是不同的,不需要擔心在舊的閉包中繫結的變數會被新的值覆蓋。
回到剛才過濾數集的例子。假設過濾條件中的 0 這個邊界值不再是固定的,而是由使用者控制。如果沒有閉包,那麼程式碼必須修改為:
1 2 3 4 5 6 7 8 9 |
class greater_than_helper: def __init__(self, minval): self.minval = minval def is_greater_than(self, val): return val > self.minval def my_filter(lst, minval): helper = greater_than_helper(minval) return filter(helper.is_greater_than, lst) |
請注意我們現在已經為過濾功能編寫了一個函式my_filter。如你所見,我們需要在別的地方(此例中是類greater_than_helper)持有另一個運算元minval。
如果支援閉包,因為閉包可以直接使用外部作用域的變數,我們就不再需要greater_than_helper了:
1 2 |
def my_filter(lst, minval): return filter(lambda n: n > minval, lst) |
可見,閉包在不影響可讀性的同時也省下了不少程式碼量。
函數語言程式設計語言都提供了對閉包的不同程度的支援。在Python 2.x中,閉包無法修改繫結變數的值,所有修改繫結變數的行為都被看成新建了一個同名的區域性變數並將繫結變數隱藏。Python 3.x中新加入了一個關鍵字 nonlocal 以支援修改繫結變數。但不管支援程度如何,你始終可以訪問(讀取)繫結變數。
- 內建的不可變資料結構
為了避開邊界效應,不可變的資料結構是函數語言程式設計中不可或缺的部分。不可變的資料結構保證資料的一致性,極大地降低了排查問題的難度。
例如,Python中的元組(tuple)就是不可變的,所有對元組的操作都不能改變元組的內容,所有試圖修改元組內容的操作都會產生一個異常。
函數語言程式設計語言一般會提供資料結構的兩種版本(可變和不可變),並推薦使用不可變的版本。
- 遞迴
遞迴是另一種取代迴圈的方法。遞迴其實是函數語言程式設計很常見的形式,經常可以在一些演算法中見到。但之所以放到最後,是因為實際上我們一般很少用到遞迴。如果一個遞迴無法被編譯器或直譯器優化,很容易就會產生棧溢位;另一方面複雜的遞迴往
往讓人感覺迷惑,不如迴圈清晰,所以眾多最佳實踐均指出使用迴圈而非遞迴。
這一系列短文中都不會關注遞迴的使用。