JetBrains开发者日见闻(二)之Kotlin1.3的新特性(Contract契约与协程篇)

3,884

简述: 上接上篇文章,今天我们来讲点Kotlin 1.3版本中比较时髦的东西,那么,今天就开始第二篇,看过一些大佬写关于Kotlin 1.3版本新特性的文章,基本上都是翻译了Kotlin Blog的官网博客。今天我不打算这么讲,既然今天的主题是时髦那就讲点有意思的东西。就像JetBrains开发者日上布道师Hali在讲Kotlin1.3新特性的时候完全就不用PPT的,拿起代码就是干。一起来看下今天提纲:

一、Coroutine协程转正,Kotlin/Native 1.0Beta

1、Coroutine协程的基本介绍

协程涉及内容很多,不是一句话就能讲清楚的,鉴于文章篇幅问题,所以这里不会去展开,后续会有专门系列文章去深入探讨。这里只是做个基本介绍。

  • 协程转正

Kotlin 1.3版本最重要的莫过于协程转正,协程实际上在官方的库中早就有应用,但是由于API不稳定一直在Experimental中,如果你之前就玩过协程的话,对比正式版本Coroutine API上还是有那么一点变化的。协程实际上就是轻量级的线程,一个线程中可以执行多个协程。

  • 协程作用

协程给开发者最大的感觉就是很好地解决了回调地狱的问题,在代码层面将异步的任务写成同步的调用形式。而且不会阻塞当前的线程。 对于Android开发者而言,为了比较好地解决多请求回调的问题,我们自然就想到了RxJava。RxJava确实可以在一定程度下缓解回调地狱问题,但是还是无法摆脱使用回调的事实。那么协程将带你用一个全新的方式来解决这个问题.在Android开发中Kotlin的Coroutine协程是基本可以替代RxJava,并且此次大会上来自Google中国技术团队负责人就演讲关于Corouine在Android中的使用,并且还说不久Retrofit库将支持协程中的suspend函数。此外Jake Wharton 大神写了一个Retrofit支持协程Coroutine的adapter库 retrofit2-kotlin-coroutines-adapter. 对于使用Kotlin开发Android的新项目来说,建议你不妨尝试使用Corouine来替代RxJava来用,也许你会觉得是真的爽啊。不信的话可以来看个协程在Android中应用的例子(伪代码):

suspend fun updateData(showId: Long) = coroutineScope {
    val localCacheDeferred = async {//启动一个异步任务处理本地耗时IO
        localDataStore.getShow(showId)
    }
    val remoteDataDeferred1 = async {//启动一个异步任务网络请求1
        remoteSource1.getShow(showId)
    }
    val remoteDataDeferred2 = async {//启动一个异步任务网络请求2
        remoteSource2.getShow(showId)
    }

    val localResult = localCacheDeferred.await()//调用await函数会挂起等待,直到执行任务结果返回,根本不需要出传入一个callback等待结果回来回调。
    val remoteResult1 = remoteDataDeferred1.await()
    val remoteResult2 = remoteDataDeferred2.await()
    val mergedResult = handleMerged(localResult, remoteResult1, remoteResult2)
    localDataStore.saveShow(mergedResult)
}

可以看到以上涉及到了三个异步任务,然而只需要使用await函数挂起等待结果返回即可,根本就不需要callback回调,可以看到整个代码看起来都是同步的,实际上包含三个异步任务。可以看到协程使用起来还是很爽的。

  • 3、解决回调地狱其他例子

不仅仅是Kotlin有这样的应用例子 ,如果你对JavaScript有所了解,JavaScript也是这样的,老版的JS执行异步任务一般用的是Promise或者ajax方式,一个异步执行后面总能带一个callback, 一旦涉及稍微复杂一点页面场景就出现了callback嵌套(回调地狱) 如果对ES6的语法新特性熟悉的话. 在Es6语法新增了async、await函数,也是将异步任务写成同步的调用,解决了JS中回调地狱的问题。

  • 4、其他语言中的协程

