程式語言的特性本質(四)往數學領域抽象化的函數程式設計


iThome 網站首載:程式語言的特性本質(四)往數學領域抽象化的函數程式設計

許多程式語言融合了多種程式設計典範(Paradigm),除了為人熟知的結構化、物件導向等典範外,逐漸也可見函數程式設計(Functional programming)的蹤影,相對於物件導向將問題具體為物件互動的世界,函數設計則往數學領域抽象化,將問題逐項分解為函數定義。

以數學形式定義問題

函數式程式設計經常與指令式程式設計(Imperative programming)相比較,可使用求解費式數(Fibonacci number)來突顯兩者設計上的差異,費式數的數學定義為 { F0 = 0, F1 = 1, Fn = Fn-1 + Fn-2 }。

多數電腦硬體運作方式都是指令式,因而許多語言常基於指令風格,例如高階語言中定義變數來儲存數值,其實是下指令要求配置記憶體以存放數值。指令式程設要求將問題翻譯為電腦執行指令。例如底下Java程式以指令式風格求解費式數:
int fib(int n) {
    int a = 1; int b = 1;
    for(int i = 2; i < n; i++) {
        int tmp = b; b = a + b; a = tmp;
    }
    return b;
}

函數式程設不用多出翻譯指令這道手續,而是直接根據數學定義撰寫程式:
int fib(int n) {
    if(n == 0 || n == 1) return n;
    else return fib(n - 1) + fib(n - 2);
}

指令式程設要求提供求解的每一道指令,函數式程設只要有問題的數學定義,電腦即可求解問題。由於定義問題時使用函數,沒有指令式程設中追蹤變數狀態的負擔;函數的輸出值(傳回值)只受輸入值(引數)影響,沒有副作用(Side effect)問題,單元測試變得容易;每個函數都是獨立單元,易於利用與組合。

函數式語言的特徵

使用非函數式語言也可撰寫出具函數式風格的程式,然而可能事倍功半,甚至可讀性變差,執行效能也不好。舉先前函數式的fib方法來說,看不出有多好的可讀性,若用純函數式語言Haskell撰寫,幾乎等於原先文字性的數學定義描述,這就是函數式語言呈現的可讀性:
fib 0 = 0
fib 1 = 1
fib n = fib (n-1) + fib (n-2)

若要得到第10個費式數,可呼叫fib 10。這是函數式語言Haskell中支援模式匹配(Pattern match)的語法。模式匹配可用指定模式對指定值進行比對、拆解而後進行對應處理。例如加總數列的問題,可用函數如下定義:
sum [] = 0
sum (x:xs) = x + sum xs

若以sum [1, 3, 5, 7, 9]呼叫,則(x:xs)模式比對後,x1xs[3, 5, 7, 9],可自行想像不使用模式匹配時,撰寫出來的程式碼會多冗長。

數學定義隨處可見遞迴,因此函數式程設時經常用到遞迴,然而遞迴效率並不好,在支援函數式的語言中,會以延遲求值(Lazy evaluation)特性來改善問題。例如若呼叫函數some(x + 10, y + 20),在不支援延遲求值的語言中,呼叫函式前必須先計算x + 10y + 20的結果,再將結果傳入some函式,如果實際求解過程中不會用到y + 20(例如在if分支中),那麼y + 20的運算就浪費了。在支援延遲求值的語言中,x + 10y + 20運算式會直接傳入函式中,在實際需要x + 10的值時才予以計算,從而減少運算負擔。

數學上要表示具相同規則的數列可使用表達式(Comprehension),例如1到100的正整數中由2倍數組成的數列可表達為 {2 * x | x ε {1, 2..100}},雖可用遞迴來解答問題,但可讀性會變差,如果語言支援數列表達式(List comprehension),則可依照問題定義直接撰寫程式。例如Haskell可寫為 [2 * x | x <- [1, 2..100]],幾乎就是問題定義。數列表達式可看作是對數列施加某運算以得到一組新數列的語法,在指令式語言通常得利用迴圈來達成,函數式程度不鼓勵用迴圈,純函數式語言如Haskell甚至沒有迴圈語法。

函數式語言支援一級(First class)函式概念,也就是函式本身就是值,可作為引數值或回傳值傳遞。現在有些主流語言支援一級函式概念,因此不少開發者對它並不陌生。在傳遞函式過程中,可將當前運算結果攜帶至另一函式,在純函數式語言由於沒有實質的變數,這種方式可視為保存函式運算狀態的一種方式。有些函數式語言進一步支援鞣製函式(Curried function),讓程式語法更貼近數學表達方式。舉例來說,若有函數f(x, y) = x + y,在已知x為1情況下,可定義函數g(y) = f(1, y),此問題使用Haskell可撰寫為:
f x y = x + y
g y = f 1 y

如果呼叫g函式時傳入2,首先f 1表示以不齊全的引數呼叫f函式,傳回值實際上是個函式,再用該函式套用傳入的2引數,也就是實際上是(f 1) 2的方式來呼叫。

物件導向與函數式設計不衝突

目前主流語言多支援物件導向,這些語言中也常見一級函式、數列表達式等函數式風格語法,函數式風格實際上是相對於指令式風格,函數式風格與物件導向並不衝突,只要遵守一些基本原則,在程式中必要時也結合物件或多或少地實現函數式設計,有些語言(如Scala)甚至在語法上直接提供支援,試著融合物件導向與函數式設計,。

函數式設計的好處是無副作用,副作用是指程式中看不到的輸入或輸出。例如數個函式共同存取某全域變數,函式輸入就不再僅依賴於引數,只要全域變數被修改,相同引數呼叫函式得到的結果就不會相同,對呼叫該函式當時的情境而言,全域變數就是看不到的輸入。在物件導向中,若物件狀態可變,物件值域(Field)對方法而言就好似全域變數,相同引數呼叫同一物件方法,將不會得到相同結果,從這個角度來看,狀態可變的物件,本身就是個副作用集合體。

在物件導向中要融合函數式設計,原則之一就是設計狀態不可變的物件,在物件導向語言中就常見一些不可變數型態,像是Java中的String、Python中的Tuple,Ruby則在方法命名上使用!區分該方法是否會變動物件狀態,若想以函數式風格撰寫程式,就應避免呼叫名稱上有!的方法。在方法本身的實作上,避免使用可變動變數,可強制以函數方式來思考,甚至辨識出更適合的領域物件。

讓運算回歸數學思路
   
電腦本質就是運算,函數式設計讓運算回歸數學思路,數學本身就是個需要訓練的過程,正如開發者也非一開始就熟悉物件導向。函數式設計是思考如何以數學形式定義問題,配合函數式特性的語言來撰寫定義,電腦就能根據定義求出解答。在純函數式語言中,程式實作在外觀上可幾乎等於數學定義。問到函數式程式可讀性如何?就是在問數學定義的可讀性如何?就等於在問自己是否有數學家的閱讀與思考能力?