Kotlin Contract

签订契约,让 kotlinc 成为魔法编译器吧~

0x0 智能,但不够智能

Kotlin 有一个比较有意思的特性,叫 Smart Cast。比如在 Java 里,我们可能会这么写:

void test(Object p) {
    if (p instanceof String) {
        System.out.println(((String) p).length());
    }
}

而在 Kotlin 中,我们可以这么写:

fun test(p: Any) {
    if (p is String) {
        println(p.length)
    }
}

或者,在 Kotlin 的可空检查中:

fun test(p: String?) {
    if (!p.isNullOrEmpty()) {
        println(p.length)
    }
}

println 调用 p.length 时,我们不需要再进行一次非空断言。

然而,有的时候 Smart Cast 却不能生效,例如:

data class News(val publisher: String, val title: String)

fun News?.isTitleValid(): Boolean {
    return this != null && title.isNotEmpty()
}

fun play(news: News?) {
    if (news.isTitleValid()) {
        println(news.title) // Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type News?
    }
}

稍有常识的人都能看出,如果我们的代码继续执行,这个可空的字符串 s,在 s.length 的时候难道会为空吗?当然不会。

然而编译器不这么觉得。由于编译器并不能深入每一个函数的数据流,根据函数 play 内当前提供的信息,编译器无法推断出此时 news 一定非空。当然我们可以将 News?.isTitleValid() 手动内联,但是如果调用点很多,手动内联并不可取。

实际上,在早期版本的 Kotlin 中,CharSequence#isNullOrEmpty() 也无法完成 Smart Cast。但是自 Kotlin 1.2 开始,Kotlin 增加了一个名为 Contract 的特性(这个特性自 1.3 开始对 Kotlin 标准库实装,对开发者编写的代码进入实验阶段)。借助 Contract,可以向编译器提供额外的函数行为信息,帮助编译器进行推断。

如果查看新版本的 CharSequence#isNullOrEmpty() 源代码,可以看到在函数开头有一段 DSL。这就是 Contract 在代码中的体现。

public inline fun CharSequence?.isNullOrEmpty(): Boolean {
    contract {
        returns(false) implies (this@isNullOrEmpty != null)
    }

    return this == null || this.length == 0
}

0x1 Contract,开发者和编译器的契约

Contract 的本意为契约。顾名思义,是开发者和编译器约定好的一系列事情。

得益于 Kotlin 的中缀函数语法,上面的 Contract DSL 理解起来还是比较容易的。它的意思是:如果 isNullOrEmpty() 返回值为 false,表明当前 CharSequence 对象不为 null

returns() 函数的返回值是 Returns 接口,后者继承于 SimpleEffect(进一步地再继承于 Effect)接口。implies 函数的左右两个参数分别为 SimpleEffectBoolean,返回值为 ConditionalEffect

尝试将 Contract 应用到 News#isTitleValid() 中:

data class News(val publisher: String, val title: String)

@OptIn(ExperimentalContracts::class)
fun News?.isTitleValid(): Boolean {
    contract {
        returns(true) implies (this@isTitleValid != null)
    }

    return this != null && title.isNotEmpty()
}

fun play(news: News?) {
    if (news.isTitleValid()) {
        println(news.title)
    }
}

需要注意的是,由于 Contract 还是一个实验特性,因此需要通过注解开启。

在有了 Contract 的加持之后,news 就可以被正确推导为 News 而不是 News? 了。

我们可以将上面的 Contract 抽象描述为 returns(false) implies BooleanExpression。除了这一种之外,还有其他几种形式。

returns(true) implies BooleanExpression

这个和 return(false) implies BooleanExpression 类似,只是将 Contract 达成的条件改为了函数返回 true。比如这样:

@OptIn(ExperimentalContracts::class)
fun News?.isFake(): Boolean {
    contract {
        returns(false) implies (this@isFake != null)
    }
    
    return this != null && this.publisher == "CNN"
}

fun play(news: News?) {
    if (!news.isFake()) {
        println(news.title)
    }
}

如果 News#isFake() 返回 false,表明 News 对象非空。

returns(null) implies BooleanExpression

依旧和上面的类似,不过条件变为了函数返回空。例如:

@OptIn(ExperimentalContracts::class)
fun News?.copy(): Any? {
    contract {
        returns(null) implies (this@copy is News)
    }

    return if (this == null) {
        "EMPTY"
    } else {
        null
    }
}

fun play(news: News?) {
    if (news.copy() == null) {
        println(news.publisher)
    }
}

如果 News#copy() 返回 null,表明其接收者是一个 News 实例。

returnsNonNull() implies BooleanExpression

这个和 returns(null) 刚好相反。例如:

@OptIn(ExperimentalContracts::class)
fun News?.anotherCopy(): Any? {
    contract {
        returnsNotNull() implies (this@anotherCopy is News)
    }

    return if (this == null) {
        null
    } else {
        "EMPTY"
    }
}

