Kotlin的挂起函数

75 阅读10分钟

挂起函数是Kotlin协程的核心

如果只是把 thread{} 替换成 launch{},那协程比起线程也没什么特殊的优势吧?仅仅只是因为“轻量”“非阻塞”,我们就应该放弃线程,拥抱协程吗?其实,Kotlin 协程最大的优势,就在于它的挂起函数。虽然很多编程语言都有协程的特性,但目前为止,只有 Kotlin 独树一帜,引入了“挂起函数”的概念。另外尽管有些语言的协程底层,也存在“挂起恢复”的概念,但是将这一概念直接暴露给开发者,直接用于修饰一个函数的,Kotlin 算是做了一种创新。

挂起函数到底有什么神奇的呢?我们先来看一段简单的 Java 代码:

// 代码段1

getUserInfo(new CallBack() {
    @Override
    public void onSuccess(String response) {
        if (response != null) {
            System.out.println(response);
        }
    }
});

在这段代码中,我们发起了一个异步请求,从服务端查询用户的信息,通过 CallBack 返回 response。这样的代码看起来没什么问题,平时我们写代码的时候也经常写类似的代码。不过实际的商业项目不可能这么简单,有的时候,我们可能需要连续执行几个异步任务,比如说,查询用户信息 --> 查找该用户的好友列表 --> 拿到好友列表后,查找该好友的动态。这样一来,我们的代码就难免会往下面这个方向发展:

// 代码段2

getUserInfo(new CallBack() {
    @Override
    public void onSuccess(String user) {
        if (user != null) {
            System.out.println(user);
            getFriendList(user, new CallBack() {
                @Override
                public void onSuccess(String friendList) {
                    if (friendList != null) {
                        System.out.println(friendList);
                        getFeedList(friendList, new CallBack() {
                            @Override
                            public void onSuccess(String feed) {
                                if (feed != null) {
                                    System.out.println(feed);
                                }
                            }
                        });
                    }
                }
            });
        }
    }
});

只要参与过大型软件的开发,不管用的是什么编程语言,大概率都能见到过类似上面的代码模式:回调地狱。我们给它取这个名字是有原因的,以上代码存在诸多缺陷:可读性差、扩展性差、维护性差,极易出错!想象一下,如果让你基于以上代码再扩展出“超时取消”“出错重试”“进度展示”等相关功能,你会不会觉得头疼?所以这时候,就该轮到 Kotlin 协程出场了。让我们用协程的挂起函数,来重构上面的代码:

// 代码段3

val user = getUserInfo()
val friendList = getFriendList(user)
val feedList = getFeedList(friendList)

是不是简洁到了极致?这就是 Kotlin 协程的魅力:以同步的方式完成异步任务

以上代码之所以能写成类似同步的方式,关键还是在于 getUserInfo()、getFriendList()、getFeedList() 这三个请求函数的定义。

// 代码段4

// delay(1000L)用于模拟网络请求

//挂起函数
// ↓
suspend fun getUserInfo(): String {
    withContext(Dispatchers.IO) {
        delay(1000L)
    }
    return "BoyCoder"
}

//挂起函数
// ↓
suspend fun getFriendList(user: String): String {
    withContext(Dispatchers.IO) {
        delay(1000L)
    }
    return "Tom, Jack"
}

//挂起函数
// ↓
suspend fun getFeedList(list: String): String {
    withContext(Dispatchers.IO) {
        delay(1000L)
    }
    return "{FeedList..}"
}

所谓的挂起函数,其实就是比普通的函数多了一个 suspend 关键字而已。如果去掉这个 suspend 关键字,所有的函数都会变成普通函数。

代码中的 withContext(Dispatchers.IO),作用是控制协程执行的线程池。

优势场景:例如多接口的链式调用,容易陷入“回调地狱”,而在协程中调用挂起函数则能以同步代码的方式实现异步任务。

挂起函数最神奇的地方,就在于它的挂起和恢复功能。从字面上看,suspend 这个词就是“挂起”的意思,而它既然能被挂起,自然就还可以被恢复。它们两个一般是成对出现的。

