上周我已经给大家推送了一篇关于高阶函数的文章,这一期,我们继续探讨一些相关的有意思的话题。

1 复合函数

大家一定见过下面的数学题吧:

\[
求 f(g(x)) 的值。
解:
设 m(x) = f(g(x))

\]
m 就是 f 和 g 的复合。

我们在 Kotlin 当中要如何对函数进行复合呢?

1
2
val add5 = { i: Int -> i + 5 }
val multiplyBy2 = { i: Int -> i * 2 }

我们定义了这么两个函数,接着这么调用它:

1
println(multiplyBy2(add5(2))) // (2 + 5) * 2

add5 相当于我们的 g(x),multiplyBy2 相当于 f(x),那么上面的式子就相当于 f(g(x))。下面我们提供一个简单的方式来复合这两个函数,得到 m(x) = f(g(x)):

1
2
3
4
5
6
// f andThen g -> g(f(x))
infix fun <P1, P2, R> Function1<P1, P2>.andThen(function: Function1<P2, R>): Function1<P1, R> {
return fun(p1: P1): R{
return function.invoke(this.invoke(p1))
}
}

这里面有几个知识点,我请大家一起复习一下。

第一个就是 infix,中缀表达式,有了这个关键字,我们的 add5 在调用 andThen 方法时,就不需要用 .andThen() 的形式了,而是像使用操作符一样。

第二个是扩展方法,andThen 其实就是 Function1 的扩展方法。

第三个则是 Lambda 表达式的类型了,我们在前面提到过,Lambda 表达式有 N (N <= 22)个参数,那么它的类型就是 FunctionN,这里的 add5 只有一个参数,所以对应于 Function1 类型。

第四个是匿名函数,这个我们前面其实已经见到不少了。

我们看一个例子:

1
2
val add5AndMultiplyBy2 = add5 andThen multiplyBy2
println(add5AndMultiplyBy2(2))

这个例子的输出结果其实与前面的相同, ( 2 + 5 ) * 2 = 14

通过 andThen,我们看到一个全新的函数 add5AndMultiplyBy2 被创造出来,它其实就是 add5 和 multiplyBy2 的复合。

当然,有时候我们其实还需要这样的结果:

1
2
val multiplyBy2AndAdd5 = add5 compose multiplyBy2
println(multiplyBy2AndAdd5(2))

这个相当于 2 * 2 + 5 = 9。 我们简单看下 compose 的实现:

1
2
3
4
5
6
// f compose g -> f(g(x))
infix fun <P1, P2, R> Function1<P2, R>.compose(function: Function1<P1, P2>): Function1<P1, R> {
return fun(p1: P1): R{
return this.invoke(function.invoke(p1))
}
}

compose 与 andThen 的结果是完全相反的,

1
f andThen g -> g(f(x))

1
f compose g -> f(g(x))

这就是函数复合,其实你在初中数学就学过这些东西了。

2 Currying

Curry 也有咖喱的意思,不过这一节可并不是充满咖喱味的。在函数式编程当中,Currying 也经常被翻译成“科里化”,我们从这个名字完全读不出它究竟是要干啥。为什么?因为,Curry 是个人名——Haskell Curry。

回到我们的程序当中,我们首先必须要搞清楚什么是 Currying?Currying 其实就是由一个多参数的函数构造出一系列只有一个参数的函数的方法。这么说可能还是有些抽象,我们直接上例子:

\[
f(x,y,z) = x * y - z
\]

我们有一个三元函数,这个没什么复杂的,你在高中数学当中见到过比这个恐怖的多的式子。接着我们给它做个变式:

\[
f(x,y,z) = k_{yz}(x)
\]

其中,\(k_{yz}(x)\) 是关于 \(x\) 的一个函数,\(yz\) 可当做常量看待。而一旦传入 \(x\) 的值以后,例如 \(k_{yz}(x_0)\) ,那么此时又有变换:

\[
f(x_0,y,z) = k_{yz}(x_0) = m_{z,x=x_0}(y)
\]

类似的,我们还能最终变换成:
\[
f(x_0,y_0,z) = m_{z,x=x_0}(y_0) = n_{x=x_0, y=y_0}(z)
\]

这么一个数学概念,其实就是 Currying。那么它到底想说明怎样一件事情呢?大家看,参数是一个一个传进来的,这就好比我们完成一件事情,也是对其进行肢解,然后一步一步完成的,通过 Currying,我们可以对一个函数的调用细节进行仔细的考量,甚至像流水线一样处理,以实现我们的目标。

用程序的语言描述,假设我们有一个函数:

1
2
3
fun hello(x: String, y: Int, z: Double): Boolean{
...
}

它 Currying 的结果便是:

1
2
3
fun curriedHello(x: String): (y: Int) ->(z: Double)-> Boolean{
...
}

下面我们给出一个 Kotlin 的例子:

1
2
3
fun log(tag: String, target: OutputStream, message: Any?){
...
}

这是一个日志打印的函数,第一个参数 tag 是一个日志的标识,第二个参数是日志的内容,第三个参数是日志打印的目标,这个可以是控制台,也可以是文件,由调用者指定。