协程不仅仅是Kotlin这门语言才有,相反在它出来之前老早就有了,诸如Python中的协程还有window编程的纤程,实际上和协程差不多。记得前段时间看到国外一篇文章说Java内部也在研究一个类似协程的轻量级线程框架项目(Loom),具体仍然还在试验阶段。想想Oracle这尿性估计得到Java 14之后吧,感兴趣的可以去具体了解下。

2、Kotlin/Native 1.0Beta

关于Kotlin/Native这里就不详细介绍了,感兴趣去参考我的上篇文章JetBrains开发者日见闻(一)之Kotlin/Native 尝鲜篇,里面有很多Kotlin/Native的详细介绍。

二、有意思的Contract 契约(Experimental)

看到标题有趣的contract契约,确实挺有趣也是非常实用的一个语法点。接下来一起看下Kotlin 1.3版本带来一个好玩的语法特性Contract契约,但是cotract还是在Experimental, API后期可能有变动,建议可以提前玩玩,但是先不要引入到生产环境中.

1、为何需要Contract契约

我们都知道Kotlin中有个非常nice的功能就是类型智能推导(官方称为smart cast), 不知道小伙伴们在使用Kotlin开发的过程中有没有遇到过这样的场景,会发现有时候智能推导能够正确识别出来,有时候却失败了。 不知道大家有没有去深入研究过这个问题啊,那么这里将会给出一个例子复现那种场景,一起来看下:

  • 案例一
//在Java中定义一个生成token的函数的类,并且这个函数有可能返回null
package com.mikyou.news;

import org.jetbrains.annotations.Nullable;

public class TokenGenerator {
    public @Nullable String generateToken(String type) {
        return type.isEmpty() ? null : "2s4dfhj8aeddfduvcqopdflfgjfgfgj";
    }
}

//在Kotlin中去调用这个函数并生成可空类型String接收
import com.mikyou.news.TokenGenerator

fun main(args: Array<String>) {
    val token: String? = TokenGenerator().generateToken("kotlin")
    if (token != null && token.isNotBlank()) {//这里做判空处理
        println("token length is ${token.length}")//这里token.length编译正常并且就进行了smart cast
    }
}

将鼠标移动到token.length中token上编译器就会告知你已经被smart cast处理过了

  • 案例二 这时候的你突然想把判空处理检查放在一个checkTokenIsValid函数处理,在代码职责和可读性那块完全合情合理。但是如果你这么做了,神奇的事就悄然发生了。
fun main(args: Array<String>) {
    val token: String? = TokenGenerator().generateToken("kotlin")
    if (checkTokenIsValid(token)) {//这里判空处理交于函数来处理,根据函数返回值做判断
        println("token length is ${token.length}")//编译异常: 报token是个可空类型,需要做判空处理。这时候是不是就很郁闷了
    }
}

fun checkTokenIsValid(token: String?): Boolean{
    return token != null && token.isNotBlank()
}

你会发现token.length那报错了提示你进行判空处理,如下图所示,是不是一脸懵逼,实际上你在函数中已经做了判空处理啊。但是编译器说在它所知作用域内token就是个可空类型,所以就必须判空。这时候你就会觉得了哪里智能?

遇到上述的场景,相信很多人是不是都是使用 !! 来解决的啊。

//使用!!来解决问题
fun main(args: Array<String>) {
    val token: String? = TokenGenerator().generateToken("kotlin")
    if (checkTokenIsValid(token)) {//这里判空处理交于函数来处理,根据函数返回值做判断
        println("token length is ${token!!.length}")//编译正常: 使用!!强制告诉编译器这里不为null
    }
}

fun checkTokenIsValid(token: String?): Boolean{
    return token != null && token.isNotBlank()
}
  • 案例三: 使用官方内置判断函数isNullOrBlank函数处理

使用Kotlin内置的函数去处理,你又会更加懵逼了...

fun main(args: Array<String>) {
    val token: String? = TokenGenerator().generateToken("kotlin")
    if (!token.isNullOrBlank()) {//这里判空处理交于函数来处理,根据函数返回值做判断
        println("token length is ${token.length}")//编译正常: 使用isNullOrBlank取反操作,这里智能推导正常识别
    }
}

