渐入佳境!函数式编程进阶实战

2,553 阅读12分钟

前言

这篇文章是函数式编程系列的第二篇文章,实战篇。没看过第一篇函数式编程介绍的点击链接过去看一下,上文为了提高阅读体验,我用了比较简单的例子来介绍什么是纯函数、函数式编程的演化和函数式编程的可能性。而这篇文章将用一个实战案例带大家进一步探索函数式编程的魅力。

代码量较大,遇到重复的代码我将会使用注释来替代。

函数式编程,启动!

在上一篇文章中,我举了一个比较简单的例子如下代码所示:

data class Staff(
    val id: String,
    val age: Int,
    val name: String,
    val job: Job
)

staff.asSequence()
    .filter { it.age > 35 && it.job == Job.Programmer }
    .onEach(::fire)
    .map(Staff::name)
    .forEach { println(it) }
    
fun fire(staff: Staff) = TODO()

找到35岁以上的程序员,并解雇,解雇完并打印他们的名字。

这份代码的重点在于这堆管道函数filteronEachmapforEach。而我以下讨论的将会是fire这个不起眼的函数。

fire这个函数并没有那么简单,它需要执行非常多的逻辑,它的流程图如下所示:

image_VoamUFKLtY.png

首先需要查询员工的关系,如果员工的后台关系网比较有实力,那么这位员工是不可以解雇的,然后再到查阅工资,较高工资的人一般会建议对方主动离职,工资较低和不同意主动离职的人走解雇程序并赔偿。

在前文中也讲到了,我们不建议在函数中引入无法把控的Side Effect,这会影响函数的决策,也可能会造成意外结果。我们必须要确定影响函数执行的因子都是我们心中有数的东西,因此实现该逻辑的所有需要用到的东西我们需要通过参数传递进去,假设我们需要员工信息仓库,财务中心,会议室中心。

suspend fun fire(
    id: String,
    staffInformationRepository: StaffInformationRepository,
    financeCenter: FinanceCenter,
    meetingRoom: MeetingRoomCenter
): FireResult {
    TODO()
}

然而而在调用该函数的地方,我们只有员工的id信息

Currying

这个时候我们需要引入一个概念Currying,它是一个减少参数函数的过程。对于这个函数来说,员工信息仓库,财务中心,会议室中心是确定的,它们通常都会使用同一个

因此我们可以通过这三个仓库生成一个fire函数。

fun generateFireFunction(
    staffInformationRepository: StaffInformationRepository,
    financeCenter: FinanceCenter,
    meetingRoom: MeetingRoomCenter
): suspend (String) -> FireResult {
    return { id ->
        // We can use many repository here.
        TODO()
    }
}

fun main() {
    runBlocking {
        val fire = generateFireFunction(/* */)
        fireProgrammers(fire)
    }
}

suspend fun fireProgrammers(staffs: List<Staff>, fire: suspend (String) -> FireResult): List<FireResult> {
    return staffs.asFlow()
        .filter { it.age > 35 && it.job == Job.Programmer }
        .map { it.id }
        .map(fire)
        .toList()
}

那么这三个仓库该怎么来呢?这需要交给外部去注入,我们编写这部分逻辑的时候暂时先不管。我们拥有了fire这个函数对象就可以专注去处理自己的逻辑,因此fireProgrammers这个函数就完成了。

使用Currying的好处就是,我们可以事先去确定好稳定的参数并记住,在使用的时候只需传入会变化的参数即可,而这个转化可以嵌套非常多层,我们甚至可以分别在不同的地方去传入仓库,不过看着还挺抽象的。

fun generateFireFunctionButNoMeetingRoom(
    staffInformationRepository: StaffInformationRepository,
    financeCenter: FinanceCenter
): (MeetingRoomCenter) -> suspend (String) -> FireResult {
    return fun(meetingRoom: MeetingRoomCenter): suspend (String) -> FireResult {
        return { id ->
            // We can use many repository here.
            TODO()
        }
    }
}

// pass StaffInformationRepository and FinanceCenter
val fireFunctionButNoMeetingRoom = generateFireFunctionButNoMeetingRoom(
    staffInformationRepository = staffInformationRepository,
    financeCenter = financeCenter
)

// pass MeetingRoomCenter to get actual fire function
val fireFunction: suspend (String) -> FireResult = fireFunctionButNoMeetingRoom(
    meetingRoomCenter
)

