Functional Programming in Kotlin (2)

FP 的三大類函式

Andy Lu
10 min readJul 18, 2022
Image by Gerd Altmann from Pixabay

前一篇文章介紹 FP 的概念,接下來繼續介紹的是在使用 FP 時,一定會出現的三大類函式,這三大類函式分別是:

  • 過濾(filter)
  • 轉換(transform)
  • 合併(combine)

下面的文章,我將一一介紹這三類函式的差異。

過濾 (Filter)

過濾,是把我們傳入的 List 根據條件篩選出我們希望留下來的值。所以它的類型並不會改變,例如原本輸入的值為 List<Int> ,經過 filter 計算之後,最後得到的結果也會是 List<Int>,只是 List 裡面項目的數量有可能會減少。

範例:當有一個包含整數 1~10 的 List ,若我們希望將三的倍數剃除,如果不是使用 FP 的方式,我們大概會寫成這個樣子,用一個 for 迴圈走訪 List 內的所有項目,並且在這裡面用 if 判斷式來決定哪些數值需要被留下來,哪些要被剃除。

fun filterMultiplesOf3(list:List<Int>): List<Int>{
val result = mutableListOf<Int>()
for(item in list){
if(item % 3 != 0) continue;
result.add(item)
}
return result.toList()
}

fun main(){
val list = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
filterMultiplesOf3(list) // 1, 2, 4, 5, 7, 8, 10
}

因為傳入的值是一個不可變的 List,所以在函式裡面建立了一個可變 List 用來儲存運算的結果,最後將可變 List 用 toList() 將其轉換成不可變 List 回傳。

如果我們是採用 FP 的 filter 函式來完成,程式碼如下:

fun main(){
val list = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
list.filter{ it % 3 != 0 }
}

這邊只需要一行指令就可以完成。注意到,在 filter 函式的尾端是使用 { } ,而不是( ) ,這是因為我們帶入 filter 函式內的是一個 Lambda 表達式(匿名函式),在這裡面把過濾條件帶入, filter 的內部就會將這段匿名函式帶入執行。

  • 我們把 filter 的原始碼展開來看一下

發現在 filter 所帶入的參數之中,那唯一的參數是一個 Function Type,而這邊使用的是泛型,用 T 表示一個任意的型別,而這個 Function Type 的回傳值是一個 Boolean 值。

所以我們帶入的 {it % 3 != 0} 就符合這個 Function Type 的定義。

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
return filterTo(ArrayList<T>(), predicate)
}

filter 裡面呼叫了 filterTo 函式。發現 filterTo 第一個參數是一個新的 ArrayList() ,也就是說,這個 filter 函式是一個純函式(Pure Function)- 呼叫它之後並不會影響到其輸入值。

public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(destination: C, predicate: (T) -> Boolean): C {
for (element in this) if (predicate(element)) destination.add(element)
return destination
}

除了 filter 之外,還有其他過濾的函式,如 filterNot(),filterIndexed() , partition()... 等。

轉換 (Transform)

與過濾不同,傳入至轉換函式內的 Lambda 表達式是轉換的方式,而經過轉換之後的型別與原本的可能相同也可能會不相同,取決於轉換方式。

map() 可以將帶入的 List 裡面的每一個項目經過處理之後,將結果儲存在一個新的 List 裡,而這個新的 List 裡面所包含的型別可能與原本的不同。舉例,如果我們希望將一個含有人名的列表將每一個項目的長度存在對應的位置,我們可以這樣做:

fun transformToSizeList(list:List<String>): List<Int>{
val result = mutableListOf<Int>()
for(item in list){
result.add(item.length)
}
return result.toList()
}

fun main(){
val names = listOf("Andy", "Alex", "Johnson", "May")
transformToSizeList(names) //[4, 4, 7, 3]
}

filter() 函式相似,同樣也是在函式內部使用一個 for 迴圈來對處理每一個項目。

若我們使用 map() 來改寫,程式碼如下:

fun main(){
val names = listOf("Andy", "Alex", "Johnson", "May")
names.map{ it.length }
}

filter 一樣也是一行指令就完成了,研究一下 map 的原始碼,發現輸入的型別與輸出的型別分別是 T 與 R,也就是說這兩個是不一定相同的型別;另外,如同 filter 一樣,這邊也是會新建一個 ArrayList 來儲存結果,當然 map() 也是一個純函式。

