理解Kotlin函数式编程

7,133 阅读30分钟

什么是函数式编程?

函数式编程(FP)是基于一个简单又意义深远的前提的:只用纯函数来构建程序。这句话的深层意思是,我们应该用无副作用的函数来构建程序。什么是副作用呢?带有副作用的函数在调用的过程中不仅仅是只有简单的输入和输出行为,它还干了一些其它的事情。并且这些副作用会把影响扩散到函数外,比如:

  • 修改一个外部变量(函数外定义)
  • 设置成员变量
  • 修改数据结构(List、Map、Set)
  • 抛出异常或以一个错误停止
  • 读取、写入文件

举个简单的例子:

public class BookStore {
  
  	//书店的丛书,省略初始化过程
    public Map<String, Book> collection;

    public Book buyABook(CreateCard lc, String bookCode){
        if(!collection.containsKey(bookCode)){
          return null;
        }
        Book book = collection.get(bookCode);
        lc.charge(book.getPrice());
        return book;
    }
    
}

buyBook方法的的作用是根据图书编号从书店中买一本书,这个方法是一个副作用函数。因为买书的过程中会涉及一些外部操作的,例如需要和库存管理系统进行交互、需要通过web service联系支付公司进行支付等操作。而我们的函数只不过是返回了一本书,获取书的过程中发生了一些额外的行为,这些行为我们就称为“副作用”。

为什么会把这些行为称为“副作用”呢?因为这些行为的作用域不单单是属于书店系统的范畴的了,它会把影响扩散到其它的系统中。副作用会导致这段代码很难测试,因为我们测试这段代码的时候,会影响到其它系统,牵一发而动全身。并且,客户端(调用这段代码的地方)没办法随心所欲的调用这段代码,在使用的时候,还要考虑副作用带来的具体影响,避免把系统带入异常状态。副作用让我们的代码使用、维护、测试、修改都更麻烦。

函数式编程的最重要也是最基础的知识点是:通过纯函数构建程序

在文章的最后,会给出一些思路解决上面例子中存在的副作用问题

JVM的Lambda表达式的实现原理

我们在使用函数式编程时,最新接触的概念一般是闭包、Lambda表达式等,这两个概念表达的是同样的意思。Lambda表达式是一种语法糖,它能帮助我们写出更简洁,更容易理解的程序。所以在开始函数式编程之前,我们要先掌握Lambda表达式到底是什么,它的原理是什么。

下面先看一段大家非常熟悉的代码:

//匿名函数
new Thread(new Runnable() {
		@Override
		public void run() {
				System.out.println("thread 1");
		}
}).start();


//Lambda表达式
new Thread(() -> System.out.println("thread 2")).start();

线程调度器运行线程时,线程的run方法会被执行,线程的调度原理这里不做介绍,有兴趣的读者可以自行了解。

上面两段代码的作用是一样的,它们的作用都是:当线程被调度时,就执行run方法中的代码。可以看到,使用Lambda表达式会让我们的代码更简单,更容易理解,这一点在Kotlin上会更明显。

Kotlin版本:

 Thread{
 		print("kotlin thread")
 }.start()

Kotlin版本语法结构更简单

Lambda和匿名函数它们有什么不一样呢?答案是,没有任何不同的地方。在JVM的角度来说,它们是一模一样的。无论是Java8还是Kotlin甚至是所有运行在JVM平台上的语言,它们的原理都是一样的,Lambda表达式只是匿名函数的一种高糖写法。那么什么样的匿名函数能用Lambda呢?答案是:只有一个方法的接口,也可以说是只有一个方法的匿名函数

我们可以看看Runnable这个接口的定义:

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

这里我们要注意一下@FunctionalInterface这个注解,这个注解的意思是,这是一个函数接口。函数接口的作用是,表明这个接口能使用Lambda表达式的风格来实现。这个注解是Java8后引入的,只起到提示作用。

我们再来看看Kotlin的一些Lambda的基础类是怎么样的:

public interface Function0<out R> : Function<R> {
    /** Invokes the function. */
    public operator fun invoke(): R
}

我们观察下Java的一些函数式接口,和Kotlin的一些能使用Lambda的接口就能发现,在JVM上,所有的Lambda都是通过只有一个方法的接口来实现的。例如Android中经常使用的View#setOnClickListnner方法。

我们可以使用Kotlin写一个简单的demo体验下:

fun main(args: Array<String>) {
    delay(2000) { println("delay: $it ms hello word") }
}

fun delay(ms: Long, action: (Long) -> Unit){
    Thread.sleep(ms)
    action(ms)
}

当我们运行main函数时,会打印出:delay: 2000 ms hello word。我们重点关注下调用代码:

   delay(2000) { println("delay: $it ms hello word") }

不是特别了解FP范式的同学可以这样理解:延迟2000毫秒后,我们就打印"delay: $it ms hello word"

现在再回顾下线程调用的例子:当线程被调度时,就执行run方法中的代码

Android中的click事件的监听:当view被点击时,就执行onClick方法中代码

所以当我们大量使用Lambda时,其实会大大增强我们代码的可读性的,因为它们都可以这样去理解:当x发生时,我们就执行y行为,这会比匿名函数容易理解很多。

现在,再来看看我们再Java中以匿名函数的方式调用delay方法时,代码是怎么样的:

public class Main {

    public static void main(String[] args) {
        MainKt.delay(2000, new Function1<Long, Unit>() {
            @Override
            public Unit invoke(Long aLong) {
                System.out.print("delay: " + aLong + " ms hello word")
                return Unit.INSTANCE;
            }
        });
    }
}

Kotlin中定义delay方法中的action(注: fun delay(ms: Long, action: (Long) -> Unit) )变量在Java中会被编译成了Function1接口,这和上述讨论的函数式接口的结论一致。

这段代码,一眼看下去很难理解它到底是做什么的。繁琐、难以理解、不够优雅。所以当我们使用FP范式编程时,建议大量使用Lambda表达式。当然,如果有人特别喜欢匿名函数的话,也是可以以FP的方式来使用匿名函数的,在Java7的环境中使用RxJava就会有类似的体验。(笔者不太推荐你们虐待自己,如果匿名函数里面的代码有几十行的话,画面太美我不敢看)