我们真的需要这么多仓库传入吗?一般仓库有这会引入大量的无用信息,可能会允许引入大量的Side Effect,可以再收敛一下参数范围,我这边只需要纯粹的获取信息的方法,我们可以得到如下函数。

suspend fun generateFireFunction(
    fetchInformation: suspend (id: String) -> StaffInformation,
    fetchHighLevelStaffs: suspend () -> List<Staff>,
    salary: suspend (id: String) -> Int,
    getMeetingRoom: suspend () -> MeetingRoom
): suspend (String) -> FireResult {
    return { id ->
        // We can use many repository here.
        TODO()
    }
}

UseCase

一般从仓库获取信息的逻辑我们会拆分一层Domain层,其由非常多的UseCase组成,对于Domain层的介绍可以点击这个链接查看,我这边就不展开来讲了。例如其中的一个fetchInformation函数我们就可以编写如下代码:

class FetchStaffInformationUseCase(
    private val repository: StaffInformationRepository
): suspend (String) -> StaffInformation {
    override suspend fun invoke(id: String): StaffInformation = TODO()
}

它继承了函数的接口,于是我们可以把UseCase当成函数来使用了,这就非常方便了,而此处就用到了面向对象的特性。

需要注意的是,它长得非常像刚刚的Currying之后的函数,那么它们的区别是什么呢?UseCase是具体的类,我们可以使用hiltkoin依赖注入框架去注入仓库,进一步把仓库注入抛到上层去定义,我们专注拿到repository之后的逻辑。

函数类型也可以使用依赖注入生成,但是函数类型是个接口,范围太大,使用依赖注入来生成不太方便。

这个UseCase类型在实际业务逻辑中也是可以使用依赖注入框架去生成的,不用我们去操心怎么去实例化。

假设我们在业务代码中已经注入了相应的UseCase,我们就写下了如下代码:

private val fetchStaffInformationUseCase: FetchStaffInformationUseCase by inject()
private val fetchHighLevelStaffsUseCase: FetchHighLevelStaffsUseCase by inject()
// other...

fun main() {
    val staffs = /* */
    val fireFunction = generateFireFunction(
        fetchStaffInformationUseCase,
        fetchHighLevelStaffsUseCase,
        fetchSalaryUseCase,
        fetchMeetingRoomUseCase
    )
    fireProgrammers(staffs, fireFunction)
}

这里提一个小技巧,在Kotlin中,对于一个生成对象的函数,我们可以给它用大写表示,使其用起来非常像实例化一个对象,官方库也有很多这种运用,例如Channel

我们简化一下函数名:

@Suppress("FunctionName")
suspend fun Firer(
    /* */
): suspend (String) -> FireResult = TODO()

val fire = Firer(/* */)

拆分逻辑

到此为止,这个Firer函数就能非常顺利地写出来了,该有的信息都有了。

@Suppress("FunctionName")
suspend fun Firer(
    fetchInformation: suspend (id: String) -> StaffInformation,
    fetchHighLevelStaffs: suspend () -> List<Staff>,
    getSalary: suspend (id: String) -> Int,
    getMeetingRoom: suspend () -> MeetingRoom
): suspend (String) -> FireResult {
    return { id ->
        coroutineScope {
            // 异步同时获取该员工信息和高层员工
            val staffInformationDeferred = async { fetchInformation(id) }
            val highLevelStaffsDeferred = async { fetchHighLevelStaffs() }
            val highLevelStaffs = highLevelStaffsDeferred.await()
            val staffInformation = staffInformationDeferred.await()
            // 比对是否有高层员工是待解雇员工亲属
            val canFire = !highLevelStaffs.contains(staffInformation.relativeStaff)
            if (!canFire) {
                FireResult.NotFired
            } else {
                val salary = getSalary(id)
                if (salary > 3800) {
                    val meetingRoom = getMeetingRoom()
                    val action = with(meetingRoom) {
                        // 获取会议室谈话组合拳
                        ActionInMeetingRoom()
                    }
                    // 对员工实施组合拳
                    val result = action(staffInformation)
                    // 获得结果
                    if (!result) {
                        FireResult.Fired.WithCompensation(
                            getCompensation(staffInformation.year, salary)
                        )
                    } else {
                        FireResult.Fired.NoCompensation
                    }
                } else FireResult.Fired.WithCompensation(
                    getCompensation(staffInformation.year, salary)
                )
            }
        }
    }
}

private fun getCompensation(year: Int, salary: Int) = (year + 1) * salary

代码比较长,但是经过上方的多重精简,其实已经比较简单了。但是还没完,还有优化的地方。

