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

前言

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

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

函数式编程,启动!

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

kotlin 复制代码
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这个函数并没有那么简单,它需要执行非常多的逻辑,它的流程图如下所示:

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

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

kotlin 复制代码
suspend fun fire(
    id: String,
    staffInformationRepository: StaffInformationRepository,
    financeCenter: FinanceCenter,
    meetingRoom: MeetingRoomCenter
): FireResult {
    TODO()
}

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

Currying

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

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

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

kotlin 复制代码
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,可以再收敛一下参数范围,我这边只需要纯粹的获取信息的方法,我们可以得到如下函数。

kotlin 复制代码
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函数我们就可以编写如下代码:

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

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

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

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

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

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

kotlin 复制代码
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

我们简化一下函数名:

kotlin 复制代码
@Suppress("FunctionName")
suspend fun Firer(
    /* */
): suspend (String) -> FireResult = TODO()

val fire = Firer(/* */)

拆分逻辑

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

kotlin 复制代码
@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

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

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

kotlin 复制代码
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的编译器会将可变的对象包裹到一个包装对象中,而这个包装对象是不可变的,函数对象引用的是这个不可变的包装对象,然而这对于我们不可见也不用在意。

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

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

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

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

kotlin 复制代码
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,非常方便。

成果

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

kotlin 复制代码
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)的情况下。

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

kotlin 复制代码
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可以改大一点吗?

    答:鹅。。

总结

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

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

相关推荐
万少4 小时前
HarmonyOS官方模板集成创新活动-流蓝卡片
前端·harmonyos
-To be number.wan6 小时前
C++ 赋值运算符重载:深拷贝 vs 浅拷贝的生死线!
前端·c++
噢,我明白了7 小时前
JavaScript 中处理时间格式的核心方式
前端·javascript
纸上的彩虹7 小时前
半年一百个页面,重构系统也重构了我对前端工作的理解
前端·程序员·架构
李艺为8 小时前
根据apk包名动态修改Android品牌与型号
android·开发语言
be or not to be8 小时前
深入理解 CSS 浮动布局(float)
前端·css
LYFlied8 小时前
【每日算法】LeetCode 1143. 最长公共子序列
前端·算法·leetcode·职场和发展·动态规划
老华带你飞8 小时前
农产品销售管理|基于java + vue农产品销售管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端
小徐_23339 小时前
2025 前端开源三年,npm 发包卡我半天
前端·npm·github
Tom4i9 小时前
【网络优化】Android 如何监听系统网络连接成功
android·网络