表面上看起来是同步的代码,实际上也涉及到了线程切换,一行代码,切换了两个线程。比如“val user = getUserInfo()”,其中“=”左边的代码运行在主线程,而“=”右边的代码运行在 IO 线程。每一次从主线程到 IO 线程,都是一次协程挂起。每一次从 IO 线程到主线程,都是一次协程恢复。挂起和恢复,这是挂起函数特有的能力,普通函数是不具备的。挂起,只是将程序执行流程转移到了其他线程,主线程不会被阻塞。如果以上代码运行在 Android 系统,我们的 App 仍然可以响应用户的操作,主线程并不繁忙。

Kotlin 协程到底是如何做到一行代码切换两个线程的呢?其实,Kotlin 协程当中并不存在什么“魔法”。这一切的细节,都藏在了挂起函数的 suspend 关键字里。

深入理解 suspend

suspend,是 Kotlin 当中的一个关键字,它主要的作用是用于定义“挂起函数”。不过如果仔细留意的话,同样的一个函数,加上 suspend 修饰以后,它的函数类型就会发生改变。举例:

// 代码段5

fun func1(num: Int): Double {
    return num.toDouble()
}
/*
func1与func3唯一的区别
   ↓                         */
suspend fun func3(num: Int): Double {
    delay(100L)
    return num.toDouble()
}

val f1: (Int) -> Double = ::func1
val f2: suspend (Int) -> Double = ::func3

val f3: (Int) -> Double = ::func3 // 报错
val f4: suspend (Int) -> Double = ::func1 // 报错

同样是 Int 作为参数,Double 作为返回值,有没有 suspend 修饰,它们两者的函数类型是不一样的。“suspend (Int) -> Double”与“(Int) -> Double”并不能互相赋值。

高阶函数中Kotlin 的函数类型,其实只跟参数、返回值、接收者相关,不过现在又加了一条:还跟 suspend 相关。

那么,suspend 修饰的函数,到底会变成什么类型?我们将挂起函数与前面“回调地狱的代码”放在一起对比:

image.png

其实,挂起函数的本质,就是 Callback

虽然我们写出来的挂起函数并没有任何 Callback 的逻辑,但是,当 Kotlin 编译器检测到 suspend 关键字修饰的函数以后,就会自动将挂起函数转换成带有 CallBack 的函数。如果我们将上面的挂起函数反编译成 Java,结果会是这样:

// 代码段6

//                              Continuation 等价于 CallBack
//                                         ↓         
public static final Object getUserInfo(Continuation $completion) {
  ...
  return "BoyCoder";
}

从反编译的结果来看,挂起函数确实变成了一个带有 CallBack 的函数,只是这个 CallBack 换了个名字,叫做 Continuation。我们来看看 Continuation 在 Kotlin 中的定义:

// 代码段7

public interface Continuation<in T> {
// ...

//      相当于 CallBack的onSuccess   结果   
//                 ↓                 ↓
    public fun resumeWith(result: Result<T>)
}

interface CallBack {
    void onSuccess(String response);
}

根据以上定义我们其实能发现,Continuation 本质上也就是一个带有泛型参数的 CallBack,只是它的名字看起来有点吓人而已。这个“从挂起函数转换成 CallBack 函数”的过程,被叫做是 CPS 转换(Continuation-Passing-Style Transformation)。看,Kotlin 官方要将 CallBack 命名为 Continuation 的原因也出来了:Continuation 道出了它的实现原理。当然,为了理解挂起函数,我们用 CallBack 会更加简明易懂。

在 CPS 转换的过程中,函数的类型发生了变化:“suspend ()->String” 变成了 “(Continuation)-> Any?”。而这就意味着,如果你在 Java 中访问一个 Kotlin 挂起函数 getUserInfo(),会看到 Java 里的 getUserInfo() 的类型是“(Continuation)-> Object”(即接收 Continuation 为参数,返回值是 Object)。

Continuation 到底是什么

首先,只需要把握住 Continuation 的词源 Continue 即可。Continue 是“继续”的意思,Continuation 则是“接下来要做的事情”。放到程序中,Continuation 就代表了,“程序继续运行下去需要执行的代码”,“接下来要执行的代码”,或者是“剩下的代码”。

就以上面的代码为例,当程序运行 getUserInfo() 这个挂起函数的时候,它的“Continuation”则是下图红框的代码:

image.png