假设我们不解雇高层员工,那么高层员工相对于这一次批量解雇任务也是稳定的,因此它在获取一遍之后就需要缓存下来,在函数中怎么做到缓存呢?其实比较简单。

suspend fun Firer(/* */): suspend (String) -> FireResult {
    var cacheHighLevelStaffs: List<Staff>? = null
    return { id ->
        coroutineScope {
            val staffInformationDeferred = async { fetchInformation(id) }
            // 先取缓存,缓存没有就去远端拿
            val highLevelStaffs = cacheHighLevelStaffs ?: async { fetchHighLevelStaffs() }.await()
            cacheHighLevelStaffs = highLevelStaffs
            val staffInformation = staffInformationDeferred.await()
            val canFire = !highLevelStaffs.contains(staffInformation.relativeStaff)
            /* */
        }
    }
}

我们在返回的Lambda外边去对高层员工做一个缓存,每次需要用到就判断缓存存不存在。而这个缓存是存在生成该Lamda的栈空间中,Lambda持有对它的引用。

大家可能有点懵,为什么可以创建的函数对象可以引用外部的可变对象,这是由于Kotlin的编译器会将可变的对象包裹到一个包装对象中,而这个包装对象是不可变的,函数对象引用的是这个不可变的包装对象,然而这对于我们不可见也不用在意。

在上方函数中我们通过会议室去获取一套组合拳,再对员工进行沟通工作。为什么不直接在会议室和员工沟通呢?其实原因在于沉淀之后的组合拳是可以复用的,不仅函数可以复用,函数中的资源也可以复用。

val action = with(meetingRoom) { ActionInMeetingRoom() }
var result = false
var tryCount = 3
do {
    result = action(staffInformation)
} while (!result && --tryCount > 0)

我们可以对这个员工重复使用三套一样的组合拳,这是函数对象的复用

而像是会议室里的水总不能拿三瓶出来,谈一次给一瓶吧?这里一名员工复用一瓶水就够了,因此我们生成组合拳的时候需要用这瓶水缓存下来,这是资源的复用

private fun MeetingRoom.ActionInMeetingRoom(): (StaffInformation) -> Boolean {
    val actions = listOfNotNull(
        Talk(getWater()),
        getBrush()?.let(::PaintCake),
        getBoxingGloves()?.let(::Fight)
    )
    return { staff ->
        var result = false
        for (action in actions) {
            result = action(staff).isSuccess
            if (result) {
                break
            }
        }
        result
    }
}

private fun Talk(water: Water): (StaffInformation) -> Result<Unit> = { TODO() }
private fun PaintCake(brush: Brush): (StaffInformation) -> Result<Unit> = { TODO() }
private fun Fight(boxingGloves: BoxingGloves): (StaffInformation) -> Result<Unit> = { TODO() }

若我们学会一种新技巧,或者会议室里有新的道具,我们就可以在这个地方去增加,例如如果会议室有一支笔,我们可以画饼,这相当于以热插拔的方式去新增feature,非常方便。

成果

1706367000263-20240127_224513_IfNS5wFU6J.gif

经过一系列演化,我们就把所有逻辑做好了,fire从一个函数,变成了下面的这样。

suspend fun fireProgrammers(staffs: List<Staff>, fire: suspend (String) -> FireResult): List<FireResult> {
    return staffs.asFlow()
        .filter { it.age > 35 && it.job == Job.Programmer }
        .map { it.id }.map(fire).toList()
}

suspend fun Firer(
    fetchInformation: suspend (id: String) -> StaffInformation,
    fetchHighLevelStaffs: suspend () -> List<Staff>,
    getSalary: suspend (id: String) -> Int,
    getMeetingRoom: suspend () -> MeetingRoom
): suspend (String) -> FireResult {
    var cacheHighLevelStaffs: List<Staff>? = null
    return { id ->
        coroutineScope {
            /* ... */
            val canFire = !highLevelStaffs.contains(staffInformation.relativeStaff)
            if (!canFire) {
                FireResult.NoFired
            } else {
                val salary = getSalary(id)
                if (salary > 3800) {
                    val meetingRoom = getMeetingRoom()
                    val action = with(meetingRoom) { ActionInMeetingRoom() }
                    val result = repeatDoActionToStaff(staffInformation, 3, action)
                    /* return FireResult... */
                } else FireResult.Fired.WithCompensation(getCompensation(staffInformation.year, salary))
            }
        }
    }
}

