作为一个使用了很多年 Java 的开发者☕️,面向对象思想早已深入人心,但 Kotlin 语言本身是天然支持函数式编程的,如果你对此没什么感觉,那么请多用 Compose 。
但是 Kotlin 的函数式编程思想并不是仅仅可以在 Compose 上发挥作用,我们在任何地方都可以使用函数式编程来解决问题。相对于面向对象,函数式在很多时候都具有独特的优势,我们可以灵活的在合适的地方用合适的方式来编写代码。
倒不是说面向对象不好,面向对象是用来解决特定问题的,它可以很好的对事物做抽象和建模,但我们也不能陷入面向对象的条条框框之中。
在 Android 开发中的函数式也有一些特有的问题需要解决,本文主要就是来介绍 Kotlin 函数式编程以及 Android 开发中的函数式编程。
函数式最早可以追溯到一百多年前,数学家希尔伯特提出了著名的希尔伯特计划:希望建立一种严格的数学基础,保证所有数学问题都能通过形式化方法解决。其中的关键问题之一就是可判定性问题(Entscheidungsproblem)。 这个问题在 1936 年被两个人同时解决,其中一个人是图灵,另一个人就是阿隆佐·邱奇。
邱奇在 1930 年代开发的 λ 演算,是建造自函数应用的一种计算形式系统。在 1937 年,艾伦·图灵证明了 λ 演算和图灵机是等价的计算模型,展示了 λ 演算是图灵完备性的。λ 演算形成了所有函数式编程语言的基础。
Kotlin 并不像 Haskell 一样是纯函数式编程语言,而是混合式的,其语法层面上是都支持的,我们可以灵活的切换编程方式,扬长避短。
函数式编程的概念
函数式编程是一种编程范式,将函数作为一等公民(first-class citizen),避免使用程序的状态和定义对象及变量,函数可以作为参数也可以作为返回值,程序的运行则是利用若干简单的函数计算逐级推导至复杂运算,最终完成计算过程,返回计算结果。
顶级函数
在 Kotlin 中我们会有个顶级函数的概念,也就是在一个 kt 文件中直接声明函数,而不是将函数声明在类的内部。这意味着我们可以直接把函数当作一段逻辑计算的入口,不需要通过类创建对象这样的方式,这也是函数式中的重要概念,也可以说是一个支持函数式编程语言的必备特性。
依赖注入怎么办?
如果都使用函数,那么就要解决依赖注入的问题,不过我知道的有些依赖注入框架是支持函数注入的,你可以直接在函数中加上参数,然后依赖注入框架会直接帮你注入。
kotlin
typealias myFunction = () -> Unit
@Inject
fun myFunction(dep: Dep) {
}
当然我们的目的并不是打造一个纯函数式编程程序,既然是混合,那么类是完全可以接受的,我们仍然可以在类中使用依赖注入。
不过 Kotlin 似乎也考虑到了这一点,所以提供了 invoke 运算符重载,用过 UseCase 的人都知道这是怎么回事。
kotlin
class UpdateActivityPubUserListUseCase @Inject constructor(
private val contentRepo: FreadContentRepo,
) {
suspend operator fun invoke(
content: ActivityPubContent,
allUserCreatedList: List<ListTimeline>
) {
// do something
}
}
看起来就是个普通的类,可以通过依赖注入在构造器内注入参数,区别在于使用了 operator
修饰符,重点是使用的时候可以像一个函数一样使用。
jsx
class XXXViewModel @Inject constructor(
private val updateActivityPubUserList: UpdateActivityPubUserListUseCase,
): ViewModel(){
init {
viewModelScope.launch {
// It's actually calling the invoke method.
updateActivityPubUserList()
}
}
}
函数类型
函数作为一等公民的意思是函数可以作为一种变量的类型而存在,它并不会因为是函数而有什么特别之处,函数可以作为变量存储、作为参数传递、作为返回值。
jsx
val add: (Int, Int) -> Int = { x, y -> x + y }
println(add(2, 3)) // 5
函数也可以接收其他函数作为参数。
jsx
fun operate(x: Int, y: Int, f: (Int, Int) -> Int): Int = f(x, y)
println(operate(2, 3, { a, b -> a * b })) // 6
总之在 Kotlin 中使用函数非常灵活,虽然 Kotlin for JVM 也会把函数编译成类对象,但这毕竟是 JVM 的限制,而 Kotlin 本身是跨平台的,只要在源码层面可以将函数视为一等公民那就没什么问题了。
纯函数与幂等函数
幂等函数和纯函数都是函数式编程中的重要概念。幂等函数是指同样的输入多次执行结果保持不变。纯函数是指输入相同的参数输出的结果必然保持一致,并且没有副作用。
一个干净整洁的函数对于函数式编程来说非常重要,因为在函数式中函数的定义是一段特定的逻辑计算,它只接受函数的入参作为计算的依赖项,然后返回计算结果,通过连续地函数调用来完成整个复杂任务。除此之外不应该有任何其他的操作。既不应该依赖外部的状态,也不应该修改外部的状态。
副作用(Side Effect)
写过 Compose 或者 React 的开发者应该对副作用的概念很清楚,在声明式 UI 框架中,UI 部分一般都是通过幂等函数来实现,但实际上我们总有一些 UI 之外的事情要做,或者我们总希望能获取到某些 UI 的信息或者时机,但是作为幂等函数我们是不能修改外部变量的,因此提供了专门的 API 来管理副作用。
也就是说,副作用可以理解为幂等函数运行过程中的一些额外的影响,为了保持幂等性,我们需要通过副作用 API 将本次运行函数的影响暴露出来。
kotlin
@Composable
fun UserDetailPage(viewModel: UserDeailViewModel){
LaunchedEffect(Unit){
viewModel.onPageResume()
}
Scaffold {
// ...
}
}
不过,严格来说,Composable 其实并不能算是纯函数或者幂等函数,他们只是在设计上尽可能按照这个方向来设计,思想上保持一定程度的一致性。因为,首先将 UI 绘制到屏幕上本身就是一种副作用,所以 composable 的目的本来就是为了产生副作用。其次,composable 并没有返回值,不存在相同的输入就有相同的输出,可以说绘制 UI 就是 composable 的输出。只不过我们先了解了函数式编程后再来理解 composable 就容易多了。
不可变性(Immutability)
函数式编程中的 不可变性 (Immutability) 指的是:一旦数据(属性、对象、集合)被创建,就不能被修改。如果数据发生变化那么应该创建一个新的数据,而不是在原值上改动。这样保证了函数调用之间不会"互相污染",也避免了一些隐藏的副作用。
这样我们很自然的就想到了 Kotlin 中的 val
和 data class
,通过 val 声明的属性是不可变的,一般来说 data clas 也是不可变的,Kotlin 虽然没有严格的要求,但是强烈推荐 data class 中的所有属性都必须使用 val 声明,否则会出现一些奇怪的问题。
并且 data class 提供了 copy
方法用于复制一个对象副本,在复制的时候可以修改其中的某些属性,这也符合函数式编程中的不可变性。
此外 Kotlin 中的集合默认也都是不可变集合,像 *arrayOf*()
, *listOf*()
, *mapOf*()
这种方式创建的集合都是不可变集合,考虑到方便的创建不可变集合,Kotlin 还贴心的提供了 buildList{ }
这样的函数防止我们为了省事创建了可变集合。
所以我现在在开发过程中已经很少使用变量了,如果看到某个地方声明了一堆 var
真的会引起生理不适🤮。这样的好处就是心智负担很低,不用担心这么多变量组合出千变万化的状态,整个程序逻辑变得异常清晰。
毕竟,多个变量共同作用组合出的状态可以说是指数级增加的,整个系统也会变的更加难以理解难以测试,实际上函数式思想就是将软件变成对数据的计算过程,如果合理的设计函数,那么可读性也会大大提高。
当然了,我们开发软件大部分使用的仍然是面向对象,函数式还是作为面向对象的补充,并且其中的一些思想完全可以借鉴,比如我们虽然还是要创建一个类,但是其中的属性可以尽量不可变,也不必排斥顶级函数,这不是什么语法糖🍬,这是函数式编程的体现。不用坚持某些教条主义,在实践中灵活的改变编程范式或许能让代码更优雅更简洁。