看完这三个案例是不是一脸懵逼啊,同样都是定义成一个函数为啥我们自己的函数不能被识别智能推导,而Kotlin内置就可以呢,内置那个函数里面到底有什么黑魔法。于是我们似乎发现了问题的本质,就是对比一下isNullOrBlank函数实现有什么不同不就明白了吗?打开isNullOrBlank函数源码:

@kotlin.internal.InlineOnly
public inline fun CharSequence?.isNullOrBlank(): Boolean {
    contract {//这里似乎不同,多了个contract
        returns(false) implies (this@isNullOrBlank != null)
    }

    return this == null || this.isBlank()//这里基本都是正常的判空
}

通过查看isNullOrBlank函数源码,似乎我发现了一个新的东西contract,没错这就是我们今天要分析的主角Contract 契约

  • 案例四: 利用自定义contract契约让checkTokenIsValid函数具有被编译器智能推导识别的黑魔法
@ExperimentalContracts //由于Contract契约API还是Experimental,所以需要使用ExperimentalContracts注解声明
fun main(args: Array<String>) {
    val token: String? = TokenGenerator().generateToken("kotlin")
    if (checkTokenIsValid(token)) {//这里判空处理交于函数来处理,根据函数返回值做判断
        println("token length is ${token.length}")//编译正常: 使用自定义契约实现,这里智能推导正常识别
    }
}

@ExperimentalContracts //由于Contract契约API还是Experimental,所以需要使用ExperimentalContracts注解声明
fun checkTokenIsValid(token: String?): Boolean{
    contract {
        returns(true) implies (token != null)
    }
    return token != null && token.isNotBlank()
}

通过以上几个例子对比,是不是发现契约很神奇,貌似它有和编译器沟通说话方式,告诉它这里是smart cast,不要再提示我判空处理了。现在就揭开Contract神秘面纱,请接着往下看。

2、Contract契约基本介绍

  • 基本定义

Kotlin中的Contract契约是一种向编译器通知函数行为的方法。 就像上面所说那样,貌似它能告诉编译器此时它行为是什么。

  • 基本格式
//伪代码
fun someThing(){
    contract{
       ...//get some effect
    }
}

上述代码意思是: 调用函数someThing并产生某种效果

是不是一脸懵逼,好抽象啊,不慌来一个实例解释一下:

@ExperimentalContracts //由于Contract契约API还是Experimental,所以需要使用ExperimentalContracts注解声明
fun checkTokenIsValid(token: String?): Boolean{
    contract {
        returns(true) implies (token != null)
    }
    return token != null && token.isNotBlank()
}
//这里契约的意思是: 调用checkTokenIsValid函数,会产生这样的效果: 如果返回值是true, 那就意味着token != null. 把这个契约行为告知到给编译器,编译器就知道了下次碰到这种情形,你的token就是非空的,自然就smart cast了。注意: 编译器下次才能识别,所以当你改了契约后,你会发现smart cast不会马上生效,而是删除后重新调用才可生效。

3、Kotlin源码中Contract契约的应用

尽管在Contract契约目前还是处于Experimental状态,但是在Kotlin之前的版本标准库就已经大量使用Contract契约。包括上述例子所看到的isNullOrBlank函数就用到了契约,你可以找到1.2版本Kotlin源码中就能轻松找到。那么这里就举几个常见的例子。

  • CharSequence类扩展函数isNullOrBlank()、isNullOrEmpty()
@kotlin.internal.InlineOnly
public inline fun CharSequence?.isNullOrBlank(): Boolean {

    contract {
        returns(false) implies (this@isNullOrBlank != null)
    }

    return this == null || this.isBlank()
}

契约解释: 这里契约表示告诉编译器:调用isNullOrBlank()扩展函数产生效果是如果该函数的返回值是false,那么就意味着当前CharSequence实例不为空。所以我们可以发现一个细节当你调用isNullOrBlank()只有在取反的时候,smart cast才会生效,不信可以自己试试。

  • requireNotNull函数(这个函数相信大家都用过吧)
