Functional Programming in Kotlin (1)

與 FP 的第一次接觸

Andy Lu
9 min readJul 16, 2022
Image by Gerd Altmann from Pixabay

什麼是 Function?

在進入 Functional Programming 之前,我們要先了解什麼是 Function,Function 的中文翻譯是「函式」,而它的概念是從數學的領域所引進的,在程式語言以外,我們可以在很多地方都能夠看到函式的存在,下面以一個一元一次方程式作範例:

f(x) = x*2 + 3

這個方程式的內容是在等號右邊的 x*2 + 3 ,左邊的 f(x) 代表這個方程式。其中 f(x) 括弧中的 x 代表我們可以傳入這個函式的值,x 可以是任意值,但是每一個相同的 x 都會得到相同的結果,也就是說,這個函式的結果是唯一的,它不會因為任何情況而改變,也不會影響其他的值。它所做的動作只有作這個計算而已。

什麼是 Functional Programming (FP) ?

注意到前面的方程式例子中,我提到帶入相同的 x 值可以得到相同的結果,而無論輸入什麼值,都不會影響到其他的部分,因為這個方程式只會針對輸入的值來作計算,對於外面其他的部分都不會碰觸到,自然也就不會影響到其他的部分了。

FP 包含了許多概念,其中包括:Pure function (純函式),First-class and higher-order functions(一級函式及高階函式)。

純函式(Pure Function)

在使用 FP 之前,我們經常使用的類別是類別裡面的函式,雖然名稱也是函式,但是我們經常會在類別中的函式裡面使用/修改函式外部的變數,所以不能稱作純函式。

Pure Function 讓我們的程式更可預期,因為在每次呼叫程式的時候都能預期得到相同的結果。譬如:

val list = listOf(1, 2, 3, 4, 5)
val filteredList1 = list.filter{ item -> item > 3} // 4, 5
val filteredList2 = list.filter{ item -> item > 3} // 4, 5

上面範例中,無論我們呼叫多少次 filter 函式,它的結果都會是相同的。也就是說 filter 函式它就是一個純函式。純函式讓程式變得可預期了,所以我們在寫下這個函式的時候,就能夠很確定其結果是什麼。

想想看,為什麼每次的呼叫結果都是一樣的呢?

原因在於每次呼叫 filter 時,都會建立一個新的 List 來存放輸出的資料,如此一來,我們在每次呼叫 filter 時,就能夠在不影響原本 List 的情況之下得到我們要的結果。

在想想看,為什麼需要每次呼叫的結果都是一樣的呢?

有一個最重要的好處,當我們的程式不小心在某個時機點多次執行某個函式時,假若是純函式,那麼就不會受到影響,因為它的結果每次都是相同的,所以我們呼叫在多次都是一樣;反之,若不是純函式,有可能因為在多次的呼叫之後,導致最後的結果出錯。

因為我們是在函式裡面建立一個新的 List 來存放處理過的結果,所以原本輸入的 List 就可以改為不可變的(Immutable),輸入的 List改成不可變時,也就代表不會有任何地方可以更改這個值,我們在傳遞的過程當中,就不用擔心在哪個環節被更改了,所以第二個優點是可以減少原始資料被改動的風險。

無副作用 (No side effect)

回頭看看我在文章的前面所提到的方程式,它只是一個單純的計算,呼叫它之後並不會影響到其他的物件,看完第一個方程式之後,回到 Pure Function 這邊的 filter 函式, filter 函式同樣也不會影響到其他的變數,呼叫它只會建立一個新的 List ,它既沒有改變外面的物件,也沒有改變自己函式傳入的輸入值。而我們知道,一個函式裡面沒有修改、調用外部的變數、函式時,這個就是一個沒有副作用的一個函式。

純函式讓我們的目的集中在該函式內,而且它既不會更改外面的變數,也不會修改內部的輸入值,所以在使用的時候,就能夠更放心的使用。

Higher-order function(高階函式)

什麼是高階函式呢?

官網是這樣介紹的

Kotlin functions are first-class, which means they can be stored in variables and data structures, and can be passed as arguments to and returned from other higher-order functions.

first-class 的中文意思是一級、頭等(頭等艙的英文就是 first class),上面的解釋很清楚了,簡單來說,我們可以把函式當作一般的型別來使用,也就是說,我們可以將其儲存在變數中,或作為參數傳進函式內,亦或是將其當作回傳值傳出皆可。到這邊可能就有點霧煞煞,那麼到底實際要怎麼運作呢?在繼續介紹之前,我們要先介紹 Function Type

Function Type(函式型別)

將函式當作一般型別使用,我們必須要知道它的輸入型別及輸出型別,而 Function Type 就是在定義這個東西的。如果以判斷輸入的整數是否為偶數的函式來作範例,我們可以完成該函式如下:

fun isEven(value: Int): Boolean = value % 2 == 0

我們可以將這個函式儲存在一個變數中,如下

val checkEven:(Int) -> Boolean = ::isEven

