感受函数式编程之美

6,692 阅读11分钟

前言

函数式编程是最近兴起的一种编程方式,很多语言都支持函数式编程,例如:JavaScript、Kotlin、C#等等。和面向对象编程不同,函数式编程有一种独属于数学的美感。纯函数式编程就像一个数学公式,传入特定的入参,即可获得确定的结果。这会给编程者一种让人心安的掌控感,这种掌控感能大大减少编码过程中的错误。

纯函数

举个很简单的例子,我们创建一个求矩形面积的函数,输入宽高,得到其面积。

fun calculateRectangleArea(width: Int, height: Int): Int {
    return width * height
}

calculateRectangleArea(16, 12) // 192
calculateRectangleArea(2, 4) // 8
s=abs=ab

这是一个纯函数,结果只由入参和里面的转换逻辑组成,不与计算环境亦或者计算时机相关。

20240128_012758_XuuxT9q5BG.gif

在面向对象编程中,我们在编写函数逻辑的时候往往可能要用到对象中的变量,而这样的函数就并不是纯函数。举个例子,我们往这个计算函数中引入一个深度变量,用于计算长方体的体积。而这个深度变量一秒钟会变化一次。

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。

20240128_014230_kREzgDZ4iQ.gif

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。这个函数会返回两种结果,因此它的示意图如下所示:

20240126_235619_c3fEB0f91C.gif

它只会选择一条道路去执行,而这条道路与传入的参数相关,也就是说从一开始调用这个函数的时候,它的结果是确定的,只会得到这一个结果,而中间就只有枯燥的运算过程罢了。

现在我们对该代码进行一些修改,我们可以对该函数进行“抽象”,使它变得更通用一些,这个函数由两个关键的部分组成,分别是输入参数控制条件

  • 输入参数为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。其示意图如下所示:

1706282719241-20240126_232329_oEQg5FxWQZ.gif

我们从另一个函数管道去引入一个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,官方也提供了很多类似的扩展函数,例如mapfilterreduce等等。而利用这些函数,我们可以很轻松地构建数据管道。

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这个函数很重要,它可以存下后续的所有操作并在一个循环中执行完成,避免多次无用的循环。

以上只是一个简单的例子,数据管道是函数式编程的一种很基础的形式,函数式编程还拥有着无限的可能。

1706367000263-20240127_224513_MPFgJmP6aI.gif

声明式UI

声明式UI是函数式编程的集大成之作,它引入了State的概念。它使得函数自身可以维护部分状态并使UI随着State变换。由于State是一种在函数栈中的属性,函数外难以顺利获取,具备了非常高的隐蔽性,因此声明式UI的可维护性和稳定性是非常高的。在编写声明式UI时会有非常强的掌控感,输入参数决定了UI展示,结果是确定且唯一。

React和Compose是经典的纯函数式的声明式UI,他们在实现上利用了函数式编程的确定性,通过不同的控制条件去选择怎样的分支,在内部生成一颗类似于上图中错综复杂的树。

声明式UI主要有以下思想。

  1. 数据下沉,事件提升。

    函数式编程中,当我们编写的函数需要获取信息时,只能通过参数去获取,参数是一步一步往下传递的,这个传递的过程被称为数据下沉。

    在函数的内部执行中可能会触发某些事件,放在声明式UI中大多数为用户输入触发。而触发事件的响应也需要通过参数获取,而这个参数就是一个函数,它是底层调用栈给顶层传递数据,相当于把事件抛出去,而这个过程被称为事件提升。

  2. 重组

    我们可以在下层传递出来的事件lambda中获取到创建lambda作用域中的属性甚至修改。

    如果UI State在这个过程中被修改,以该State为参数或者为控制条件的作用域就会发生重组,简单来说就是用新的State再次调用该函数,而这份重组为函数式编程赋予了构建视图并相应操作的能力。

    20240127_232530_6_rBOYAHA5.gif

    @Composable
    fun ShowHelloAndWorld() {
        var text by remember { mutableStateOf("Hello") }
        Button(onClick = { text = "World" }) {
            Text (text)
        }
    }
    

    造成UI State变更的不只是用户触发,还有可能是数据下层导致亦或者Side Effect。

  3. 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一份,防止外部调用函数之后继续修改实例中的内容。

总结

编程不是黑白分明的,函数式编程不一定需要完全舍弃面向对象,面向对象不一定容不下函数式编程。

我们在编程中如果偶尔想起纯函数,并编写几个稳定性、维护性极强的纯函数,由于它单元测试非常方便,顺手再多写几个单元测试,这对于整个项目来说是一个非常美妙的东西。

它可以是主导,也可以是点缀,它的艺术性无可挑剔。