@kotlin.internal.InlineOnly
public inline fun <T : Any> requireNotNull(value: T?, lazyMessage: () -> Any): T {
    contract {
        returns() implies (value != null)
    }

    if (value == null) {
        val message = lazyMessage()
        throw IllegalArgumentException(message.toString())
    } else {
        return value
    }
}

契约解释: 这里可以看到和上面有点不一样,不带参数的returns()函数,这表示告诉编译器:调用requireNotNull函数后产生效果是如果该函数正常返回,没有异常抛出,那么就意味着value不为空

  • 常见标准库函数run,also,with,apply,let(这些函数大家再熟悉不过吧,每个里面都用到contract契约)
//以apply函数举例,其他函数同理
@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block()
    return this
}

契约解释: 看到这个契约是不是感觉一脸懵逼,不再是returns函数了,而是callsInPlace函数,还带传入一个InvocationKind.EXACTLY_ONCE参数又是什么呢? 该契约表示告诉编译器:调用apply函数后产生效果是指定block lamba表达式参数在适当的位置被调用。适当位置就是block lambda表达式只能在自己函数(这里就是指外层apply函数)被调用期间被调用,当apply函数被调用结束后,block表达式不能被执行,并且指定了InvocationKind.EXACTLY_ONCE表示block lambda表达式只能被调用一次,此外这个外层函数还必须是个inline内联函数。

4、Contract契约背后原理(Contract源码分析)

看到上述Contract契约在源码中广泛的应用,并且看到上个例子分别代表三种不同类型的契约。此时的你是不是对Contract的源码顿时产生了浓厚的兴趣呢?下面我们将去简要剖析下Contract契约的源码。一说到分析源码很多人会脑袋疼,那是你可能没找到很好分析源码方法。

下面给出个人分析源码的一个习惯(一直沿用至今,效果感觉还是不错的):

源码分析的规则: 需要分析一个源码问题,首先确定问题域,然后再列举出问题域中所有参与的角色或者名词概念,每个角色或名词所起的作用,角色与角色之间的关系,他们是如何通信的,如何建立联系的。

那么,我们就用这个原则一步步揭开Contract神秘面纱,让你对Contract的API有个更深全面的了解。

  • 第一步,确定问题域(也就是你需要研究东西)

梳理和理解Contract契约背后原理,以及它的工作流程

  • 第二步,确定问题域中参与角色(也就是Contract中那些API类),先给出一个contracts package中所有类和接口

  • 第三步,理清它们各自职责。
//用来表示一个函数被调用的效果
public interface Effect 
//继承Effect接口,用来表示在观察函数调用后另一个效果之后,某些条件的效果为true。
public interface ConditionalEffect : Effect 

//继承Effect接口,用来表示一个函数调用后的结果(这个一般就是最为普通的Effect)
public interface SimpleEffect : Effect {
    public infix fun implies(booleanExpression: Boolean): ConditionalEffect //infix表明了implies函数是一个中缀函数,那么它调用起来就像是中缀表达式一样
}
//继承SimpleEffect接口,用来表示当一个函数正常返回给定的返回值
public interface Returns : SimpleEffect
//继承SimpleEffect接口,用来表示当一个函数正常返回非空的返回值
public interface ReturnsNotNull : SimpleEffect
//继承Effect接口,用来表示调用函数式参数(lambda表达式参数)的效果,并且函数式参数(lambda表达式参数)只能在自己函数被调用期间被调用,当自己函数被调用结束后,函数式参数(lambda表达式参数)不能被执行.
public interface CallsInPlace : Effect

然后重点来看下ContractBuilder.kt文件,这个实际上是Contract契约一个DSL Builder以及暴露给最外面一个contract函数.

//ContractBuilder接口聚合了不同的Effect返回对应接口对象的函数
public interface ContractBuilder {
    @ContractsDsl public fun returns(): Returns

    @ContractsDsl public fun returns(value: Any?): Returns
    
    @ContractsDsl public fun returnsNotNull(): ReturnsNotNull

