iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 2
2

Function composition

回想以前在學校的時候,對於數學函式的第一印象就是一堆 f(x) 跟 g(x) 了,還有他們的組合:f(g(x))。這在數學中是很一個基本的觀念,對於 functional programming 來說呢也是如此,在這邊先預告一下,後面的章節中,這觀念將會不斷的出現,說是 functional programming 的核心觀念也不爲過,現在,讓我們從簡單的範例開始吧!

fun addOne(a: Int) = a + 1
fun timesTwo(a: Int) = a * 2

上面這兩個函式要怎麼組合在一起呢?相信很多人已經有答案了,就是這樣而已:

// (3*2) + 1
val result = addOne(timesTwo(3))
println(result)

輸入是 3 的話,會依序執行 timesTwo 以及 addOne ,結果將會是 (3*2) + 1 = 7 ,那如果換成 lambda 表示式呢?

val addOne = { x: Int -> x + 1}
val timesTwo = { x: Int -> x * 2}

result = addOne(timesTwo(3))
println(result)

看起來沒什麼不一樣,對吧?讓我們在回想一下以前學的數學,通常可以使用另外一個函式來代表兩個函式的組合:h(x) = f(g(x)) 。讓我們來試看看這怎麼在 Kotlin 中實現吧

// 非常直覺
fun addOne(a: Int) = a + 1
fun timesTwo(a: Int) = a * 2
fun composeFun(a: Int) = addOne(timesTwo(a))

// 糟了!這要怎麼組合?
val addOne = { x: Int -> x + 1}
val timesTwo = { x: Int -> x * 2}
val composeFun = addOne(timesTwo(x))   //x 從哪裡來?

對於我們熟悉的 function 來說,組合是很簡單的,但是 lambda 的組合就很難想像了!如果用一樣的方式組合,會出現 compile error !為了能完成 lambda 的 compose function,在這裡就需要介紹一個概念了:Lazy execution。

Lazy execution

註:這邊的觀念不是最正確的,下方有良哥哥的正確版本,這邊還是留著這版本來對比,搞不好大家也正在犯一樣的 錯誤。

之所以不知道怎麼實作,是因為沒有把 function 的兩個概念拆開,一個是定義與實作 function 的內容、另一個則是執行的時間點。在很多時候,一個 function 是不需要馬上被執行的,然而傳統的 function(非 lambda)有一個很大的限制,就是他只能馬上拿來執行,無法被當成變數傳來傳去,隨意的組合,也因為如此,在 Java 中定義一個 interface 來當作的 callback 的 pattern 才這麼常見,function 無法做到的事,由 class 來實現。

所以如果我們把執行分開討論的話,組合函式這件事就變的簡單多了。現在我們要定義的是一個 function ,而且會指派到一個變數中,我們需要什麼?需要的正是 lambda 表示式:

val composeFun: (Int) -> Int = { x: Int -> ??? }

先把 function type 寫出來也有助於思考,由於上面範例中的型別都是整數,所以組合出來的型別是 (Int) -> Int ,然後呢,就可以藉由 {} 來寫出 lambda 了。lambda 的外框寫出來之後,就是要填空了,裡面要填什麼呢?輸入是一個整數,所以可以先填入 x: Int -> 。疑?那 x 不就是我們要的輸入嗎?所以最後在裡面呼叫 addOnetimesTwo 即可。

val composeFun: (Int) -> Int = { x: Int -> addOne(timesTwo(x)) }

請注意這個 composeFun 本身還是 lazy 的,可以自行決定呼叫的時間點,也可以傳遞給任何人,這就是 lambda 的一個大好處。

Generalization

身為一個有潔癖的工程師,永遠都會想要讓程式碼的可重用性更高,更泛用。接下來的目標,就是建立一個可以組合任何函式的工具,像是下面這樣:

val anotherFun = addOne compose timesTwo

如果有了這個工具,我不就不用在每次需要時還要寫一個新的 lambda 嗎?那麼這是怎麼完成的呢?還記得上次介紹的 infix 嗎?這正是他派上用場的時候

// 也別忘了 extension function
infix fun ???.compose(anotherFun: ??): ?? 

好的,接下來的目標是填入這些問號,依上面的 fucntion 來說, addOnetimesTwo 的型態都是 (Int) → Int,那組合出來的型態呢?自然也是 (Int) → Int。

infix fun ((Int) -> Int).compose(anotherFun: (Int) -> Int): (Int) -> Int {
    return ???
} 

有了上面實作 composeFun 的經驗,這邊該要有一個直覺了,這也是一個 lazy execution function。所以什麼都不用想,先寫一個 lambda 再說,剩下的自然會想到要怎麼做。

infix fun ((Int) -> Int).compose(anotherFun: (Int) -> Int): (Int) -> Int {
    return { x: Int ->
        ??? 
    }
} 

有了輸入值 x 之後,就跟之前實作過的內容一樣了,直接在 lambda 呼叫 function 。跟剛剛不一樣的是,第一個呼叫的 function 是自己本身 this,第二個 function 是帶進來的參數 anotherFun,這樣 compose 使用起來的順序才會是對的。