这里总结一下,如果你们的项目比较保守,需要追求稳定的话,尽量把你们的语言升级到Java8,如果激进点的话,可以直接升级到Kotlin。好的语法糖,能大大加强我们代码的可读性的。

函数式编程带来的性能问题

使用高阶函数是会带来一些性能损失的,因为每个Lambda表达式都是一个对象。从上文的分析可知,在JVM中,Lambda表达式其实是通过函数接口实现的。所以我们在使用lambda的时候,就相当于new了一个新对象出来。而且由于Lambda在访问外部变量时,会捕获变量的原因,捕获变量也会带来一定的内存开销。如果我们大量使用Lambda的时候不想带来这些影响的话,我们可以使用内联函数来解决这些问题。

内联函数时如何解决这些问题的呢?我们可以尝试下把上面的delay函数改造成内联函数的形式。

fun main(args: Array<String>) {
    delay(2000) {
        println("delay: $it ms hello word")
    }
}

inline fun delay(ms: Long, action: (Long) -> Unit){
    Thread.sleep(ms)
    action(ms)
}

我们现在知道了,如果delay不是内联函数的话,编译器会把上面的代码编译成new函数接口对象的形式。而内联函数的调用代码会编译成下面的形式:

fun main(args: Array<String>) {
    Thread.sleep(2000)
    println("delay: $2000 ms hello word")

}

上面的代码不就是我们一开始编写的非函数式代码吗?没错。通过内联函数,我们可以通知编译器,让编译器帮我们做这种脱糖处理。这样做的好处是,避免了大量使用lambda表达式导致对象大量创建和lambda捕获导致的性能开销。如果我们能合理使用内联函数,我们的应用会在性能上有所提升。

内联函数会导致编译生成的代码量变多,所以我们要合理使用避免内联过大的函数。举个简单的例子,如果我们的delay函数里面有100句代码的话,那么代码就会变成下面这个样子。

fun main(args: Array<String>) {
    Thread.sleep(2000)
    //假设还有一百句代码
    println("delay: $2000 ms hello word")

}

inline fun delay(ms: Long, action: (Long) -> Unit){
    Thread.sleep(ms)
    //假设还有一百行代码
    action(ms)
}

这样看起来问题好像不大,但是如果delay函数在项目中被大量调用的话,这将是一场灾难(想象下如果有100处调用,内联导致的代码增量是 100 * 100 -100)。合理使用内联函数才能带来性能的提升

熟悉JVM编译器的小伙伴会对内联这个词非常熟悉。这里的内联函数的原理和JVM中内联的原理是一样的,有兴趣的读者可以了解JVM的内联优化下。

篇幅有限,这里只对内联函数的作用与原理作个简单的介绍。读者有兴趣的话,可以自行查阅JVM内联优化的相关资料。

函数式编程的简单应用

集合操作

函数式编程中,我们用得最多的就是集合操作。集合操作的链式调用比起普通的for循环会更直观、写法更简单。下面我们以一个简单的例子来学习下函数式的集合操作。

现在假设有一个书店在线销售商场,他的初始化代码如下:

fun initBookList() = listOf(
    Book("Kotlin", "小明", 55, Group.Technology),
    Book("中国民俗", "小黄", 25, Group.Humanities),
    Book("娱乐杂志", "小红", 19, Group.Magazine),
    Book("灌篮", "小张", 20, Group.Magazine),
    Book("资本论", "马克思", 50, Group.Political),
    Book("Java", "小张", 30, Group.Technology),
    Book("Scala", "小明", 75, Group.Technology),
    Book("月亮与六便士", "毛姆", 25, Group.Fiction),
    Book("追风筝的人", "卡勒德", 30, Group.Fiction),
    Book("文明的冲突与世界秩序的重建", "塞缪尔·亨廷顿", 24, Group.Political),
    Book("人类简史", "尤瓦尔•赫拉利", 40, Group.Humanities)
    )

data class Book(
    val name: String,
    val author: String,
    //单位元,假设只能标价整数
    val price: Int,
    //group为可空变量,假设可能会存在没有(不确定)分类的图书
    val group: Group?)


enum class Group{
    //科技
    Technology,
    //人文
    Humanities,
    //杂志
    Magazine,
    //政治
    Political,
    //小说
    Fiction
}

我们先尝试用命令式的风格获取Technology类型的书名列表。

fun getTechnologyBookList(books: List<Book>) : List<String>{
    val result = mutableListOf<String>()
    for (book in books){
        if (book.group == Group.Technology){
            result.add(book.name)
        }
    }
    return result
}

如果我们要使用函数式的风格来实现这个功能的话,可以通过filter与map函数来实现。

fun getTechnologyBookListFp(books: List<Book>) =
    books.filter { it.group == Group.Technology }.map { it.name }

这两段代码输出的结果都是一样的,我们可以把返回的列表打印出来看看。

[Kotlin, Java, Scala]

可以看出来,如果用函数式的风格,代码会比使用for循环更容易理解,并且更简洁。上面的函数式代码实现的功能一目了然:先过滤出group 等于 Technology的书本,然后把书本转换成书本的名字

这个例子简单的介绍了filter和map的作用

那么我们面对复杂一点的功能的时候又如何呢?

现在有一个简单的需求: 把书按照分组分类放好。如果我们用命令式编程风格的话,我们会写出类似下面的代码:

fun groupBooks(books: List<Book>){
    val groupBooks = mutableMapOf<Group?, MutableList<Book>>()
    for (book in books){
        if (groupBooks.containsKey(book.group)){
            val subBooks = groupBooks[book.group] ?: mutableListOf()
            subBooks.add(book)
        }else{
            val subBooks = mutableListOf<Book>()
            subBooks.add(book)
            groupBooks[book.group] = subBooks
        }
    }

    for (entry in groupBooks){
        println(entry.key)
        println(entry.value.joinToString(separator = "") { "$it\n" })
        println("——————————————————————————————————————————————————————————")
    } 
}

那我们再看看,如果要用函数式的方式来实现一下这段函数要怎样写呢?我们可以使用操作符groupBy实现这个功能

fun groupBooksFp(books: List<Book>){
    books.groupBy { it.group }.forEach { (key, value) ->
        println(key)
        println(value.joinToString(separator = "") { "$it\n" })
        println("——————————————————————————————————————————————————————————")
    }
}

我们运行这两个方法看看这两段函数的输出结果:

因为是输出没有区别,所以只贴一段结果

Technology
Book(name=Kotlin, author=小明, price=55, group=Technology)
Book(name=Java, author=小张, price=30, group=Technology)
Book(name=Scala, author=小明, price=75, group=Technology)

——————————————————————————————————————————————————————————
Humanities
Book(name=中国民俗, author=小黄, price=25, group=Humanities)
Book(name=人类简史, author=尤瓦尔•赫拉利, price=40, group=Humanities)

——————————————————————————————————————————————————————————
Magazine
Book(name=娱乐杂志, author=小红, price=19, group=Magazine)
Book(name=灌篮, author=小张, price=20, group=Magazine)

——————————————————————————————————————————————————————————
Political
Book(name=资本论, author=马克思, price=50, group=Political)
Book(name=文明的冲突与世界秩序的重建, author=塞缪尔·亨廷顿, price=24, group=Political)

——————————————————————————————————————————————————————————
Fiction
Book(name=月亮与六便士, author=毛姆, price=25, group=Fiction)
Book(name=追风筝的人, author=卡勒德, price=30, group=Fiction)

——————————————————————————————————————————————————————————

可以看到,函数式的实现更简单,而且也更直观,当你习惯了这种方式之后,你的代码会更简洁。并且使用函数式的风格,能让你更容易避开烦人的副作用。

关于集合的操作就介绍到这里了,集合还有很多其它的函数(flatMap、find等限于篇幅,这里不作深入介绍)能让你的代码更简洁。

高阶函数

高阶函数和我们在学习代数的时候的高级代数非常像,我们可以用一句话来解析清楚什么是高阶函数:**入参是函数或者出参是函数的函数就是高阶函数。**这句话非常绕口,直接看代码会更加容易理解:

//入参是函数的高阶函数
fun fooIn(func: () -> Unit){
    println("foo")
    func()
}

//出参是函数的高阶函数
fun fooOut() : () -> Unit{
    println("hello")
    return { println(" word!")}
}

上面这两种就是最简单的两种形式的高阶函数,那么高阶函数有什么作用呢?我们先来介绍一下第一种高阶函数。我们先回顾下我们上面集合操作的函数,在这里以filter为例,filter函数是怎么样实现的呢?直接看源码:

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}

这是filter的实现,我们再来看看filterTo是怎么样的:

public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(destination: C, predicate: (T) -> Boolean): C {
    //具体实现
    for (element in this) if (predicate(element)) destination.add(element)
    return destination
}

从代码我们可以分析到,在我们调用filter函数的时候,会经历如下步骤:

  • filetr方法接收一个叫predicate的函数参数。
  • 然后filter会调用filterTo方法,filterTo方法第一个入参是一个可变集合,第二个入参和filter的predicate是一样的。
  • 当filter调用filterTo的时候会创建一个名为destination的ArrayList对象,这个对象的作用就是一个收集器。在filter中,会遍历目标集合。
  • 如果predicate方法返回true,则会把当前元素添加到收集器中。便利完毕会返回destination收集器。

这里可以看出,filter函数返回的是一个新集合,不会影响调用集合本身。

对于函数式编程的初学者可能会觉得,无论是代码还是笔者的描述都非常难以理解(如果理解了,跳过这段)。如果理解不了的,我们可以把函数在Java里面脱糖后再理解。在这里我们会以上面books.filter { it.group == Group.Technology } 这段调用代码为例,进行脱糖处理。

因为filter函数涉及到Function1这个函数,所以在看实际代码前先看下Function1的定义:

public interface Function1<in P1, out R> : Function<R> {
    /** Invokes the function with the specified argument. */
    public operator fun invoke(p1: P1): R
}

Function1其实就是一个普通的函数接口,没有什么特别。下面看看脱糖的代码。

  books.filter(object : Function1<Book, Boolean>{
        override fun invoke(p1: Book): Boolean {
            return p1.group == Group.Technology
        }

    })

//为了更方便读者理解,这里把泛型也去掉了,并在语法上也做了一些处理
inline fun Iterable<Book>.filter(predicate: Function1<Book, Boolean>): List<Book> {
    return filterTo(ArrayList<Book>(), predicate)
}

inline fun Collection<Book>.filterTo(destination: MutableCollection<Book>, predicate:Function1<Book, Boolean>): MutableCollection<Book> {
    for (element in this) {
        val isAdd = predicate.invoke(element)
        if (isAdd) destination.add(element)
    }
return destination
}

可以看到,脱糖处理后的filter函数和我们常用的一种设计模式是非常类似的,这个filter函数我们可以看作是策略模式的一种实现,而传入的predicate实例就是我们的一种策略。在上面这种场景中,我们也可以用命令式编程的策略模式实现(如Java集合操作中的sorted函数)。

函数式编程可读性更强更易于维护的原因之一就是:在函数式编程的过程中,我们会被动使用大量的设计模式。就算我们不刻意去定义/使用,我们也会大量使用类似设计模式中的策略模式和观察者模式去实现我们的代码。

语法不重要,重要的是思想

柯里化函数

柯里化函数,这个名词听起来逼格非常高,给我的感觉就和第一次听到依赖注入这个词一样(笑哭)。但是当你稍微了解下之后就能发现,柯里化函数和依赖注入一样,都是一些非常简单非常基本的东西。柯里化函数其实是高阶函数的一种,它的定义是:**返回值是一个函数的函数。**就是这么简单。但是这句话理解起来会有点抽象,我们直接看代码吧:

fun sum(x: Int) : (Int) -> Int{
    return { y: Int ->
        x + y
    }
}

这就是一个简单的求和柯里化函数,我们可以这样用它:


fun main(args: Array<String>) {
    val s = sum(5)
    println(s(10))

}

输出结果是:15

这种函数看起来合普通的求和函数好像也没有什么区别。我们可以拓展下上面的调用代码再看看:

fun main(args: Array<String>) {
    val s = sum(5)
    println(s(10))
    println(s(20))
    println(s(30))
}

这下输出的结果是:

15 25 35

是不是觉得有点意思了。柯里化函数的特点是,第一次调用会得到一个特定功能的函数,上面的例子就是,得到一个和5求和的函数。然后第二次调用的作用是,求传入的值和5的和。这样看起来貌似也没有什么作用,只是语法上好像更炫了一点而已。