fun play(news: News?) {
    if (news.anotherCopy() != null) {
        println(news.publisher)
    }
}

如果 News#anotherCopy() 返回非空,表明其接收者是一个 News 实例。

returns implies BooleanExpression

这个 Contract 对返回值没有要求。只要正常返回就成立。例如:

@OptIn(ExperimentalContracts::class)
fun News?.validate() {
    contract {
        returns() implies (this@validate is News)
    }

    if (this == null) {
        throw Exception()
    }
    if (publisher.isBlank()) {
        throw IllegalArgumentException()
    }
}

fun play(news: News?) {
    news.validate()
    println(news.title)
}

如果 News#validate() 正常返回,不抛出异常,表明其接收者是一个 News 实例。

callsInPlace

除了返回值,Contract 还可以对高阶函数的调用进行约定。比如,Kotlin 基础库中的高阶函数 run 是不会对异常进行处理了,假设有个奇怪的需求需要一个不会抛异常的 safeRun,那么我们也许会这么写:

fun playWithCallsInPlace() {
    val num: Int
    safeRun {
        num = 50 // Captured values initialization is forbidden due to possible reassignment
    }
}

inline fun safeRun(block: () -> Unit) {
    try {
        block.invoke()
    } catch(t: Throwable) {
        t.printStackTrace()
    }
}

可惜,编译器并不能让我们这段代码通过编译。按照程序执行过程,我们确实可以知道,num 只会被赋值一次。而对编译器来说,它是不知道 safeRun 的实参 block 是否会被执行(不执行的话会导致 num 未被初始化)、是否会被多次执行(val 类型变量不能被多次赋值)、是否会在 playWithCallsInPlace 执行过程中执行等信息的。

这个时候,我们可以为 safeRun 加上 Contract,告知编译器这些信息:

fun playWithCallsInPlace() {
    val num: Int
    safeRun {
        num = 50
    }
}

@OptIn(ExperimentalContracts::class)
inline fun safeRun(block: () -> Unit) {
    contract {
        callsInPlace(block, kotlin.contracts.InvocationKind.EXACTLY_ONCE)
    }
    try {
        block.invoke()
    } catch(t: Throwable) {
        t.printStackTrace()
    }
}

这样,编译器就知道 block 会在 playWithCallsInPlace 执行过程中执行,并且只会被执行一次。因此,在 safeRun 中对 num 进行一次赋值操作就是合法的。

callsInPlace() 的定义如下:

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

第一个参数 lambda 是我们要约束的函数,第二个参数 kindInvocationKind 类型,表示调用类型,可以取的值有:

  • AT_MOST_ONCElambda 至多被执行一次。也就是要么不执行,要么只执行一次。
  • AT_LEAST_ONCElambda 至少被执行一次。
  • EXACTLY_ONCElambda 会被执行且只会被执行一次。
  • UNKNOWN:无法确定 lambda 的执行次数。这个是默认值。

0x2 撕毁契约

毕竟只是个约定,作为开发者想翻脸也不是不行。比如非要这么写:

@OptIn(ExperimentalContracts::class)
fun validateByMistake(news: News?): Boolean {
    contract {
        returns(true) implies (news is News)
    }
    return true
}

fun play(news: News?) {
    if (validateByMistake(news)) {
        news.title
    }
}

fun main(args: Array<String>) {
    play(null)
}

编译器一看,好家伙开发者都翻脸了,这 Contract 不要也罢,也就跟着翻脸。来人,上 NPE。

可以看出,编译器并不会检查 Contract 的正确性。因此在使用的时候需要注意。

0x3 Contract 的局限

在 Kotlin 1.3 的时候,Contract 只能写在顶层函数里,并且 callsInPlace() 只能为 inline 函数指定。在后续的版本中则去掉了这两个限制。

另外,Contract 中只能访问当前函数的参数,如果参数有成员,是不能访问的。比如:

data class Request(val arg: String?)

@OptIn(ExperimentalContracts::class)
private fun validate(request: Request?) {
    contract {
        returns() implies (request?.arg != null) // Only references to parameters are allowed in contract description
    }
    if (request == null) {
        throw IllegalArgumentException("Undefined request")
    }
    if (request.arg.isBlank()) {
        throw IllegalArgumentException("No argument is provided")
    }
}

这段代码是无法编译的。

0x4 参考资料

Contract,开发者和 Kotlin 编译器之间的契约 - 技术小黑屋 (droidyue.com)

Kotlin Contracts DSL | 萌夜雀的人头会社 (aisia.moe)

Kotlin Contracts | Baeldung on Kotlin

评论

  1. fundroid
    已编辑
    3 年前
    2021-7-26 18:20:32

    好文章!可否转载?

    • Robotxm
      博主
      fundroid
      3 年前
      2021-7-27 22:50:36

      这篇文章本是用作笔记的,很大一部分是摘自最后参考资料里列出的文章。要转载的话还是直接联系原作者转载原文章吧。

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