可能是最好的函數式編程入門

為什么要學習函數式編程

函數式編程編程范式中的一種,是一種典型的編程思想和方法。其他的編程范式還包括面向對象編程邏輯編程等。

許多人會有這樣的疑惑:為什么要學習編程范式?為什么要多學習一種編程范式?
答案是

為了更好的模塊化

編程范式的意義在于它提供了模塊化代碼的各種思想和方法。函數式編程亦然。

模塊化對工程的意義不言而喻,它是工程師的追求和驕傲。

  • 模塊化使得開發更快、維護更容易
  • 模塊可以重用
  • 模塊化便于單元測試和debug

所謂人如其名,正如面向對象編程是以對象為單位來構建模塊一樣,如果以一句話介紹函數式編程,我會說:

函數式編程是以函數為核心來組織模塊的一套編程方法。

文章結構

本文首先會介紹函數式編程的兩點基本主張

  1. 函數是第一等公民
  2. 純函數

這兩點是函數式編程的基礎,他帶來了更高層次的模塊化代碼手段,是單元測試工程師的夢想天堂。

在以上基本主張之上,函數式編程帶來了諸多酷炫的技術:

  1. 利用Memorization提升性能
  2. 利用延遲求值寫出更好的模塊化代碼
  3. 使用currying技術進行函數封裝

好,旅途正式開啟。

函數是第一等公民

既然函數式編程的基本理念是以函數為核心來組織代碼,很自然的,它首先將函數的地位提高,視其為第一等公民 (first class)。
所謂“第一等公民”,是指函數和其他數據類型擁有平等的地位,可以賦值給變量,也可以作為參數傳入另一個函數,或者作為別的函數的返回值。
當編程語言將函數視作“第一等公民”,那么相當于它支持了高階函數,因為高階函數就是至少滿足下列一個條件的函數

  • 接受一個或多個函數作為輸入
  • 輸出一個函數

為什么將函數視作“第一等公民”有利于寫出模塊化的代碼?不妨來看這樣一個例子

有數組numberList = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],編寫程序完成以下目標:
- 1.1 將numberList中的每個元素加1得到一個新的數組
- 1.2 將numberList中的每個元素乘2得到一個新的數組
- 1.3 將numberList中的每個元素模3得到一個新的數組

函數不是“第一等公民”情況下的代碼:

# 1.1 將numberList中的每個元素加1得到一個新的數組
newList = []
for num in numberList:
    newList.append(num + 1)

# 1.2 將numberList中的每個元素乘2得到一個新的數組
newList = []
for num in numberList:
    newList.append(num * 2)

# 1.3 將numberList中的每個元素模3得到一個新的數組
newList = []
for num in numberList:
    newList.append(num % 3)

有沒有發現每段代碼除了加1,乘2,模3的部分不一樣之外,其他的代碼都是一樣的,
也就是說這三段代碼只有原數組到新數組的映射函數是不同的(分別是加1,乘2,模3)。如果這個映射函數能夠以參數的方式傳遞,那么就可以復用上面的大部分代碼了。我們將可復用的代碼抽取出來編寫成高階函數map,如下

# 高階函數`map`
# 該函數接受一個函數和一個數組作為輸入,函數體中將這個函數作用于數組的每個元素然后作為返回值返回

def map(mappingFuction, numberList):
    newList = []
    for num in numberList:
        newList.append(mappingFuction(num))

事實上幾乎所有函數式編程語言都提供了這樣的map函數,于是完成上面的作業事實上只需3行代碼,如下:
PS:lambda關鍵字用于定義一個匿名函數(anonymous function),x表示輸入,冒號后是函數體同時也是返回值

# 1.1 將numberList中的每個元素加1得到一個新的數組
map(lambda x: x + 1, numberList)

# 1.2 將numberList中的每個元素乘2得到一個新的數組
map(lambda x: x * 2, numberList)

# 1.3 將numberList中的每個元素模3得到一個新的數組
map(lambda x: x % 3, numberList)

除了map函數之外,一般函數式編程語言還會配套提供一些非常通用的高階函數,使得寫出的代碼就像是對原問題的一句描述,簡練又易讀——有時人們也稱這樣的編程風格為聲明式編程 (declarative_programming)。而前一種代碼看起來是一步一步的求解步驟,這種編程風格被稱為指令式編程 (imperative_programming)。

純函數

除了將函數視作“一等公民”,函數式編程語言還主張甚至強制將函數寫成純函數 (pure function)。

純函數是指同時滿足下面兩個條件的函數:

  1. 函數的結果只依賴于輸入的參數且與外部系統狀態無關——只要輸入相同,返回值總是不變的。
  2. 除了返回值外,不修改程序的外部狀態(比如全局變量、入參)。——滿足這個條件也被稱作“沒有副作用 (side effect)”

由純函數的兩點條件可以看出,純函數是相對獨立的程序構件。因為函數的結果只依賴于輸入的參數且與外部系統狀態無關,使得單元測試和debug變得異常容易,而這也正是模塊化的優點之一。

除此之外,可以并發執行是純函數的另一優點,比如如下代碼

    t1 = pureFunction1(arg1)
    t2 = pureFunction2(arg2)
    result = concatFuction(t1, t2)

由于純函數pureFunction1pureFunction2只與入參相關而不依賴其他的外部系統狀態,前兩句函數調用的執行順序與程序結果是無關的,完全可以并發執行。

