函數語言程式設計入門

張博208發表於2017-02-17

為什麼要學習函數語言程式設計

函數語言程式設計程式設計正規化中的一種,是一種典型的程式設計思想和方法。其他的程式設計正規化還包括物件導向程式設計邏輯程式設計等。

許多人會有這樣的疑惑:為什麼要學習程式設計正規化?為什麼要多學習一種程式設計正規化?
答案是

為了更好的模組化

程式設計正規化的意義在於它提供了模組化程式碼的各種思想和方法。函數語言程式設計亦然。

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

  • 模組化使得開發更快、維護更容易
  • 模組可以重用
  • 模組化便於單元測試和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
傻瓜函數語言程式設計

相關文章