iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 9
1
Software Development

Functional Programming in Kotlin系列 第 9

More FlatMap : List and Try

今天再來講多一點 flatMap 的例子吧!首先從 List 開始!

FlatMap for List

一樣先看例子,以下這個例子的目標是分解句子中的單字,其中分解的規則使用是單字跟單字之間的空白。另外,順便幫大家複習一下 lambda as function。

// 1
val sentences: List<String> = listOf("Hello world!",
    "Awesome functional kotlin!",
    "I like pie I like cake")
// 2
val splitFun: (String) -> List<String> = { sentence: String -> sentence.split(" ")}
// 3
val result: List<String> = sentences.map(splitFun)
    .flatten()

println(result)
// [Hello, world!, Awesome, functional, kotlin!, I, like, pie, I, like, cake]
  1. sentences 是一些英文例句,其中有著許多空白
  2. 在這個函式中,輸入是 String,回傳是一個 List 。因為這些英文單字之間是用空白來隔開的,所以直接使用了 split(" ") 來實作。
  3. 對於每一個句子,都要操作一次 splitFun,所以使用了 map 這個 operator。之後,再利用 flatten 將二維的 List 降為一維。

如果沒有 flatten 結果將會是 [[Hello, world!], [Awesome, functional, kotlin!], [I, like, pie, I, like, cake]] ,而這個不是我們想要的結果,拿一維的資料來做分析才會方便。

對了,還記得上一篇的 flatMap 嗎?來試看看結果會不會一樣吧!

val result = sentences.flatMap(splitFun)

println(result)
// [Hello, world!, Awesome, functional, kotlin!, I, like, pie, I, like, cake]

結果一樣!而且行數還更少了!現在再來進一步分析,在 map 中放進不同的 function ,並且外層再用一個 lambda 包起來,觀察輸入以及輸出型別的變化。

val splitFun: (String) -> List<String> = { sentence: String -> sentence.split(" ")}

// 1
val composeFun1: (List<String>) -> List<String> = { sentences: List<String> ->
    sentences.map{ "$it by Yanbin" }
}

// 2
val composeFun2: (List<String>) -> List<List<String>> = { sentences: List<String> ->
    sentences.map(splitFun)
}

// 3
val composeFun3: (List<String>) -> List<String> = { sentences: List<String> ->
    sentences.flatMap(splitFun)
}
  1. 最基本的 map ,原本輸入的“容器”是 List,經過計算過後,由於執行的 function 是 {"$it by Yanbin"} ,型別沒有任何變化,所以輸出的"容器"也還是 List。
  2. 但是放在 map 裡的 function 本身也會產出一個“容器”的話,在這裡是 splitFun: String -> List<String>,回傳值就是兩層的“容器”了。
  3. 為了不產生出兩層的“容器”,直接使用 flatMap 就可以讓輸出變回一層的“容器”。然後 composeFun3 的 function type 就會跟 compose1 的 function type 一樣是 (List → String)。

FlatMap in Try

既然 Observable 跟 List 都有 flatMap ,他們都是一種“容器”。之前也有說過,Try 也是一種“容器”,那 Try 也會有 flatMap 嗎?如果有,那又是什麼時候才會用到呢?

容許我偷懶一下,重用前幾篇看過的一個例子:

class AccountRepo() {
  // 從 id 找不到的話會丟 Exception
  fun queryFromId(id: String): Account {...}
  // account 必須還在,有可能被其他人不小心刪掉
  fun updateAccount(account: Account): Boolean {...}
}

class BankService() {
  // 帳戶的錢必需還要夠多,不夠的話會丟 Exception
  fun withDraw(account: Account, amount: Int): Account {...}
}

在這個範例中,有很多會發生錯誤的狀態,而 Try 之前有介紹過,可以使用 Try.Fail() 來包裝所有的錯誤狀態,與其任由 Exception 隨意丟出來,用 Try 來包裝不是很好嗎?所以在這裏 AccountRepoBankService function 的 return type 全部都改用 Try 來做包裝,這麼做有另一個好處,這些函式的意圖也更加清楚了,使用方( AccountRepo 的呼叫者)不得不意識到錯誤有可能會發生。

class AccountRepo() {
  fun queryFromId(id: String): Try<Account> {
    return try {
       ...  
    } catch(error: Throwable) {
      Try.Fail(error)
    }
  }

  fun updateAccount(account: Account): Try<Boolean> {
    return try {
       ...  
    } catch(error: Throwable) {
      Try.Fail(error)
    }
  }
}

