签订契约,让 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
函数的左右两个参数分别为 SimpleEffect
和 Boolean
,返回值为 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
是我们要约束的函数,第二个参数 kind
是 InvocationKind
类型,表示调用类型,可以取的值有:
AT_MOST_ONCE
:lambda
至多被执行一次。也就是要么不执行,要么只执行一次。AT_LEAST_ONCE
:lambda
至少被执行一次。EXACTLY_ONCE
:lambda
会被执行且只会被执行一次。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)
好文章!可否转载?
这篇文章本是用作笔记的,很大一部分是摘自最后参考资料里列出的文章。要转载的话还是直接联系原作者转载原文章吧。