infix fun ((Int) -> Int).compose(anotherFun: (Int) -> Int): (Int) -> Int {
    return { x: Int ->
        this(anotherFun(x))
    }
}

如果型態只能用 Int 的話也不能說是很好用,還可以把他弄的更泛用點,轉成泛型:

infix fun <T, Q, R> ((Q) -> R).compose(anotherFun: (T) -> Q): (T) -> R {
    return { x: T ->
        this(anotherFun(x))
    }
}

val composeFun = addOne compose timesTwo 
println(composeFun(4))      // 9

大功告成!這裏還有一個小小的問題,一般我們在閱讀程式碼時,都是從左邊看到右邊,但是這個 compose 卻得要我們從右邊看到左邊?如果一個不注意的話,還會以為 addOne 比 timesTwo 還先執行,所以結果會是 10 才對?

幸運的是,解決這問題也很簡單,我們把 function 反過來組合不就得了?依照慣例,這個 function 應該會叫做 pipe:

val composeFun = addOne pipe timesTwo pipe addOne
println(composeFun(4))      // (4 + 1) * 2 + 1 = 11

結語

今天小小嘗試了一下 function 的組合還有講解 lazy execution 所帶來的好處與特性,之後將會看到更多更多不一樣的組合,請拭目以待。現在有一個小挑戰給各位:請各位讀者嘗試著寫出 pipe 這個 funcion,將會在下一篇中提供解答,可以的話,不要看上面 compose 的實作,全部靠自己寫出來會讓你的印象更深刻!


上一篇
Function type and basic syntax
下一篇
Pure function and immutability
系列文
Functional Programming in Kotlin30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

2
良葛格
iT邦新手 2 級 ‧ 2020-09-15 08:47:00

沒有學過 Kotlin,不過就前一篇文看到的,Kotlin 中有一般函式與 lambda 函式的差別(類似 Java 中 method 與 lambda expression 的差別),而談 Function composition 的部份,感覺只是在談這個:

fun addOne(a: Int) = a + 1
fun timesTwo(a: Int) = a * 2
fun composeFun(a: Int) = addOne(timesTwo(a))

如何用 lambda 函式表達為:

val addOne = { x: Int -> x + 1}
val timesTwo = { x: Int -> x * 2}
val composeFun = (Int) -> Int = { x: Int -> addOne(timesTwo(x)) }

這基本上跟 lazy 與否沒有關係,只是 Kotlin 中 fun 定義的函式不是值,而 lambda 定義的函式是值,這部份應該是為了要對照 Java 的 method 與 lambda expression,只是 Kotlin(與 Java)在融入 FP 時不得已的做法。

如果要談 lazy 的觀念,應該像是底下(因為沒學過 Kotlin,我用 JavaScript 來示範):

function addOne(a) {
    return a + 1;
}

function timesTwo(a) {
    return a * 2;
}

const r1 = addOne(timesTwo(3)); // 馬上運算 timesTwo(3),沒有 lazy

function callfAndAddOne(f) {
    return f() + 1;
}

const r2 = callfAndAddOne(() => timesTwo(3));  // 延遲運算 timesTwo(3)

從這個出發,若要進一步談 compose 的 Generalization 的話,可以把 3 當成 callfAndAddOne 的參數代入:

function addOne(a) {
    return a + 1;
}

function timesTwo(a) {
    return a * 2;
}

function callfAndAddOne(f, x) {
    return f(x) + 1;
}

const r2 = callfAndAddOne(x => timesTwo(x), 3);  // 延遲運算 timesTwo
console.log(r2);

接著把每個函式都換為 lambda(就 FP 本身而言,這個動作不是必要的,因為 ES5 的函式不支援 partially applied,在天生支援 partially applied 的語言中,像是 Haskell,就不用這個動作):

function addOne(a) {
    return a + 1;
}

function timesTwo(a) {
    return a * 2;
}

const callfAndAddOne = f => x => f(x) + 1;

const r2 = callfAndAddOne(x => timesTwo(x))(3);  
console.log(r2);

實際上 callfAndAddOne 有點通用了,因為 f 可以自行指定,然後傳回新函式,將之改名一下:

function addOne(a) {
    return a + 1;
}

function timesTwo(a) {
    return a * 2;
}

const composefAndAddOne = f => x => f(x) + 1;  // 通用化 AddOne 與 f 的 compose

const timesTwoAndAddOne = composefAndAddOne(x => timesTwo(x));

const r2 = timesTwoAndAddOne(3);  
console.log(r2);

接下來可以進一步重構,令 f(x) + 1 也是可指定的…最後導出 compose 函式,也就是你這篇文件想要的結果。

感謝更正觀念,竟然釣出大大來看了,我會在文章中多一份註解,提示讀者這邊的觀念有誤,可以看下方的留言的正確版本(同時也會留錯誤的版本給大家看)

我要留言

立即登入留言