这样理解了 Continuation 以后,CPS 也就容易理解了,它其实就是将程序接下来要执行的代码进行传递的一种模式。而 CPS 转换,就是将原本的同步挂起函数转换成 CallBack 异步代码的过程。这个转换是编译器在背后做的:

image.png

当程序执行到 getUserInfo() 的时候,剩下的未执行代码都被一起打包了起来,以 Continuation 的形式,传递给了 getUserInfo() 的 Callback 回调当中。以上就是 Kotlin 挂起函数的核心原理,它的挂起和恢复,其实也是通过 CPS 转换来实现的。

所以,现在我们可以理出一条线索了:协程之所以是非阻塞,是因为它支持“挂起和恢复”;而挂起和恢复的能力,主要是源自于“挂起函数”;而挂起函数是由 CPS 实现的,其中的 Continuation,本质上就是 Callback。

那协程跟挂起函数之间是什么关系?

协程与挂起函数

协程 != 挂起函数,但两者关系密切,例如runBlocking函数中的block也是一个挂起函数。

关于协程和挂起函数,它们之间有着千丝万缕的联系。让我们来看个简单的例子:

// 代码段8

fun main() {
    getUserInfo() // 报错
}

suspend fun getUserInfo(): String {
    withContext(Dispatchers.IO) {
        delay(1000L)
    }
    return "BoyCoder"
}

在上面的代码中,我们直接在 main 函数当中调用了 getUserInfo() 这个挂起函数,这时候,我们发现 IDE 会报错,报错的具体内容是这样的:

image.png

这个报错信息的意思是:挂起函数,只能在协程当中被调用,或者是被其他挂起函数调用。这个意思也很好理解,对于这样的要求,我们很容易就能写出下面的代码:

// 代码段9

// 在协程中调用getUserInfo()
fun main() = runBlocking {
    val user = getUserInfo()
}

// 在另一个挂起函数中调用getUserInfo()
suspend fun anotherSuspendFunc() {
    val user = getUserInfo()
}

以上两种方式,它们之间是可以继续深入并且挖掘出共性的。回过头来看看 runBlocking 的函数签名:

// 代码段10

public actual fun <T> runBlocking(
    context: CoroutineContext, 
    block: suspend CoroutineScope.() -> T
): T {
}

重点关注它的第二个参数 block 的类型“suspend CoroutineScope.() -> T”,看到其中的 suspend 关键字了吗?原来 block 也是一个挂起函数的类型!那么,在 block 当中可以调用挂起函数,就一点也不奇怪了!所以说,虽然“协程和挂起函数”都可以调用“挂起函数”,但是协程的 Lambda,也是挂起函数。所以,它们本质上都是因为“挂起函数可以调用挂起函数”。也就是说,站在目前的阶段来看,我们可以认为:挂起和恢复,是协程的一种底层能力;而挂起函数,是这种底层能力的一种表现形式,通过暴露出来的 suspend 关键字,开发者可以在上层,非常方便地使用这种底层能力。

小结:

挂起函数可以极大地简化异步编程,让我们能够以同步的方式写异步代码。相比“回调地狱”式的代码,挂起函数写出来的代码可读性更好、扩展性更好、维护性更好,也更难出错。

要定义挂起函数,我们只需在普通函数的基础上,增加一个 suspend 关键字

suspend 这个关键字,是会改变函数类型的,“suspend (Int) -> Double”与“(Int) -> Double”并不是同一个类型。

挂起函数,由于它拥有挂起和恢复的能力,因此对于同一行代码来说,“=”左右两边的代码分别可以执行在不同的线程之上。而这一切,都是因为 Kotlin 编译器这个幕后的翻译官在起作用。

挂起函数的本质,就是 Callback。只是说,Kotlin 底层用了一个更加高大上的名字,叫 Continuation。而 Kotlin 编译器将 suspend 翻译成 Continuation 的过程,则是 CPS 转换(Continuation-Passing-Style Transformation)。这里的 Continuation 是代表了,“程序继续运行下去需要执行的代码”,“接下来要执行的代码”,或者是 “剩下的代码”。

挂起函数,只能在协程当中被调用,或者是被其他挂起函数调用。但协程中的 block,本质上仍然是挂起函数。

所以,我们可以认为:挂起和恢复是协程的一种底层能力;而挂起函数则是一种上层的表现形式。