我们可以把上面的例子改成普通函数的方式再对比下。

fun sum1(x: Int, y: Int) : Int{
    return x + y
}

fun main(args: Array<String>) {
    println(sum(5, 10))
    println(sum(5, 20))
    println(sum(5, 30))
}

不用柯里化函数的话,会非常依赖调用方的自觉性,因为我们要得到5与某个数字的和的话,我们必须要要求调用方需要在使用函数的时候,第一个值需要传入5。

我们换个角度想象下,如果我们现在有一个非常复杂的两段式运算,我们可能会需要复用第一段运算的结果。那么这种场景下使用柯里化函数是非常方便的,我们可以直接把第一段运算的结果直接缓存起来。并且由于柯里化函数第一次调用返回的是一个函数,所以,柯里化函数是无副作用的。柯里化函数会起到延迟作用的效果,第一次调用返回一个函数,第二次调用才会得到一个值,即在真正被消费的时候,才会生成值。

柯里化函数非常适合框架的开发者使用,我们可以通过柯里化函数实现一些阅读简单并且非常有效的API。

scala源码中有大量的柯里化函数

Kotlin柯里化函数的缺点

主要是scala和kotlin的对比,没兴趣可以跳过

Kotlin的柯里化函数其实也是有自己的局限性的,从语法角度来说,Kotlin的柯里化函数更难以理解,远没有Scala的简单。例如我们上面的sum函数,从定义上来说就没这么好理解。而Scala的柯里化函数会更加简单明晰。下面我们对比下两种语言的柯里化函数的特点。

Scala

 object Main{

  def main(args: Array[String]): Unit = {
    val s = sum(5)(_)
    println(s(10))
    println(s(20))
    println(s(30))
    //最终打印出15,25,35
  }
   
  def sum(x: Int)(y: Int) : Int = x + y
}

Kotlin

fun sum(x: Int) : (Int) -> Int{
    return { y: Int ->
        x + y
    }
}

可以看到,Scala的柯里化函数从定义上来说是更简单的,和普通的函数定义差不多。而Kotlin的柯里化函数会更加难以理解一点。希望Kotlin有一天也能支持这种风格的柯里化函数。

这里只是简单介绍下kotlin和scala柯里化函数的语法区别,限于篇幅,这里不对柯里化函数的应用作过多介绍

函数式设计的通用结构

当我们掌握了上面的知识点后,我们就掌握了函数式编程的基础知识了。是的,掌握了上面的知识后,只是处于FP编程入门的状态。上文提到过,我们在使用函数式编程的时候,会被动地使用一些命令式编程中的设计模式,设计模式可以说是命令式编程的一种高阶应用。那么我们要进一步理解、提高函数式编程技能,我们需要了解一些函数式设计的通用结构。这种结构和我们常说的设计模式有点类似,但是又不太一样。在初学阶段可以把它当成是函数式编程中设计模式来理解。

我们主要介绍三种比较常用的通用结构。

Option

在我们刚接触Kotlin的时候,大部分人会先了解Kotlin的一个特性,就是:空安全。空安全是通过可空变量/常量实现的。在我们使用可空变量/常量的时候,编译器会强制我们要做空检查才能使用。一般我们会使用?这个语法糖实现,当然也可以在使用前先判空,判空后Kotlin会自动帮我们进行智能转换,会把可空变量转换成非空变量。一般情况下,我们可以使用类似下面的代码处理可空变量。

现在假设我们需要定义一个函数,入参是可能为空的书本列表,当列表长度大于5时,返回下表为5的元素,小于5时,返回下标为1的元素,为0时返回空。我们可以用下面的三种方法来实现这个函数。


fun foo(books: List<Book>?) : Book?{
    val size = books?.size ?: 0
    return if (size >  5){
        books?.get(5)
    }else {
        books?.firstOrNull()
    }
}

fun foo1(books: List<Book>?) : Book?{
    return if (books != null){
        if (books.size > 5){
            books[5]
        }else{
            books.firstOrNull()
        }
    }else{
        null
    }
}

fun foo2(books: List<Book>?) : Book?{
    books ?: return null
    return if (books.size > 5){
        books[5]
    }else{
        books.firstOrNull()
    }
}

相对来说foo和foo2这两种风格可读性都比较强,而foo1就有点啰嗦了。在面对这种比较简单的场景的时候,Kotlin的空安全写起来十分简洁,维护也挺方便的。那么假如我们需要面对一些更为复杂的需求的时候呢?这种时候可能我们会需要写一堆**?**号来解决这种问题。

例如:现在我们用Kotlin实现一次文章开头的那个BookStore的程序。

CreateCard

class CreateCard{

    fun charge(price: Int){
        println("pay $price yuan")
    }
}
class BookStoreOption {
    private val bookCollection = initBookCollection()

    fun buyABook1(lc: CreateCard?, bookCode: Int) : Book?{
        val result = bookCollection[bookCode]
        //判空,result为空时不能产生交易行为
        //lc?.charge(result?.price ?: 0) 这种写法会导致金额为0的交易行为
        if (lc != null && result !=  null){
            lc.charge(result.price)
        }
        return result
    }
}

上面是在不使用函数式通用结构时的比较合理的一种写法。

这样写的时候,客户端的调用代码可能是这样的。

    val buyBook1 = BookStoreOption().buyABook1(CreateCard(), 1)
    if(buyBook1 != null ) {
        println("book name = ${buyBook1.name}, author = ${buyBook1.author}")
    }

输出结果:

pay 55 yuan book name = Kotlin, author = 小明

客户端的调用代码,我们通过Kotlin的语法糖稍微简化了下代码。现在加多个条件,当buyBook1不为空时,打印出购买的书名、作者名。并且当buyBook1的group不为空时,再打印书的分组。

这种情况下我们很容易就能写出下面这种代码:

    val buyBook1 = BookStoreOption().buyABook1(CreateCard(), 1)
    if(buyBook1 != null ) {
        println("book name = ${buyBook1.name}, author = ${buyBook1.author}")
        if (buyBook1.group != null){
            println("book group = ${it.group}")
        }
    }

上面这种写法虽然功能上没什么问题,但是结构不太合理。假如我们再加多几个判断条件的话,这种代码几乎没有可读性。难以阅读,也难以维护。那我们试试用Option结构优化下这段代码。在优化之前,我们先简单介绍下Option