class BankService() {
  fun withDraw(account: Account, amount: Int): Try<Account> {
    return try {
       ...  
    } catch(error: Throwable) {
      Try.Fail(error)
    }
  }
}

// 相信大家都覺得 try catch 寫三次很煩,在 Arrow 中其實有一個替代方案:
// 使用 extension function - Try
// ref: https://arrow-kt.io/docs/0.10/apidocs/arrow-core-data/arrow.core/-try/

好的,一切準備就緒,現在試試看把所有東西都組合起來吧!這邊有一個前提,我們暫時忽略非同步的所有狀況,先假設一切操作都是同步的。了解完前提之後,再來複習一下要完成的需求吧,這需求總共有三個步驟:尋找帳戶、提款、更新帳戶。

fun withDrawMoney(accountId: String, amount: Int) {
    accountRepo.queryFromId(accountId)
        .??? { account: Account -> bankService.withDraw(account, amount) }
        .??? { account: Account -> accountRepo.updateAccount(account) }
        .fold(success = {...}, fail = {...})
}

withDrawMoney 這個函式中,可以很清楚的看到這三個步驟依序執行,最後再使用 fold 來處理結果,那中間的 operator 要填什麼呢?首先,第一個步驟回傳結果的型別是 Try ,第二個步驟 bankService.withDraw 的型別也是使用 Try 這個容器,如果直接用 map 的話,理所當然的可以想像出結果會是一個兩層容器:Try<Try<...>>。所以...大家應該想到了,沒錯!在這裡應該也要用 flatMap !

fun withDrawMoney(accountId: String, amount: Int) {
    accountRepo.queryFromId(accountId)
        .flatMap { account: Account -> bankService.withDraw(account, amount) }
        .flatMap { account: Account -> accountRepo.updateAccount(account) }
        .fold(success = {...}, fail = {...})
}

sealed class Try<T>{

    ...

    fun <R> flatMap(transform: (T) -> Try<R>): Try<R> {
			return when(this) {
          is Success -> transform(this.value)
          is Fail -> Fail<R>(this.error)
      }
    }
}

其實 flatMap 的實作非常簡單,最難的部分就是 function 的 input 型別跟 output 型別。首先,回傳的容器一定是 Try ,這無庸置疑。然而,我們要保留一點彈性,所以讓使用者自行決定回傳的型別,也就是 R。好,完成 output 的部分了,那 input 呢?我們要接收的是一個 function ,而且從原本的型別 T 轉換成型別 R ,但是由於這個型別 R 同時被一個容器給包起來了,所以我們才需要 flatten ,於是就導出來了,input 的 function type 應該是 (T) → Try。那實作內容呢?可以分兩個 case ,第一個是失敗,既然目前已經失敗了,那我們也不需要浪費時間去做計算,繼續將失敗的內容傳遞下去即可。第二個是成功,成功的話,那直接執行 transform 這個 function 不就好了嗎?執行的結果還剛好是 Try ,什麼都不用動。


歸納與整理

我們已經介紹過 Observable, List, Try 各自的 flatMap 了,如果把他放在一起可以觀察到一個有趣的現象:

// Observable (Java)
public final <R> Observable<R> flatMap(Function<? super T, ? extends ObservableSource<? extends R>> mapper) {
    return flatMap(mapper, false);
}

// List
public inline fun <T, R> Iterable<T>.flatMap(transform: (T) -> Iterable<R>): List<R> {
    return flatMapTo(ArrayList<R>(), transform)
}

// Try
fun <R> flatMap(transform: (T) -> Try<R>): Try<R> = {...}

全部都長的很像!輸入一樣都是一個從 T 到同一個容器 R 的 transform function,回傳的型別一樣都是同一個容器的 R ,所以對於任何一個 flatMap 我們都可以這樣寫:

fun <R> flatMap(transform: (T) -> F<R>): F<R> = {...}

上面的 F 可以替換成任何容器。現在,我們發現了重複的概念出現三次了, 根據 DRY (Don't Repeat Yourself) 原則,這些是不是可以共同抽取到某個抽象呢?答案是可以的,functional programming 最厲害的地方就是對這些概念進行抽象化,讓一切都以存粹的數學來表示,而這背後最基礎的數學理論 - Category theory,將會在下一篇介紹給大家。


上一篇
[RxJava] side effect operators and advanced operator
下一篇
Category theory
系列文
Functional Programming in Kotlin30

尚未有邦友留言

立即登入留言