    @ContractsDsl public fun <R> callsInPlace(lambda: Function<R>, kind: InvocationKind = InvocationKind.UNKNOWN): CallsInPlace
}

//用于枚举callsInPlace函数lambda表达式参数被调用次数情况
public enum class InvocationKind {
    //最多只能被调用一次(不能调用或只能被调用1次)
    @ContractsDsl AT_MOST_ONCE,
    //至少被调用一次(只能调用1次或多次)
    @ContractsDsl AT_LEAST_ONCE,
    //仅仅只能调用一次
    @ContractsDsl EXACTLY_ONCE,
    //不能确定被调用多少次
    @ContractsDsl UNKNOWN
}
  • 第四步,理清Effect之间关系。

  • 第五步,分析一个例子模拟contract工作流程
fun checkTokenIsValid(token: String?): Boolean{
    contract {//首先这里实际上就是调用那个contract函数传入一个带ContractBuilder类型返回值的Lambad表达式。
        returns(true) implies (token != null)
        //然后这里开始定义契约规则,lambda表达式体内就是ContractBuilder,所以这里的returns(value)函数实际上相当于this.returns(true);
        //再接着分析implies函数这是一个中缀调用可以看到写起来像中缀表达式,实际上相当于returns(value)函数返回一个Returns接口对象,Returns接口是继承了SimpleEffect接口(带有implies中缀函数)的,所以直接用Returns接口对象中缀调用implies函数
    }
    return token != null && token.isNotBlank()
}

其实分析完后发现契约描述的实际上就是函数的行为,包括函数返回值、函数中lambda表达式形参在函数内部执行规则。把这些行为约束告知给编译器,可以节省编译器智能分析的时间,相当于开发者帮助编译器更快更高效做一些智能推导事情。

5、自定义Contract契约

实际上自定义在上述例子中早就给出来,由于在Kotlin1.3版本Contract还处于实验阶段,所以不能直接使用。看了源码中各种契约使用例子,相信自定义一个契约应该很简单了。

//这里给出一个instanceOf类型smart cast例子
data class Message(val msg: String)
 
@ExperimentalContracts //加上Experimental注解
fun handleMessage(message: Any?) {
    if (isInstanceOf(message)) {
        println(message.msg) 
    }
}
 
@ExperimentalContracts
fun isInstanceOf(message: Any?): Boolean {
    contract { 
        returns(true) implies (message is Message)
    }
    return message is Message
}

其实说了那么多,大家有没有体会到一点东西,契约实际上就是开发者和编译器之间定义一个协议规则,开发者通过契约去向编译器传递一些特殊效果Effect。而且这些效果都是针对函数调用的行为来确定的。所以从另一方面也说明了开发者必须足够了解业务场景才能使用契约,因为这相当于编译器把一些操作信任于开发者来处理,开发者使用空间灵活度越高,那么危险性也越大,切记不能滥用。

6、Contract契约使用限制

虽然Kotlin契约看起来很棒,但目前的语法目前还不稳定,并且未来API可能会有改变。不过有了上述一系列分析,就算将来变换了,你也能很快理解。

  • 1、我们只能在顶层函数体内使用Contract契约,即我们不能在成员和类函数上使用它们。
  • 2、Contract调用声明必须是函数体内第一条语句
  • 3、就像我上面说得那样,编译器无条件地信任契约;这意味着程序员负责编写正确合理的契约。

尽管Contract还处于实验阶段,但是我们也看到了在很久之前版本中stalib标准库源码中就大量使用了契约,所以预测在后续版本中API改动也不会很大,所以这时候深入分析还是值得的。

三、结语

由于Contract契约深入分析一下,占用文章篇幅过大。所以其他新特性相关介绍和分析挪到了下篇文章,欢迎持续关注~~~。

Kotlin系列文章,欢迎查看:

原创系列:

翻译系列:

实战系列:

欢迎关注Kotlin开发者联盟,这里有最新Kotlin技术文章,每周会不定期翻译一篇Kotlin国外技术文章。如果你也喜欢Kotlin,欢迎加入我们~~~