回想以前在學校的時候,對於數學函式的第一印象就是一堆 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。
註:這邊的觀念不是最正確的,下方有良哥哥的正確版本,這邊還是留著這版本來對比,搞不好大家也正在犯一樣的 錯誤。
之所以不知道怎麼實作,是因為沒有把 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 不就是我們要的輸入嗎?所以最後在裡面呼叫 addOne
與 timesTwo
即可。
val composeFun: (Int) -> Int = { x: Int -> addOne(timesTwo(x)) }
請注意這個 composeFun 本身還是 lazy 的,可以自行決定呼叫的時間點,也可以傳遞給任何人,這就是 lambda 的一個大好處。
身為一個有潔癖的工程師,永遠都會想要讓程式碼的可重用性更高,更泛用。接下來的目標,就是建立一個可以組合任何函式的工具,像是下面這樣:
val anotherFun = addOne compose timesTwo
如果有了這個工具,我不就不用在每次需要時還要寫一個新的 lambda 嗎?那麼這是怎麼完成的呢?還記得上次介紹的 infix 嗎?這正是他派上用場的時候
// 也別忘了 extension function
infix fun ???.compose(anotherFun: ??): ??
好的,接下來的目標是填入這些問號,依上面的 fucntion 來說, addOne
跟 timesTwo
的型態都是 (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 的實作,全部靠自己寫出來會讓你的印象更深刻!
沒有學過 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 函式,也就是你這篇文件想要的結果。
感謝更正觀念,竟然釣出大大來看了,我會在文章中多一份註解,提示讀者這邊的觀念有誤,可以看下方的留言的正確版本(同時也會留錯誤的版本給大家看)