private fun getCompensation(year: Int, salary: Int) = (year + 1) * salary

private fun repeatDoActionToStaff(
    staffInformation: StaffInformation,
    count: Int = 3,
    action: (StaffInformation) -> Boolean
): Boolean {
    if (count == 0) return false
    return if (action(staffInformation)) true else repeatDoActionToStaff(staffInformation, count - 1, action)
}

private fun MeetingRoom.ActionInMeetingRoom(): (StaffInformation) -> Boolean {
    val actions = /* */
    return { staff ->
        val result = /* */
        result
    }
}

如果业务比较重的话,函数的颗粒度还可以进一步打碎。

上述代码中甚至不需要会议室,只需要“获取方法论的方法” (StaffInformation) -> Boolean就行,会议室太大,会引入Side Effect。

然而我举这个例子的原因是函数式编程可以做得很极致,也可以做得很灵活,而引入一个会议室就比较灵活,我们可以随意运用这个类中的东西,只需要注意Side Effect即可

实例中的元素需要尽量做到不可变,以减少Side Effect的引入。

这样的函数式编程相对一个普通的函数来说有什么优势呢?

  • 你可以把函数分别放在不同的文件,甚至不同的模块,它们都是独立的个体,不依附任何类
  • 函数颗粒度越小耦合度就越低,这点和面向对象的思路一致,我就不多赘述。
  • 安全性高,如果编写类的话可能会被其他开发者暴露不想暴露的成员变量出去,甚至可能被反射读取并修改。而将逻辑都写在函数中,要做到这两者将变得非常困难。
  • 有助于单元测试。

单元测试

这点我需要强调一下,单元测试一个函数的成本比测试一个类小很多,这些函数都是独立的个体,结果只与传入的参数和里面的逻辑相关(在没有SideEffect)的情况下。

20240128_012758_bXLzqBzN18.gif

比如对于fireProgrammers函数,我们可以编写以下单元测试,这个单元测试中的三个程序员中有一个幸免于难,一个是老板亲戚,一个倒霉蛋:

val staffs = listOf(
    Staff(id = "123", name = "Leon", age = 36, job = Job.Programmer),
    Staff(id = "678", name = "Jack", age = 45, job = Job.Programmer),
    Staff(id = "345", name = "Mike", age = 32, job = Job.Programmer)
)
val boss = Staff("001", name = "Boss", age = 24, job = Job.Boss)
val fire = Firer(
    fetchInformation = { StaffInformation(if (it == "678") boss else null) },
    fetchHighLevelStaffs = { listOf(boss) },
    getSalary = { 4000 },
    getMeetingRoom = { MeetingRoom() }
)
val result = fireProgrammers(staffs, fire)
val firedCount = result.filterIsInstance<FireResult.Fired>().size
assert(firedCount == 1)

那么其中的更细的单测逻辑我就不多举例了,大家可以自行实践。单元测试覆盖得越全面,这套系统的的稳定性就越高!

开始编写单元测试需要非常大的决心,业务代码一般与业务耦合比较重,比较难单测,如果需要改成能够单测的代码会需要比较大工作量。但是一开始就使用函数式编程,强迫自己在编程过程中避免Side Effect,这对于单元测试的编写会相对比较轻松,项目的单测覆盖率也会变高。

常见问题

  • 这样编程对性能有影响吗,速度会变慢吗?

    答:有,会变慢,但不多。函数也是一个对象,实例的创建总是会带来性能消耗的,而对于现代操作系统来说这部分消耗很小,需要复用的方法论尽量去复用就好了。对于内存稳定性有要求的项目慎用,一直创建新的函数实例并回收会带来更多的内存抖动。

  • 可以用inline提高性能吗?

    这个需要看情况,取决于传递进去的函数参数是当做对象还是单纯传进去调用,前者不行,后者可以,在使用过程中还请按需使用crossInline或者noInline来规避inline带来的问题。

  • UseCase和Currying后的函数长得很像,它们俩可以互相替换使用吗?

    答:可以。

  • 上文的数字35可以改大一点吗?

    答:鹅。。

总结

如果带着面向对象思维读下去的话,会发现这种编程方式有些离谱,越读越抗拒。不过要是沉下心看完的话会发现函数式编程有一种纯真的美。

这是函数式编程系列的第二篇,这个系列看情况应该会写四篇。虽然上面的例子很形象,但是还是有些单薄,并没有结合实际业务和实际架构。等经过更多实战之后,我将会带来下一篇更有意思的函数式编程文章。