Option是什么?

这里我们直接采用arrow开源库的Option演示,Option的结构非常简单,不想依赖库的话,可以自己定义一个。

Kotlin里面的Option其实和Java8的Optional是一个意思,就是用来处理可空变量的。我们先简单看下Option是怎么用的:

fun main(args: Array<String>) {
    fooOption("hello word!!!")
    println("——————————————————————")
    fooOption(null)
}

fun fooOption(str: String?){
    val optionStr = Option.fromNullable(str)
    val printStr = optionStr.getOrElse { "str is null" }
    println(printStr)
    optionStr.exists {
        println("str is not null! str = $it")
        true
    }

}

Option.fromNullable(str)可以用语法糖str.toOption()代替

我们看看打印结果:

hello word!!! str is not null! str = hello word!!! —————————————————————— str is null

结合例子,我们可以知道:

Option.getOrElse函数的作用是:如果对象不为空,返回对象本身,为空则返回一个默认值。

Option.exists函数的作用是:如果对象不为空,则执行Lambda(函数、闭包)里面的代码。

对于Option,我们先了解这么多就行了。

如何使用Option写出结构更合理的代码

我们直接看看,如果要用Option来优化buyABook1我们要如何优化:

class BookStoreOption {
    private val bookCollection = initBookCollection()

    fun buyABook2(lc: CreateCard?, bookCode: Int) : Option<Book>{
        val lcOption = lc.toOption()
        val bookOption = bookCollection[bookCode].toOption()
        lcOption.map2(bookOption){
            it.a.charge(it.b.price)
         }
        return bookOption
    }

}

//客户端调用函数
fun main(args: Array<String>) {
   
    println("\n——————————— createCard is null, bookCode 1 ———————————————")

    val bookStoreOption2 = BookStoreOption()
    buyBook2ForStore(bookStoreOption2, null, 1)

    println("\n—————————————————————————— bookCode 1 ————————————————————————————————")

    buyBook2ForStore(bookStoreOption2, CreateCard(), 1)

    println("\n—————————————————————————— bookCode 20————————————————————————————————")
    buyBook2ForStore(bookStoreOption2, CreateCard(), 20)

}

fun buyBook2ForStore(store: BookStoreOption, createCard: CreateCard?, bookCode: Int){
    val buyBook2 = store.buyABook2(createCard, bookCode)
    buyBook2.map{
            println("book name = ${it.name}, author = ${it.author}")
            it.group
        }.exists {
            println("book group = $it")
            true
        }

}

