前言
函数式编程是最近兴起的一种编程方式,很多语言都支持函数式编程,例如:JavaScript、Kotlin、C#等等。和面向对象编程不同,函数式编程有一种独属于数学的美感。纯函数式编程就像一个数学公式,传入特定的入参,即可获得确定的结果。这会给编程者一种让人心安的掌控感,这种掌控感能大大减少编码过程中的错误。
纯函数
举个很简单的例子,我们创建一个求矩形面积的函数,输入宽高,得到其面积。
fun calculateRectangleArea(width: Int, height: Int): Int {
return width * height
}
calculateRectangleArea(16, 12) // 192
calculateRectangleArea(2, 4) // 8
这是一个纯函数,结果只由入参和里面的转换逻辑组成,不与计算环境亦或者计算时机相关。
在面向对象编程中,我们在编写函数逻辑的时候往往可能要用到对象中的变量,而这样的函数就并不是纯函数。举个例子,我们往这个计算函数中引入一个深度变量,用于计算长方体的体积。而这个深度变量一秒钟会变化一次。
class CuboidCalculator {
private var depth: Int = 12 // here
fun calculateVolume(width: Int, height: Int): Int {
return width * height * depth
}
init {
thread {
while(true) {
depth = Random.nextInt(0, 20)
Thread.sleep(1000)
}
}
}
}
只要函数的逻辑可能会被参数以外的因素影响,它就不是一个纯函数,而影响因子也被称作Side Effect。
Side Effect
Side Effect有两种,外界影响,和影响外界,刚刚介绍的就是被外界影响,还有一种是影响外界。
private var depth: Int = 12
fun calculateVolume(width: Int, height: Int): Int {
val volume = width * height * depth
depth = volume % 10
return volume
}
不是说函数式编程就不能引入Side Effect,相反引入Side Effect 是一件不可避免的事情,计算机运行需要管理一大块内存,并在内存中做存储释放等操作,这本质上也是一种Side Effect。因此无需泄气,在函数式编程中,我们可以尽量减少Side Effect的引入,以尽量确保函数的确定感。
如何减少Side Effect的引入?就是尽量将可变的元素全部放在入参中和结果中,将逻辑变得可预测,这样也有一个好处,减少无用的临时变量存储。
fun calculateVolume(width: Int, height: Int, depth: Int): Int {
return width * height * depth
}
var depth = 12
fun main() {
val width = 2
val height = 4
val volume = calculateVolume(width, height, depth)
println(volume.toString())
depth = volume % 10
}
如上代码所示,我们将代码中引入的Side Effect变量通过参数传递进去,并将这个函数就变成了一个纯函数,并在外边去处理Side Effect。
以上是一个非常简单的例子,来说明函数式编程是怎样的,并介绍了纯函数与普通函数的最大的区别,即Side Effect,以下我们就开启一场函数式编程之旅。
函数式编程,启动!
函数式编程就像一条条封闭的管道,其几乎不被外界影响,它的路径是由一系列管道组成。
假设我们有一个用户列表,我们的需求是查找特定userId
的用户并返回,我们可以编写出以下代码。
fun List<User>.findUserWithUserId(userId: String): User? {
return findUserWithUserId(userId, 0)
}
private fun List<User>.findUserWithUserId(userId: String, index: Int): User? {
return if (index > lastIndex) null else {
val user = get(index)
if (user.id == userId) {
user
} else {
findUserWithUserId(userId, index + 1)
}
}
}
代码很好理解,递归查找id等于userId
参数的User
,若找不到就返回null
。这个函数会返回两种结果,因此它的示意图如下所示:
它只会选择一条道路去执行,而这条道路与传入的参数相关,也就是说从一开始调用这个函数的时候,它的结果是确定的,只会得到这一个结果,而中间就只有枯燥的运算过程罢了。
现在我们对该代码进行一些修改,我们可以对该函数进行“抽象”,使它变得更通用一些,这个函数由两个关键的部分组成,分别是输入参数和控制条件。
- 输入参数为
User
列表和需要查找的userId
。 - 控制条件为遍历获取每一个
User
,并判断其id
是否与输入参数的userId
相等。
fun List<User>.findUserWithUserId(userId: String, index: Int = 0): User? {
return if (index > lastIndex) null else {
val user = get(index)
if (user.id == userId) {
user
} else {
findUserWithUserId(userId, index + 1)
}
}
}
还记得刚刚我们将函数中的Side Effect抽离出逻辑放到了参数吗?这里我们也可以这么做,我们可以将“控制条件”放到参数中,这样的参数为函数类型。
fun List<User>.findUserWithCondition(condition: (User) -> Boolean): User? {
return findUserWithCondition(condition, 0)
}
fun List<User>.findUserWithCondition(
condition: (User) -> Boolean, index: Int = 0
): User? {
return if (index > lastIndex) null else {
val user = get(index)
if (condition(user)) {
user
} else {
findUserWithCondition(condition, index + 1)
}
}
}
于是我们得到了一个需要控制条件的查找函数,我们需要查找一个User
,但是这个User
长什么样子由调用函数者来定。如果我们想要查找一个id
为 123 的用户,我们可以这么写:
fun main() {
val users = ...
val user: User? = users.findUserWithCondition(::condition)
println(user.toString())
}
private fun condition(user: User): Boolean {
return user.id == "123"
}
我们创建了一个条件,叫condition
,并将该条件传入findUserWithCondition
,后者在每次遍历User
之后都会调用condition
函数获取是否符合条件,若符合条件即返回对应User
。其示意图如下所示:
我们从另一个函数管道去引入一个Boolean
值,两者结合得出结果。
到这一步之后大家可能会觉得它好像变灵活了,但是又没那么灵活。这是因为上面代码中userId
是hard code在condition
函数中,每次创建条件都好像不是很方便,这个时候我们需要引入一个概念,名为Currying,它是一种将多参数函数转换为单参数函数的过程。
一个匿名函数可以获取到创建该函数的作用域的内容。我们可以运用该特性写出如下代码,我们可以传入参数去创造条件。
fun main() {
val users = ...
val user = users.findUserWithCondition(conditionWithUserId("123"))
println(user.toString())
}
private fun conditionWithUserId(userId: String): (User) -> Boolean {
return { user: User ->
user.id == userId
}
}
在conditionWithUserId
中我们可以动态传入userId
去创造一个新的条件,来用于在递归中去执行。相信大家看出来了,我们返回的是一个函数类型,它可以lambda的方式来创建,lambda的具体工作原理此处不细究,在lambda中我们可以获取函数外部的userId
并直接使用。
既然有lambda这么方便的存在,我们也无需多此一举,我们直接在需要条件的地方传入lambda,在lambda中去动态创建条件,这便是该函数的最终形态。
fun main() {
val users = ...
val user = users.findUserWithCondition { it.id == "123" }
println(user.toString())
}
熟悉基础库的扩展函数的小伙伴看到这里可能会发现,这个不就是官方的函数find
吗?是的,它和find
函数的原理基本一样。不仅find
,官方也提供了很多类似的扩展函数,例如map
,filter
,reduce
等等。而利用这些函数,我们可以很轻松地构建数据管道。
fun fire(staff: Staff) = TODO()
staff.asSequence()
.filter { it.age > 35 && it.job == Job.Programmer }
.onEach(::fire)
.map(Staff::name)
.forEach { println(it) }
例如上面的命令的作用是找到35岁以上的程序员,并解雇,解雇完并打印他们的名字。本来很复杂的操作,使用类管道函数,可读性就变得非常高。
asSequence
这个函数很重要,它可以存下后续的所有操作并在一个循环中执行完成,避免多次无用的循环。
以上只是一个简单的例子,数据管道是函数式编程的一种很基础的形式,函数式编程还拥有着无限的可能。
声明式UI
声明式UI是函数式编程的集大成之作,它引入了State的概念。它使得函数自身可以维护部分状态并使UI随着State变换。由于State
是一种在函数栈中的属性,函数外难以顺利获取,具备了非常高的隐蔽性,因此声明式UI的可维护性和稳定性是非常高的。在编写声明式UI时会有非常强的掌控感,输入参数决定了UI展示,结果是确定且唯一。
React和Compose是经典的纯函数式的声明式UI,他们在实现上利用了函数式编程的确定性,通过不同的控制条件去选择怎样的分支,在内部生成一颗类似于上图中错综复杂的树。
声明式UI主要有以下思想。
-
数据下沉,事件提升。
函数式编程中,当我们编写的函数需要获取信息时,只能通过参数去获取,参数是一步一步往下传递的,这个传递的过程被称为数据下沉。
在函数的内部执行中可能会触发某些事件,放在声明式UI中大多数为用户输入触发。而触发事件的响应也需要通过参数获取,而这个参数就是一个函数,它是底层调用栈给顶层传递数据,相当于把事件抛出去,而这个过程被称为事件提升。
-
重组
我们可以在下层传递出来的事件lambda中获取到创建lambda作用域中的属性甚至修改。
如果UI State在这个过程中被修改,以该
State
为参数或者为控制条件的作用域就会发生重组,简单来说就是用新的State再次调用该函数,而这份重组为函数式编程赋予了构建视图并相应操作的能力。@Composable fun ShowHelloAndWorld() { var text by remember { mutableStateOf("Hello") } Button(onClick = { text = "World" }) { Text (text) } }
造成UI State变更的不只是用户触发,还有可能是数据下层导致亦或者Side Effect。
-
Side Effect
Side Effect在声明式UI中是一门比较深的学问。虽然我们尽量避免在函数式编程中引入Side Effect,但是在声明式UI中加入一点点Side Effect会使编程更加顺利和更具创造性,例如某些动效也是Side Effect的一种。
小技巧
文章接近尾声,此处分享一些我在日常开发中碰到的有意思的小技巧。
构造函数也是函数
在刚刚的例子中,我们解雇了35岁以上的程序员,他们的身份从职员变成了独一无二的人生的掌控者。
class Master(val name: String) {
constructor(staff: Staff) : this(staff.name)
}
val masters: List<Master> = staff.asSequence()
.filter { it.age > 35 && it.job == Job.Programmer }
.onEach(::free)
.map(::Master)
.toList()
我们可以直接将构造函数作为一个Lambda传入到map函数中获得一个Master
列表。
与面向对象结合
若患上了纯函数洁癖,把一切的属性都通过参数注入,这会导致参数数量非常多。
suspend fun Client.call(
header: String,
body: String,
token: String,
cookie: Cookie
): Response = TODO()
我们可以把参数都封装到一个对象中,以后需要增减参数,只需要修改Request即可,无需修改调用call的地方。
data class Request(
var header: String,
var body: String,
var token: String,
var cookie: Cookie
)
suspend fun Client.call(
request: Request
): Response = TODO()
需要注意的是,在函数式编程中如果需要以对象作为参数,我们需要尽可能保证该对象中的参数不可变。否则不符合函数式编程的初衷了。
若你能够保证在调用之后不会动态改变参数的内容时,借助函数对象,我们甚至无需自己去构造Requset,我们只需要传入一个构造者即可,而这种写法也叫做DSL。
// 使用 var 声明变量
data class Request(
var header: String = "",
var body: String = "",
var token: String = "",
var cookie: Cookie = ""
)
suspend fun Client.call(
builder: Request.() -> Unit
): Response {
val request = Request().apply(builder).copy()
return TODO()
}
val response = client.call {
// 构造内容
header = "..."
}
注意需要copy一份,防止外部调用函数之后继续修改实例中的内容。
总结
编程不是黑白分明的,函数式编程不一定需要完全舍弃面向对象,面向对象不一定容不下函数式编程。
我们在编程中如果偶尔想起纯函数,并编写几个稳定性、维护性极强的纯函数,由于它单元测试非常方便,顺手再多写几个单元测试,这对于整个项目来说是一个非常美妙的东西。
它可以是主导,也可以是点缀,它的艺术性无可挑剔。