一个 Kotlin 开发,对于纯函数的思考
什么是纯函数?
纯函数是数学上的一种理想状态,即相同的输入永远会得到相同的输出,而且没有任何可观察的副作用
在数学上函数的定义为
- It must work for every possible input value
- And it has only one relationship for each input value
即每个在值域内的输入都能得到唯一的输出,它只可能是多对一而不是一对多的关系:
副作用
Wikipedia Side Effect: In computer science, an operation, function or expression is said to have a side effect if it modifies some state variable value(s) outside its local environment, which is to say if it has any observable effect other than its primary effect of returning a value to the invoker of the operation. Example side effects include modifying a non-local variable, modifying a static local variable, modifying a mutable argument passed by reference, performing I/O or calling other functions with side-effects. In the presence of side effects, a program's behaviour may depend on history; that is, the order of evaluation matters. Understanding and debugging a function with side effects requires knowledge about the context and its possible histories.
副作用的形式很多样,一切影响到外部状态、或依赖于外部状态的行为都可以称为副作用。副作用是必须的,因为程序总是不可避免的要与外界交互,如:
更改外部文件、数据库读写、用户 UI 交互、访问其他具有副作用的函数、修改外部变量
这些都可以被视为副作用,而在函数式编程中我们往往希望使副作用最小化,尽量避免副作用,对应的纯函数则是希望彻底消除副作用,因为副作用让纯函数变得不“纯”,只要一个函数还需要依赖外部状态,那么这个函数就无法始终保持同样的输入得到同样的输出。
好处是什么?
You wanted a banana but what you got was a gorilla holding the banana and the entire jungle. — Joe Armstrong, creator of Erlang progamming
- 可缓存性,由于唯一的输入代表唯一的输出,那么这意味着我们可以在输入不变的情况下直接返回运算过的结果。
- 高度并行,由于纯函数不依赖外部状态,因此即便在多线程情况下外部怎么变动,纯函数始终能够返回预期的值,纯函数能够达到真正的无锁编程,高度并发。
- 高度可测性,不需要依赖外部状态,传统的 OOP 测试我们都需要模拟一个真实的环境,比如在 Android 中将 Application 模拟出来,在执行完之后断言状态的改变。而纯函数只需要模拟输入,断言输入,这是如此的简单优雅。
- 依赖清晰,面相对象编程总需要你将整个环境初始化出来,然后函数再依赖这些状态修改状态,函数往往伴随着大量外部的隐式依赖,而纯函数只依赖输入参数,仅此而已,也仅提供返回值。
更进一步
传统大学老师教的都是 OOP,所以大多数人最开始也不会去学习纯函数的思路,但纯函数是完全不一样的一套编程思路,下面是一个纯函数中实现循环的例子,传统的循环往往是这样的:
int sum(int[] array) {
int sum = 0;
for (int i = 0; i < array.length; i++) {
sum += array[i];
}
return sum;
}
尽管大多数语言也会提供 for...in 之类的语法,如 kotlin:
fun sum(array: IntArray): Int {
var sum = 0
for (i in array) {
sum += i
}
return sum
}
但我们注意到,在上面的例子中均引入了两个可变的变量,sum 和 i,站在 sum += i 的视角,它的外部依赖:i 是一个外部的可变状态,因此这个函数并不“纯”。
但另一方面来说,从整体函数对外的视角来看,其还是很“纯”的,因为对于传入的外部 array,始终有唯一的 int 返回值,那么我们追求完全的“纯”,完全不使用可变量的目的是什么呢?
在纯函数下要实现完全消灭不可变变量,我们可以这么做:
tailrec fun sum(array: IntArray, current: Int = 0, index: Int = 0): Int {
if (index < 0 || index >= array.size) return current
return sum(array, current + array[index], index + 1)
}
我们编写了退出条件,当 index 在不正常的情况下会,意味着没有东西可以加,直接返回 current,即当前已经算好的值;其余情况则直接返回 current 与当前 index 的值的和,再加上 index + 1 之后所有值的 sum。这个例子已经很简单了,但函数式,递归思维不免会让学传统 OOP 的人需要多加思考一下。
当然作为一个 kotlin 开发,我也毫不犹豫的使用了 tailrec 这个 kotlin 语言特性来帮助优化尾递归,否则在遇到相当长的列表的时候,这个函数会抛出 StackOverFlowError。
函数一等公民
许多面向对象语言通常会用 this 显式访问对象的属性或方法,也有一些语言会省掉编写 this,事实上在许多语言编译器的背后实现中,通常也会将“对象成员的调用”变成“额外给成员函数添加一个 this 变量”。
可见发挥重要作用的其实是函数,不如更进一步,函数是一等公民,对象只不过是个结构体;如此,在纯函数中你完全用不到 this,甚至很多情况下都用不到对象。
所谓的一等公民,就是希望函数包含对象,而不是对象包含函数,甚至可以不需要对象(暴论),下面就是一个例子,是一个常见的业务诉求:
- UserService 接收用户 id,并提供两个函数来获取用户 token 和用户的本地储存
- ServerService 需要服务器 ip 和 port,提供通过 secret 获取 token 和通过用户 token 获取用户数据两个能力
- UserData 是一个用户数据类,它能够接收父布局参数来构建 UI 数据用于显示
class UserService(private val id: Int) {
fun userToken(password: String): UserToken = TODO()
fun localDb(dbPassword: String): LocalDb = TODO()
}
class ServerService(private val ip: String, private val port: Int) {
fun serverToken(serverSecret: String): ServerToken = TODO()
fun getUser(userToken: UserToken): UserData = TODO()
}
class UserData(
val name: String, val avatarUrl: String, val description: String,
) {
fun uiData(parentLayoutParameters: LayoutParameters): UIData = TODO()
}
那么这些变成函数式会怎么样呢?会像下面这样!
typealias UserTokenService = (password: String) -> UserToken
typealias LocalDbService = (dbPassword: String) -> LocalDb
typealias UserService = (id: Int) -> Pair<UserTokenService, LocalDbService>
typealias ServerTokenService = (serverSecret: String) -> ServerToken
typealias ServerUserService = (userToken: UserToken) -> UserDataAbilities
typealias ServerService = (ip: String, port: Int) -> Pair<ServerTokenService, ServerUserService>
typealias UserUIData = (parentLayoutParameters: LayoutParameters) -> UIData
typealias UserDataAbilities = UserUIData
val userService: UserService = { userId: Int ->
val tokenService: UserTokenService = { password: String -> TODO() }
val localDbService: LocalDbService = { dbPassword: String -> TODO() }
tokenService to localDbService
}
val serverService: ServerService = { ip: String, port: Int ->
val tokenService: ServerTokenService = { serverSecret: String -> TODO() }
val userService: ServerUserService = { userToken: String -> TODO() }
tokenService to userService
}
是不是看起来这些东西变得相当的复杂?但实际上真正的代码并没有写多少行,大量的代码都用来定义类型了!这也就是为什么你能看到的大多数展示函数式的例子都是用 js 去实现的,因为 js 的类型系统很弱,这样函数式写起来会很方便。
我这里用 kt 的范例则是写了大量的类型标记代码,因为我本人对显式声明类型有极高的要求,如果愿意,也可以完全将类型隐藏全靠编译器推理,就像下面这样,一切都变得简洁了,写起来和常规的 OOP 并没有太大区别。
val userService = { userId: Int ->
val tokenService = { password: String -> TODO() }
val localDbService = { dbPassword: String -> TODO() }
tokenService to localDbService
}
val serverService = { ip: String, port: Int ->
val tokenService = { serverSecret: String -> TODO() }
val userService = { userToken: String -> TODO() }
tokenService to userService
}
但不同的是,你看上面的代码,完全没有类/结构体的存在,因为变量的存储全部在函数体内储存了!
对于使用处,两种方式的用法事实上也大同小异,但可以看到我们彻底抛弃了类的存在!甚至在 kotlin 的未来版本中,如果这种代码始终在字节码中编译成 invokeDynamic,那么通过这种方式,字节码中甚至都可以避免类的存在!(当然,在 Android DEX 中会被脱糖成静态内部类)
// OOP
val userService = UserService(id = 0)
val serverService = ServerService(ip = "0.0.0.0", port = 114514)
val userToken = userService.userToken(password = "undefined")
val userData = serverService.getUser(userToken)
val uiData = userData.uiData(parentLayoutParameters)
// functional
val (userTokenService, _) = userService(0)
val (_, userDataService) = serverService("0.0.0.0", 114514)
val userToken = userTokenService("undefined")
val userData = userDataService(userToken)
val uiData = userData(parentLayoutParameters)
BTW,这里函数式 argument 没加 name 主要 kt 现在不支持。。。
柯里化
在上面的例子中,其实我们也能看到,类的存在是不必须的,类的本质其实只是预设好了一部分参数的函数,柯里化要解决的问题就是如何更轻松的实现“预设一部分参数”这样的能力。将一个函数柯里化后,允许多参数函数通过多次来进行传入,如 foo(a, b, c, d, e)
能够变成 foo(a, b)(c)(d, e)
这样的连续函数调用
在下面的例子中,我将举一个计算重量的范例:
fun weight(t: Int, kg: Int, g: Int): Int {
return t * 1000_000 + kg * 1000 + g
}
将其柯里化之后:
val weight = { t: Int ->
{ kg: Int ->
{ g: Int ->
t * 1000_000 + kg * 1000 + g
}
}
}
使用处:
// origin
weight(2, 3, 4)
// currying
weight(2)(3)(4)
在这里我们能发现,柯里化其实让实现处变复杂了,不过在 js 中通常会通过 bind 来实现,kt 也有民间大神 github 开源的柯里化库,使用这些能够从一定程度上降低编写柯里化代码的复杂度。
让我们看看 skiplang 语言吧
Skiplang 的宗旨就在其网站主页,A programming language to skip the things you have already computed,在纯函数的情况下,意味着得知输入状态,那么输出状态就是唯一确定的,这种情况就非常适合做缓存,如果输入值已经计算过,那么直接可以返回缓存的输出值。
在纯函数的情况下,意味着运算可以做到高度并行,在 skiplang 中,多个异步线程之间不允许共享可变变量,自然也不会出现异步锁等东西,从而保证了异步的绝对安全。
个人思考
纯函数的收益非常诱人,但开发者往往不喜欢使用纯函数,一些常见的原因可能是:
- 对性能的担忧:纯函数不允许修改变量,只允许通过 copy 等方式,创建了大量的变量;编译器需要进行激进的尾递归优化。
- 开发者意识淡薄:大多数学校出身的开发者只会用老师教的那一套 OOP,想培养 OOP 向函数式的转变,通常会让很多开发者感到困难,从而认为传统 OOP 简单,也是主流,没必要学新的。
尽管我对纯函数也非常的心动,但是我不是激进的纯函数派,我在日常工作中对其部分认同,具体到 kotlin 编程中,我通常坚持的理念是:
- 可以使用类,也可以在类中定义函数,但不允许使用可变成员。
- 可以使用可变的 local variable(但不推荐),但不允许在多线程之间共享
- 同种副作用,单一数据源。
参考
个人主页原文:一个 Kotlin 开发,对于纯函数的思考
来源:juejin.cn/post/7321049383571046409