:: 代表反射,所以 ::isEven 就等於 isEven 函式的內容。

若我們不想要另外定義一個函式,我們可以直接在變數宣告函式的內容,只要用大括弧 { } 將內容帶入即可:

val checkEven2:(Int)->Boolean = { value: Int -> value % 2 == 0 }

不論 checkEven 或是 checkEven2 兩個函式都是代表相同的意思,具有相同的功能:將傳入的整數以布林值當作輸出,若輸入值為偶數即回傳 true ;反之,則回傳 false

回到 Function Type 這個部分,從上面的範例我們可以知道,若函式的輸入為 Int,且輸出為 Boolean 時,那麼這個函式的 Function Type 就為 (Int) -> Boolean ,箭頭左邊用括號代表輸入的型別,箭頭右邊代表輸出的型別。

我們第二個變數 checkEven2 等號右邊大括弧包住的範圍,由於沒有函式名稱,所以它是一個匿名函式,我們稱為 Lambda,符號記作 λ。

為什麼我們要用 FP?

前面我們介紹了 FP 的兩個概念,一個是 Pure Function,另一個則是 Higher-order function,我們開始對 FP 開始有點了解了,但是你可能會問我,為什麼我們要用 FP 呢?在我把我的答案告訴你之前,你可以想想看一個問題,為什麼 Kotlin 會要支援多種範式呢?Kotlin 大可不必支援 FP ,只支援 OOP 就好了。

其實不只 Kotlin ,現在有越來越多語言都或多或少都有支援 FP。原因不是因為它很潮,畢竟 FP 也不是什麼新技術了,我認為原因是使用 FP 時,我們是將關注點切細,由類別切細成函式,由抽象成類別改為呼叫高階函式。在某些時候我們其實更在意的是資料的處理,而不是要怎麼抽象複用該類別。當我們要完成一件事時,若是使用 FP 的開發方式,我們思考的是如何將一個動作切成一連串的小動作,這麼做或許看起來變得更囉唆了。不過如果仔細思考,由於每個高階函式都只作一個小動作,每一個函式都是 Pure Function,也就是說,由於呼叫每一個高階函式的結果都是可預期的,所以我們更能夠把所有可預期的動作組合成一連串的動作,當我們最後在檢閱時,也可以很容易地從一連串的動作中知道我們主要的動作是什麼。而 Kotlin 標準函式庫內提供許多好用的高階函式,所以我們就不需要思考這麼底層的實作,轉而可以用更高的抽象層次來解決問題。

OOP(Object-orientated programming) VS FP

OOP 與 FP 都在 Kotlin 中所被支援的範式之一,FP 跟 OOP 的設計方式有什麼差異呢?

Working effectively with legacy code 的作者 Michael Feathers 有一則推文是這樣說的:

OO makes code understandable by encapsulating moving parts. FP makes code understandable by minimizing moving parts.

-Michael Feathers

OOP 將不確定性封裝起來,多型將不同型態的物件實作相同介面,或是說將不同型態但相同的函式抽出成為它們所共同實作的介面,當這些物件擁有相同的介面時,呼叫端就可以只使用介面提供的函式,而不管內部的實作是如何,這就是抽象。

FP 是利用高階函式將步伐縮小,利用不同的函式組合出合適的動作,並搭配 Pure Function 讓函式只專注在單一的功能上,不會影響到其他部分(無副作用),所以在平行化的資料處理上,就不會遇到多個執行緒要修改相同的變數時,所造成的問題,如 Dead lock,Race condition…,因為每一次的呼叫都是建立一個新的物件,而不會修改原本的輸入值,換句話說,當有多個執行緒要修改相同的變數時,就不需要把這個變數鎖起來了,不過因為是在函式內建立新的物件,當然也會需要更多的記憶空間來儲存。

小結

對 FP 的第一個印象就是它是由函式為觀點的開發方式,那麼由函式為觀點有什麼優勢呢?當函式為純函式(Pure Function) 時,相同的輸入傳進相同的函式中,永遠會得到相同的結果,這麼一來我們就可以在開發時就很準確的預期結果會是什麼,不會有其他的變化,而因為純函式的另一個特性 — 無副作用(No side effect),所以當我們呼叫一個純函式時,可以很確定的是不會影響到其他的變數或呼叫其它的函式,換句話說,我們就可以放心的呼叫這個函式。

用 FP 開發時的觀點也隨著改變,因為 Kotlin 標準函式庫提供許多高階函數,我們可以依照需求組合這些函式,這麼做的好處是我們就只考慮如何完成需求,內部的實作反倒不是最重要的點。那麼,我們就能夠用更高層次的抽象來解決問題。這種開發方式與 OOP 不同,彼此也各有優缺點,這也就是 Kotlin 提供多種範式的原因 — 讓我們能夠自由的選擇適當的範式來解決問題。

如果本篇文章有幫助到您,留下您的鼓勵是支持我創作的動力,謝謝。

--

--

Andy Lu

Android/Flutter developer, Kotlin Expert, like to learn and share.