我们可以看到,buyABook2和buyABook1的主要区别是,buyABook2返回的是一个非空的Option<Book>对象。单纯看这个函数我们看不出太大的优势,那我们们再看看buyBook2ForStore这个函数。我们回顾下前面的客户端调用函数:

  val buyBook1 = BookStoreOption().buyABook1(CreateCard(), 1)
    if(buyBook1 != null ) {
        println("book name = ${buyBook1.name}, author = ${buyBook1.author}")
        if (buyBook1.group != null){
            println("book group = ${it.group}")
        }
    

这样一看好像用Option后,代码反而更多了。但是不难看出,buyBook2ForStore的结构更加清晰,可读性更强。我们可以这样理解这个函数:

  • 调用调用store.buyABook2方法获取一个Option对象。
  • 把Option转换成Option对象。
  • 如果Option对象存在(不为空)的话,则处理事件。

使用Option之后,代码结构和我们人类的思考方式是非常类似的,可读性会强很多。但是这样看起来,使用Option其实也没有带来太大的提升。但是如果当我们的代码规模和复杂度更高了之后呢?例如,Book中的Group其实是更加复杂的对象呢?Group还包含:名字,id,等等信息呢?并且他们都是可空变量(在Kotlin和Java混合编程里面非常常见,因为Java对变量是否可空的限制比较弱)呢?假如我们现在需要在打印group后再,读取group里面的name的值,可能要这样写:

  val buyBook1 = BookStoreOption().buyABook1(CreateCard(), 1)
    if(buyBook1 != null ) {
        println("book name = ${buyBook1.name}, author = ${buyBook1.author}")
        if (buyBook1.group != null ){
            println("book group = ${it.group}")
            if(buyBook1.group.name != null){
              println("book group name = ${it.group.name}")
            }
        }

随着迭代,这段代码变得越来越难以理解了,现在它嵌套了三层了。在不使用Option的时候,我们只能一层层使用if语句去判断。**代码嵌套会使复杂度暴增。**我相信没什么人会想维护一段多重嵌套的代码,特别是这段代码还是别人写的。如果使用Option的话,上面这个需求,可以这样实现:

    val buyBook2 = store.buyABook2(createCard, bookCode)
    buyBook2.map{
            println("book name = ${it.name}, author = ${it.author}")
            it.group
        }
        .map {
             println("book group = $it")
             it.name
        }
        .exists {
            println("book name = $it")
            true
        }

使用Option之后,会比我们使用if嵌套代码结构清晰很多。Option在处理大量可空值的时候,能以线性的方式去处理,而简单使用if的话,我们的代码需要以类似多重嵌套的方式去实现,代码复杂度会暴增。

当然,在使用命令式编程的时候,我们可以通过设计模式去优化代码。而在函数式编程中,我们使用函数式的通用结构的话,天然就是在使用类似设计模式的方式去编写代码。函数式的通用结构和设计模式的作用是类似的,但是函数式结构能提供更高程度的抽象(例如,我们不需要为具体的业务场景定义一个具体的Option,所有场景使用Option的方式是一样的,而设计模式做不到这么彻底的抽象)。使用这些结构,能让我们以类似代数演算的方式去实现我们的代码,通过不同的组合能让我们构建出更加复杂的功能。

虽然函数式通用模式在很多场景下工作得很好,但是并不能完全替代设计模式。在实际开发中要根据业务场景来选择,同时使用两种方式进行cc设计也是可以的。

Option是一个比较简单的函数通用结构,但是它的功能场景比较局限。读者可以把它作为一个入门的函数式通用结构来学习。接下来我们会介绍其它两个更加强大也更有用的通用结构。

Monoid

分离副作用

前面我们介绍了如何使用Option更优雅的处理可空变量的问题,但是Option其实是没有解决我们文章开头提到的一个最核心的问题的。**如何实现无副作用的函数。**我们这里回顾下,因为我们在调用buyABook函数的时候,会包含一个支付行为。支付行为会和外界系统进行交互,所以这个buyABook行为不单单影响了我们的图书销售系统,还会把影响扩散到其它系统,这就是我们所说的副作用。

要消除这个副作用,我们要做的是支付行为从图书销售系统中分离开,实现图书销售系统 —— 支付系统解耦。现在我们重新整理下我们的需求。我们的需求是要实用向书店购买书本并支付,现实场景中,我们可能会购买多本书本。如果使用我们上面的buyABook的话,我们可以循环遍历这个方法,直到全部购买成功为止。不过这里会有个问题,每购买一本书,就付款一次,显然是非常不合理的,并且可能会存在余额不足的问题。我们要重构BookStore这个对象,以实现我们的需求。整理一下,我们的重构的BookStore要支持下面的功能:

  • 支持批量购买
  • 销售和支付解耦
  • 支持拒绝策略

为了实现这个需求,我们会增加一个Charge类,这个类的作用是记录费用。

下面直接上代码,第一次重构:

/**
 * 费用
 * [id] 唯一标识符,太懒了,用随机数表示
 */
data class Charge(
    val createCard: CreateCard,
    val price: Int,
    val id: Int = Random.nextInt()
)

/**
 * 第一次重构BookStore
 */
class RefactoringBookStore{

    private val bookCollection = initBookCollection()

    /**
     * 购买多个
     */
    fun buyBooks(cc: CreateCard, bookIds: List<Int>) : Pair<List<Book>, Charge>{
        val purchases = bookIds
            .map { buyABook(cc, it).orNull() }
            .filterNotNull()
        val (books, charges) = purchases.unzip()
        //传入的createCard是同一个,所以reduce操作的时候不用判断createCard是否一致
        val totalCharge = charges.reduce {
                acc, charge -> Charge(acc.createCard, acc.price + charge.price) }
        return books to totalCharge
    }

    @Suppress("MemberVisibilityCanBePrivate")
    fun buyABook(cc: CreateCard, bookId: Int) : Option<Pair<Book, Charge>> {
        val book = bookCollection[bookId]
        return book.toOption().map { Pair(it, Charge(cc, it.price)) }
    }
}

重构后的代码已经把支付这个“副作用”分离出去了,购买书的函数中不会产生支付行为。支付行为客户端需要通过读取我们封装好的Charge对象,然后再调用支付模块实现支付(限于篇幅,省略支付模块)。

现在我们来看看客户端的调用代码:

fun main() {
  val (books1, charge1) = 
        RefactoringBookStore().buyBooks(CreateCard(123), 1, 3, 5, 10, 20, 30)
    printBuyBooks(books1, charge1)

    println("——————————————————————————————\n")

    val (books2, charge2) = 
        RefactoringBookStore().buyBooks(CreateCard(111, 120), 7, 2, 8)
    printBuyBooks(books2, charge2)
}

fun printBuyBooks(books: List<Book>, charge: Charge){

    val buyBookName = books.map { it.name }
    println("希望购买书本名字: $buyBookName")
    if (charge.createCard.amount >= charge.price){
        val cc = charge.createCard.charge(charge.price)
        println("支付成功,支付金额 = ${charge.price}; 剩余额度 = ${cc.amount}")
    }else{
        println("额度不足,需要支付金额 = ${charge.price}; 可用额度 = ${charge.createCard.amount}")
    }
}

我们看看调用结果

希望购买书本名字: [Kotlin, 娱乐杂志, 资本论, 文明的冲突与世界秩序的重建] pay 148 yuan 支付成功,支付金额 = 148; 剩余额度 = 352 ——————————————————————————————

希望购买书本名字: [Scala, 中国民俗, 月亮与六便士] 额度不足,需要支付金额 = 125; 可用额度 = 120

上面的这段代码已经成功把副作用分离出去了(省略支付模块)。一般情况下,这段代码已经能工作得很好了(经验丰富的同学可能会觉得,这种代码算是比较好维护的代码了😂)。但是其实这段代码还是有问题的,它的问题就是,我们需要通过List来维护Charge这个对象,在组合Charge的时候需要通过类似下面的方式来实现:

fun foo(){
    Charge(acc.createCard, acc.price + charge.price)
    charge.copy(charge.createCard, charge.price + charge1.price)
}

这样看起来好像也没什么问题。但是假如有这样的一个场景:书店里面有位客人,买了一批书后,发现还有书忘记买了,再买一批后,又发现了一本自己很想买的书。在这种场景下我们客户端的代码可能会变成类似这种:

    val cc = CreateCard(12423)
    val (b1, c1) = RefactoringBookStore().buyBooks(cc, 1, 2)
    val (b2, c2) = RefactoringBookStore().buyBooks(cc, 4, 5)
    val (b3, c3) = RefactoringBookStore().buyBooks(cc, 6, 7)

    val books = mutableListOf<Book>().apply {
        addAll(b1)
        addAll(b2)
        addAll(b3)
    }
    printBuyBooks(books, Charge(c1.createCard, c1.price + c2.price + c3.price))

这种代码主要的问题就是在于Charge的合并上面,当我们在组合更多更复杂的Charge的时候,会写很多类似这种繁琐又不利于维护的代码。当然,你可以把多个Charge放到List里面再做处理,但是这两种方式都不是那么的方便。那么有没有更合理的方法解决这种问题呢?有的,我们可以使用Monoid来解决这个问题。

Monoid是什么?

Monoid和Option一样,也是函数设计的通用结构的其中一种。Monoid是一种纯代数结构,它的中文名字叫幺半群,它是一个代数定义。Monoid在函数式编程中经常出现,操作列表、连接字符、循环中进行累加操作都可以背解析成Monoid。Monoid的主要作用是:将问题拆分成小部分然后并行计算和将简单的部分组装成复杂的计算。Monoid是一种数学上的概念。

在抽象代数此一数学分支中,幺半群(英语:monoid,又称为单群、亚群、具幺半群或四分之三群)是指一个带有可结合二元运算和单位元的代数结构。—— 维基百科

看不懂上面这段话也没关系,大部分第一次接触这个定义的时候,都会觉得很难理解。我们下面通过简单的例子来学习Monoid到底能做些什么。

举个整数计算的例子,假设有 x 、y、z三个整数,那么我们很容易就能得出下面的一些公式:x + y + z 等于( x + y ) + z 等于x + ( y + z )。而且有一个单位元(Monoid的定义)元素0,当0和其它整数相加时,结果不会发生改变。乘法运算也有一个单位元元素1。

我们先看看Kotlin中的Monoid的定义是怎么样的:

Monoid

/**
 * ank_macro_hierarchy(arrow.typeclasses.Monoid)
 */
interface Monoid<A> : Semigroup<A>, MonoidOf<A> {
  /**
   * A zero value for this A
   */
  fun empty(): A

  /**
   * Combine an [Collection] of [A] values.
   */
  fun Collection<A>.combineAll(): A =
    if (isEmpty()) empty() else reduce { a, b -> a.combine(b) }

  /**
   * Combine an array of [A] values.
   */
  fun combineAll(elems: List<A>): A = elems.combineAll()

  companion object
}

Semigroup

interface Semigroup<A> {
  /**
   * Combine two [A] values.
   */
  fun A.combine(b: A): A

  operator fun A.plus(b: A): A =
    this.combine(b)

  fun A.maybeCombine(b: A?): A = Option.fromNullable(b).fold({ this }, { combine(it) })
}

这是Monoid在arrow开源库中的定义,如果在使用时不想依赖arrow库的话,自己实现一个Monoid也是非常简单的,30多行代码就足以。

我们简单使用下Monoid:

fun main() {
    val monoid = Int.monoid()
    val sum = listOf(1, 2, 3, 4, 5).foldMap(monoid, ::identity)

    println("sum = $sum")

}

结果:

sum = 15

可以看到IntMonoid在这里的作用就是定义了元素之间的结合规则。

Int.monoid()给我们返回了一个IntMonoid。我们再来看看IntMonoid的定义:

interface IntMonoid : Monoid<Int>, IntSemigroup {
  override fun empty(): Int = 0
}

interface IntSemiring : Semiring<Int> {
  override fun zero(): Int = 0
  override fun one(): Int = 1

  override fun Int.combine(b: Int): Int = this + b
  override fun Int.combineMultiplicate(b: Int): Int = this * b
}

IntMonoid的定义也非常简单,主要是定义了0值,1值和它们两两结合的操作。

这样看来Monoid好像给人一点画蛇添足的感觉,我们不用monoid的话,折叠集合的时候可以这样写啊:

listOf(1, 2, 3, 4, 5).fold(0){acc, i ->
        acc + i
}

那么我们为什么要使用Monoid呢,上一小节我们通过Charge把副作用分离了,但是Charge的后续处理还是存在不完善的地方。那么我们来尝试使用Monoid来优化我们的代码。

如何使用Monoid

我们先定义个Monoid<Charge>

/**
 * 需要传入[CreateCard] 因为同一张信用卡的费用才能做合并处理
 */
class ChargeMonoid(private val cc: CreateCard) : Monoid<Charge>{

    /**
     * 固定的单位元值,id也需要是固定的
     * 因为同一个ChargeMonoid(equals 为 true)的单位元([empty])是相等(equals 为 true)的
     */
    override fun empty(): Charge = Charge(cc, 0, 0)

    /**
     * 只会合并createCard等于[cc]的Charge
     * 不同信用卡的费用无法合并
     */
    override fun Charge.combine(b: Charge): Charge =
        if (cc == b.createCard){
            Charge(cc, b.price + price)
        }else{
            this
        }

}

我们回顾下上文说的书店的例子:

我们先来看看新的书店的代码:

class MonoidBookStore {

    private val bookCollection = initBookCollection()

    /**
     * bookId可以传入多个
     */
    fun buyBooks(cc: CreateCard, vararg bookIds: Int) : Pair<List<Book>, Charge>{
        val purchases = bookIds
            .map { buyABook(cc, it).orNull() }
            .filterNotNull()
        val (books, charges) = purchases.unzip()

        //使用ChargeMonoid折叠列表
        val totalCharge =  charges.foldMap(Charge.monoid(cc), ::identity)
        return books to totalCharge
    }

    @Suppress("MemberVisibilityCanBePrivate")
    fun buyABook(cc: CreateCard, bookId: Int) : Option<Pair<Book, Charge>> {
        val book = bookCollection[bookId]
        return book.toOption().map { Pair(it, Charge(cc, it.price)) }
    }
}

和第一次重构后的RefactoringBookStore非常像,他们只有一句代码是有区别的:

RefactoringBookStore

        //传入的createCard是同一个,所以reduce操作的时候不用判断createCard是否一致
        val totalCharge = charges.reduce {
                acc, charge -> Charge(acc.createCard, acc.price + charge.price) }

MonoidBookStore

        //使用ChargeMonoid折叠列表
        val totalCharge =  charges.foldMap(Charge.monoid(cc), ::identity)

::identity是Kotlin的一个语法糖,在这里的作用是,返回折叠后的Charge对象。::identity的作用是返回本身,有兴趣的可以看看它的具体实现。

我们可以看到它们的差别很小,那么我们这样写有什么好处呢?我们再来回顾一下上面的一个场景:店里面有位客人,买了一批书后,发现还有书忘记买了,再买一批后,又发现了一本自己很想买的书。

那么我们用ChargeMonoid要怎么实现这个功能呢?直接看代码。

fun main() {
    val cc = CreateCard(12423)
    val (b1, c1) = MonoidBookStore().buyBooks(cc, 1, 2)
    val (b2, c2) = MonoidBookStore().buyBooks(cc, 4, 5)
    val (b3, c3) = MonoidBookStore().buyBooks(cc, 6, 7)

    val books = listOf(b1, b2, b3).flatten()
    val charge = listOf(c1, c2, c3).foldMap(Charge.monoid(cc),::identity)

    printBuyBooks(books, charge )
}

现在来看看输出:

希望购买书本名字: [Kotlin, 中国民俗, 灌篮, 资本论, Java, Scala] pay 255 yuan 支付成功,支付金额 = 255; 剩余额度 = 245

单单看代码的话,上面这段代码和前面的对比起来貌似只有一个好处:不需要人工维护Charge对象的合并。是的,它就真的只有这一个好处。Monoid它的主要作用就是这个,它定义了相同类型(群)的对象的合并规律。在这里我们Charge的合并规律就是:CreateCard相同的Charge对象以价格累计的方式合并在一起

我们为了这个简单的功能引入一个这样复杂的概念(对初学者来说,这的确是有点难以理解)值得吗?

现在我们再来改一下这个需求( 敏捷开发😂 ):店里面有位客人,买了一批书后,发现还有书忘记买了,再买一批后,又发现了一本自己很想买的书。然后他又想再买一批书,但是现在想用另外一张卡付款,然后再买一批,再用另外一张卡付款。这个过程中,一共用了三张卡付款,大家可以尝试用命令式编程实现这个需求,这里就不演示了,直接上Monoid的例子:

fun main() {
   
    //数据初始化
    val cc2 = CreateCard(124234)
    val (b4, c4) = MonoidBookStore().buyBooks(cc2, 1, 2)
    val (b5, c5) = MonoidBookStore().buyBooks(cc2, 4, 5)
    val (b6, c6) = MonoidBookStore().buyBooks(cc2, 6, 7)

    val cc3 = CreateCard(124234512)
    val (b7, c7) = MonoidBookStore().buyBooks(cc3, 3)

    val cc4 = CreateCard(151212)
    val (b8, c8) = MonoidBookStore().buyBooks(cc4, 8)


    //数据处理
    val monoid3 = monoidTuple3(
        //a monoid
        Charge.monoid(cc2),
        //b monoid
        Charge.monoid(cc3),
        //c monoid
        Charge.monoid(cc4))
    val result =  listOf(c6, c7 ,c8).foldMap(monoid3){
        Tuple3(it, it, it)
    }

    println("\n—————————————————第一张信用卡———————————————————")
    //result.a -> monoid a收集的数据
    printBuyBooks(listOf(b4, b5, b6).flatten(), result.a)

    println("\n—————————————————第二张信用卡———————————————————")
    //result.b -> monoid b收集的数据
    printBuyBooks(b7 , result.b)

    println("\n—————————————————第二张信用卡———————————————————")
    //result.b -> monoid b收集的数据
    printBuyBooks(b8, result.c)
}

/**
 * 不用理解下面的代码(Tuple3是另外一种函数设计通用结构)
 * 只需要明白个函数的作用值组合3个Monoid就行了
 */
fun <A, B, C> monoidTuple(MA: Monoid<A>, MB: Monoid<B>, MC: Monoid<C>): Monoid<Tuple3<A, B, C>> =
    object: Monoid<Tuple3<A, B, C>> {

        override fun Tuple3<A, B, C>.combine(y: Tuple3<A, B, C>): Tuple3<A, B, C> {
            val (xa, xb, xc) = this
            val (ya, yb, yc) = y
            return Tuple3(MA.run { xa.combine(ya) }, MB.run { xb.combine(yb) }, MC.run { xc.combine(yc) })
        }

        override fun empty(): Tuple3<A, B, C> = Tuple3(MA.empty(), MB.empty(), MC.empty())
    }


现在我们看看输出的内容

—————————————————第一张信用卡——————————————————— 希望购买书本名字: [Kotlin, 中国民俗, 灌篮, 资本论, Java, Scala] pay 105 yuan 支付成功,支付金额 = 105; 剩余额度 = 395

—————————————————第二张信用卡——————————————————— 希望购买书本名字: [娱乐杂志] pay 19 yuan 支付成功,支付金额 = 19; 剩余额度 = 481

—————————————————第二张信用卡——————————————————— 希望购买书本名字: [月亮与六便士] pay 25 yuan 支付成功,支付金额 = 25; 剩余额度 = 475

我们回顾下代码的数据处理部分

val monoid3 = monoidTuple3(
    //a monoid
    Charge.monoid(cc2),
    //b monoid
    Charge.monoid(cc3),
    //c monoid
    Charge.monoid(cc4))
val result =  listOf(c6, c7 ,c8).foldMap(monoid3){
    Tuple3(it, it, it)
}

是的,数据处理就这么多代码,在这里我们通过了Monoid来处理了数据。ChargeMonoid会把数据按照CreateCard的类型来收集,所以我们不用关心数据是如何收集的。如果采用命令式编程,大家能想象到代码的糟糕程度。

并且在上面的场景中,就算我们再用多几张信用卡用来支付也不会增加太多代码量,这就是Monoid的优势。

monoidTuple3在这里的作用是组合Monoid,它能组合任何三个Monoid,是一段复用性非常强的代码。当然读者也可以自己写一个monoidTuple2用于组合两个Monoid。

小结

从文章开头的思维导图可以看出来,这里少写了一个函数式通用结构:Monad。少写的原因是:文章的文字量已经接近1.2W字了,作为一篇博客,内容量已经是过于多了。所以这里就暂时就不继续Monad结构了。对函数式通用结构有兴趣的同学可以继续关注我的博客/公众号:代码之外的程序员(懒鬼,一年没更新了还好意思叫我关注。好吧,我今年会努力保持跟新的)。笔者会慢慢继续更新函数式通用结构中其它的实用结构的。

函数式编程,大家可以不局限于Kotlin,因为FP更多是一种思想,类似一条条代数公式,和语言是无关的。当大家掌握了FP之后,就算是切换到别的FP语言中,大部分

学习函数式编程建议

这里给大家一个学习建议,我知道大家看到一篇感兴趣的文章的时候,都喜欢当一个马来人(mark)。大家回想下,收藏夹里面有多少篇文章是大家只看了题目的?感兴趣的东西,要马上过一遍,因为这个时候你的热情是最高的,过了一天你可能就没兴趣了。学习就是一个这样的过程:兴趣 -> 理解 -> 实战 -> 深入

所以这里建议有兴趣的同学(看到这里的同学很赞),下载Demo体验一下,自己再尝试写感兴趣的部分。最后祝大家尽快在自己的项目中愉快地使用FP风格进行编程。

Demo地址

Demo: KotlinFp

Demo是开源库中的KotlinFp项目,请使用IntelliJ打开。如果觉得本文对你有帮助的话,可以点一下star以资鼓励😊。

友情推荐

如果下面的项目你有兴趣的话,也可以点进去看看。如果觉得还可以的话,可以点一下star

关于MVVM的两个项目的文档还在整理中,笔者会尽快整理出来。有兴趣的同学可以关注下我的github动态,最后,我写的这么辛苦大家记得点个star哦。