public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(destination: C, transform: (T) -> R): C {
for (item in this)
destination.add(transform(item))
return destination
}

轉換函式還有其他家族成員,包括 mapIndexed()mapNotNull()zip() ... 等等。

合併 (Combine)

說到合併,直覺想到的就是將一個 List 經過處理之後,變成單一個項目,而這個項目的型別有跟原本的有可能不同,而最經典的函式便是 reduce()fold(),這邊我們以 fold() 來作示範,假如我們需要計算一個 List 的總和,我們可以使用一個 for 迴圈將 List 裡面所有的項目拿出來加總,最後就會得到我們所想要的結果,如下:

fun sum(nums: List<Int>): Int{
var result = 0
for(item in nums){
result += item
}
return result
}

fun main(){
val nums = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
sum(nums) // 55
}

若改成 fold() 可以只用一行指令就可以完成一樣的工作,

nums.fold(0) { acc, i -> acc + i }

研究一下 fold() 的原始碼,fold() 函式需要帶入兩個參數,第一個是初始值,也就是我們自己寫的 sum() 函式裡面的 var result = 0 ,將預設值設定為 0 之後,並將 List 依照傳入的 Lambda 表達式來計算,其中 acc 代表累計值,i 代表每一個項目的值。

public inline fun <T, R> Iterable<T>.fold(initial: R, operation: (acc: R, T) -> R): R {
var accumulator = initial
for (element in this) accumulator = operation(accumulator, element)
return accumulator
}

其實,這個範例我們可以用 reduce() 來處理會更容易一些, reduce()fold() 類似,唯一不同的地方在於它不需要初始值:

nums.reduce { acc, i -> acc + i }

reduce() 的原始碼得知,它不是在內部呼叫 fold(),並將 0 帶入第一個參數,取而代之的是,它將輸入的 List 轉成 iterator,並利用 while 讀取每一次 iterator 的 next() 值,在第一步是直接呼叫 next() 來取得第一個值,所以也就不需要初始值了。

public inline fun <S, T : S> Iterable<T>.reduce(operation: (acc: S, T) -> S): S {
val iterator = this.iterator()
if (!iterator.hasNext()) throw UnsupportedOperationException("Empty collection can't be reduced.")
var accumulator: S = iterator.next()
while (iterator.hasNext()) {
accumulator = operation(accumulator, iterator.next())
}
return accumulator
}

當然如果是計算總和, Kotlin 的標準函式庫有提供一個同樣名為 sum() 的函式,如果查看它的原始碼,會發現跟我們寫的第一版 sum() 完全相同。

合併類的函式除了本節所介紹的 reduce()fold()sum() ... 等等。

小結

介紹完三種類型的函式後,若以輸入型態及輸出型態來分類,可以分為1. 輸入與輸出同樣型態的「過濾」,2. 輸入與輸出不同型態的「轉換」,3. 將一連串的值合併成一個值的「合併」。

在使用這些函式時,最容易注意到的是它們的命名,每一個名稱都具有相當唯一的名稱,而在這些函式當中,相似名稱的函式也很多,因為要讓使用者能夠依照需求挑選出適當的函式,如此一來,我們就可以將我們需要實現的功能建立在這些「高階函式」之上。

這些函式除了可以讓我們使用更精簡的程式碼以外,還有另外一個很重要的改變,如果就以 sum() 函式來說,我們自己寫的函式裡面的內容都是我們自己去實作的,我們自己建立一個可變的 List ,自己用一個 for 迴圈來走訪每一個元素,也自己處理要對每一個元素做的動作。這種方式很像是我們命令電腦幫我們做事,我們給它的是很精確的命令,電腦必須要按照我們所提供的指令一行一行的去執行。若是將角度切到我們 使用 的高階函式,這些高階函式背後的動作我們不一定需要知道,我們只需要知道,我們需要使用這個函式來完成我們的需求。這也就是我前面所提到的,利用較高的抽象層次來解決問題,而這種程式編寫的方式稱為「宣告式」,而原本的作法稱為「命令式」,宣告式與命令式的差異將在下一篇文章繼續討論。

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

工商時間

對於 Kotlin 裡針對 Collection 大量的 API 搞的頭昏眼花嗎?可以參考這本 Kotlin Collection 全方位解析攻略 : 精通原理及實戰,寫出流暢好維護的程式(iT邦幫忙鐵人賽系列書)

--

--

Andy Lu

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