前言
这篇文章是函数式编程系列的第二篇文章,实战篇。没看过第一篇函数式编程介绍的点击链接过去看一下,上文为了提高阅读体验,我用了比较简单的例子来介绍什么是纯函数、函数式编程的演化和函数式编程的可能性。而这篇文章将用一个实战案例带大家进一步探索函数式编程的魅力。
代码量较大,遇到重复的代码我将会使用注释来替代。
函数式编程,启动!
在上一篇文章中,我举了一个比较简单的例子如下代码所示:
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岁以上的程序员,并解雇,解雇完并打印他们的名字。
这份代码的重点在于这堆管道函数filter
、onEach
、map
、forEach
。而我以下讨论的将会是fire
这个不起眼的函数。
fire
这个函数并没有那么简单,它需要执行非常多的逻辑,它的流程图如下所示:
首先需要查询员工的关系,如果员工的后台关系网比较有实力,那么这位员工是不可以解雇的,然后再到查阅工资,较高工资的人一般会建议对方主动离职,工资较低和不同意主动离职的人走解雇程序并赔偿。
在前文中也讲到了,我们不建议在函数中引入无法把控的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
是具体的类,我们可以使用hilt
,koin
等依赖注入框架去注入仓库,进一步把仓库注入抛到上层去定义,我们专注拿到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,非常方便。
成果
经过一系列演化,我们就把所有逻辑做好了,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)的情况下。
比如对于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可以改大一点吗?
答:鹅。。
总结
如果带着面向对象思维读下去的话,会发现这种编程方式有些离谱,越读越抗拒。不过要是沉下心看完的话会发现函数式编程有一种纯真的美。
这是函数式编程系列的第二篇,这个系列看情况应该会写四篇。虽然上面的例子很形象,但是还是有些单薄,并没有结合实际业务和实际架构。等经过更多实战之后,我将会带来下一篇更有意思的函数式编程文章。