當人們討論函數式編程的時候,常常會提到一個詞——引用透明(Referential transparency)。其實引用透明的概念與純函數很接近:

如果一個表達式,對于相同的輸入,總是有相同的結果并且不修改程序其他部分的狀態,那么這個表達式是引用透明的。

由前面純函數的定義可以看到,由于函數調用也是表達式的一種,因此任何純函數的調用都滿足引用透明。

作為一等公民的純函數還帶來了什么

函數式編程語言中一等公民的函數地位,以及純函數的強制要求,可以帶來諸多好處。

(1)可以利用Memoization技術提升性能。
滿足引用透明的表達式(包括任意純函數調用)滿足這樣一個特點,就是任意兩次調用只要輸入相同,其結果總是不變的。于是可以將第一次的計算結果緩存起來,遇到下一次執行時直接替換,依然能保證程序的正確性。這種優化方法稱為Memoization

(2)延遲求值 ( Lazy Evaluation )
延遲求值是指表達式不在它被綁定到變量時就立即求值,而是在該值被用到的時候才計算求值。
很顯然,延遲求值的正確性需要純函數的性質來保證——即在輸入參數相同的情況下,無論什么時候被執行,結果總是不變的。

延遲求值有利于程序性能的提升。
比如下面這段代碼,trace函數在debugFlag等于True時會將debug信息打印到標準輸出。

def trace(debugFlag, debugStr):
    if debug == True:
        print debugStr

在實際使用中debug信息可能會由幾部分拼接而成,如下:

trace(debugFlag, 'str1' + 'str2' + 'str3')

如果是沒有延遲求值的語言,無論debugFlag參數等于True還是Flase, 'str1' + 'str2' + 'str3'這段代碼都是會被執行的。
而如果是采用延遲求值策略,只要當debug參數等于True且真正執行到print語句時,字符串拼接代碼才會被執行。也就是說真正被用到時才執行。

延遲求值更大的好處仍是利于模塊化,而且是超酷的模塊化。比如思考求解下面這組問題。

- 1.1 輸出斐波那契數列的第10個到第20個數
- 1.2 輸出斐波那契數列中前十個偶數
- 1.3 輸出斐波那契數列數列的前五個能被3整除的數

如果不考慮具體的語言和實現,可以將問題拆解成幾個函數。一個函數負責生成斐波那契數列,一個函數負責篩選數列中的偶數,再寫個函數挑出任意數列中能被3整除的數。
將第一個函數的輸出作為后面兩個函數的輸入,問題就得到解決了。

但問題是斐波那契數列是一個無窮數列,一般的語言無法輸出一個無窮的數據結構。不過這對于支持延遲求值的語言來說不成什么問題,因為每個值只有在真正被用到的時候才會被計算出來,因此完全可以像這樣定義一組無窮的斐波那契數列
fiboSequence = createFibonacci()
然后完成上面三道題只需這樣

- 1.1 輸出斐波那契數列的第10個到第20個數
print fiboSequence[10 : 20] #數列中第20個之后的數不會被計算

- 1.2 輸出斐波那契數列中前十個偶數
evenFiboSequence = pickEven(fiboSequence) # 函數pickEven從斐波那契數列中挑出所有的偶數,此時并不會真正計算
print evenFiboSequence[0 : 10] #直到輸出時才會把用到的值計算出來

- 1.3 輸出斐波那契數列數列的前五個能被3整除的數
newList = pick3(fiboSequence) #函數pick3從斐波那契數列中挑出所有能被3整除的數,此時并不會真正計算
print newLIst[0 : 5] #直到輸出時才會把用到的值計算出來

(3)可以使用 currying 技術進行函數封裝
curryiny 將接受多個參數的函數變換成接受其中部分參數,并且返回接受余下參數的新函數。(維基百科中的定義是接受一個單一參數的新函數,然而現實中currying技術的涵義被延伸了。)
比如冪函數pow(x, y),它接受兩個參數——x和y,計算x^y。使用currying技術,可以將y固定為2轉化為只接受單一參數x的平方函數,或者將y固定為3轉化為立方函數。代碼如下:

#平方函數
def square (int x):
    return pow(x, 2)

#立方函數
def cube (int x):
    return pow(x, 3)

熟悉設計模式的朋友已經感覺到,currying完成的事情就是函數(接口)封裝,它將一個已有的函數(接口)做封裝,得到一個新的函數(接口),這與適配器模式(Adapter pattern)的思想是一致的。但由于函數式編程語言更高的抽象層次,使得許多使用者甚至感覺不到自己在使用函數封裝,它的語法太直觀自然了。比如使用python語言functools中提供的partial函數,currying只需一行代碼,如下

#將pow函數的x參數固定為2,返回平方函數
square = partial.partial(pow, x=2)

#將pow函數的x參數固定為3,返回立方函數
cube = partial.partial(pow, x=3)

總結

最后用兩句話來歸納全文:
函數式編程通過
1)支持高階函數
2)倡導純函數
的設計,使得它在模塊化設計、單元測試、并行化方面具有獨特的魅力。

在這樣的設計之下
1)利用Memorization提升性能
2)利用延遲求值模塊化代碼
3)使用currying技術進行函數封裝
這些炫酷的技術成為現實。

我愛函數式編程。

參考資料

Why Functional Programming Matters —— John Hughes
函數式編程 —— 陳浩
函數式編程初探 —— 阮一峰
Functional Programming For The Rest of Us —— Slava Akhmechet
傻瓜函數式編程

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容