显然,我们通常调试时,输出日志都是直接到控制台的,于是我们定义一个新函数:

1
fun consoleLog(tag: String, message: Any?) = log(tag, System.out, message)

由于我们可能针对某一个问题不断地调试,这些日志的 tag 也是相同的,那么我们又会定义一个新函数:

1
2
3
val TAG = ...
...
fun consoleLogWithTag(message: Any?) = log(TAG, System.out, message)

这样看上去似乎没什么问题,不过你有可能会想,我不过是临时打几行日志,真的有必要定义这么多函数?调试一段代码还好,调试的内容多了呢,而且他们的 tag 都还不一样,难道我要定义 consoleLogWithTag2 、consoleLogWithTag3 … 么?

显然,如果你运用 Currying,问题就简单的多了,只不过是定义一个局部变量嘛:

1
2
3
4
5
val consoleLogWithTag = (::log.curried())(TAG)(System.out)
...
//打印日志
consoleLogWithTag("This may be an error to call here.")

其中 log.curried() 这个方法的签名如下:

1
2
3
fun <P1, P2, P3, R> Function3<P1, P2, P3, R>.curried(): (p1: P1)->(p2: P2)->(p3: P3)-> R{
...
}

注意,由于 log 是函数名,因此我们在获取其对应的函数引用时需要加 ::。

好,说到这里,你可能直接去试前面的代码,然后垂头丧气的告诉我,说我这代码是骗人的,根本不能跑。为啥呢?因为根本没有 curried() 这个方法啊。

对啊,非常遗憾,截止到 1.1RC 版,我们也没有看到这样的 API 出现在标准库当中,所以我们只好自己搞咯:

1
2
fun <P1, P2, P3, R> Function3<P1, P2, P3, R>.curried()
= fun(p1: P1) = fun(p2: P2) = fun(p3: P3) = this(p1, p2, p3)

当然,这只是 Function3 的 curried() 实现版本,Kotlin 有 0-22 个 Function 版本,因此我们如果需要使用 Currying 这一特性的话,针对每一个版本都实现一个 curried 方法即可。

当然有 curried,就会有 uncurried,二者是完全相反的过程,我就不多讲了,大家可以自己尝试着实现一下。

3 偏函数

我们再来看一下上一节这个打日志的例子,对于有三个参数的 log 函数,我们在极大多数的使用场景下都对前两个参数传入了相同的值:

1
2
3
4
5
6
7
fun log(tag: String, target: OutputStream, message: Any?){
...
}
...
val consoleLogWithTag = (::log.curried())(TAG)(System.out)

其实,对一个多参数的函数,通过指定其中的一部分参数后得到的仍然是一个函数,那么这个函数就是原函数的一个偏函数了。从这个意义上来讲,consoleLogWithTag 也可以认为是 log 的一个偏函数。

显然,偏函数与 Currying 有一些内在的联系,如果我们需要构造的偏函数的参数恰好处于原函数参数的最前面,那么我们是可以使用 Currying 的方法获得这一偏函数的;当然,如果我们希望得到任意位置的参数被指定后的偏函数,那么我们就有足够的理由使用一些更好的方法。

例如:

1
2
3
4
5
6
7
8
9
10
val makeString = fun(byteArray: ByteArray, charset: Charset): String{
return String(byteArray, charset)
}
...
val makeStringFromGbkBytes = makeString.partial2(charset("GBK"))
//实际当中这个字节流可以是文件流,也可以是网络数据等等
val gbkByteArray = ...
println(makeStringFromGbkBytes(gbkByteArray))

对于第二个参数 Charset,我们在国内有不少公司仍在用 GBK 编码,那么在开发的过程中,我们就没有必要每次都指定 GBK 这个编码选项了,下面这一句代码返回了一个 makeString 的偏函数,这个函数第二个参数确定为 charset(“GBK”)。

1
val makeStringFromGbkBytes = makeString.partial2(charset("GBK"))

接下来,同样我们需要给出 partial2 的实现:

1
2
fun <P1, P2, R> Function2<P1, P2, R>.partial1(p1: P1) = fun(p2: P2) = this(p1, p2)
fun <P1, P2, R> Function2<P1, P2, R>.partial2(p2: P2) = fun(p1: P1) = this(p1, p2)

我们看到,我们为 Function2 实现了两个扩展方法 partial1 和 partial2,这两个方法分别用来生成两个参数分别被指定后的偏函数。

目前 Kotlin 标准库尚且没有对此提供支持,如果需要得到 FunctionN (N > 1) 的偏函数,那么我们需要把他们对应的 partialN 依次实现。

需要注意的是,makeString 是一个函数引用,可以直接用于调用函数的方法,这与上一节当中的 ::log 本质上是一样的,只是二者的定义方式不同,希望大家不要感到困惑。

1
2
3
4
5
6
// log 是函数名
fun log(tag: String, target: OutputStream, message: Any?){
...
}
...
val consoleLogWithTag = (::log.curried())(TAG)(System.out)

本文主要给大家介绍了如何基于 Kotlin 的现有标准库来实现一些函数式编程的特性,其实这些特性已经在 Github 的 funKTionale 当中给出,本文的内容也更多的是在向它致敬。