Contract,開發者和 Kotlin 編譯器之間的契約
- 2020 年 1 月 23 日
- 筆記
相比 Java,使用 Kotlin 編程的時候,我們和kotlin編譯器的交互行為會更多一些,比如我們可以通過inline
來控制位元組碼的輸出結果,使用註解也可以修改編譯輸出的class文件。
這裡介紹一個和kotlin編譯器更加好玩的特性,contract。可以理解成中文裡面的契約。
不夠智慧的 Kotlin 編譯器
Kotlin編譯器向來是比較智慧的,比如做類型推斷和smart cast
等。但是有些時候,顯得不是那麼智慧,比如下面的這段程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 |
data class News(val publisherId: Int, val title: String) //檢查標題是否合法,如果title為null或者內容為空返回false fun News?.isTitleValid(): Boolean { return this != null && title.isNotEmpty() } fun testNewsTitleValid(news: News?) { if (news.isTitleValid()) { news.title //編譯失敗 並報錯 //Only safe (?.) or non-null asserted (!!.) calls //are allowed on a nullable receiver of type News? } } |
---|
上面的程式碼會讓我們覺得Kotlin編譯器很不智慧,甚至是有些笨拙。
news.isTitleValid()
返回true,我們可以推測出news.title
不為null,也能推斷出news不為null- 但是即使這樣,我們使用
news.title
會導致編譯報錯Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type News?
- 所以,想要編譯通過,我們要麼繼續使用
news?.title
或者是news!!.title
,但無論哪一種都不夠優雅
所以不爭的結論就是,Kotlin編譯器在if
語句內部無法推斷news
是非null的。
為什麼 Kotlin編譯器不能推斷出來呢
可能有人會想,我覺得挺簡單的啊,應該可以推斷出來吧。
是的,如果僅僅以例子中如此簡單的實現,大家都會覺得可以推斷出來
但是
- 現實中的實踐程式碼往往會比上面的複雜,比如涉及到多個調用和更加複雜的方法體實現等等
- 縱使可以做到,編譯器也需要花費資源和時間來分析上下文,這其中隨著層級加深,資源消耗和編譯耗時也會增加。
所以,不能推斷也是有對應的考慮的。
契約是什麼
所以我們面臨的現實情況是
- 作為開發者,我們了解較多的情況,比如
News?.isTitleValid
返回true,代表News實例不為null - 而編譯器,由於上面的原因或者其他原因,不知道足夠的資訊,無法做到和開發者一樣做相同的推斷
於是,開發者和編譯器之間可以建立一個這樣的契約
- 開發者將關於方法的額外資訊提供給編譯器,還是以
News?.isTitleValid
返回true,代表News實例不為null為例 - 編譯器在編譯的時候,發現
News?.isTitleValid
為true後,按照開發者預期,轉換成非空的News實例,讓開發者可以直接調用
而 Kotlin 從1.3版本引入了Contract(契約),用來解決我們剛剛提到的問題。
應用契約
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@ExperimentalContracts fun News?.isTitleValid(): Boolean { //contract 開始 contract { returns(true) implies (this@isTitleValid is News) } //contract 結束 return this != null && title.isNotEmpty() } @ExperimentalContracts fun testNewsTitleValid(news: News?) { if (news?.isTitleValid() == true) { news.title } } |
---|
關於上面程式碼的一些簡單解釋
- contract 採用DSL方式聲明
returns(true) implies (
[email protected]
is News)
代表如果方法返回(returns) true,表明(implies)[email protected]
是News實例,而不是News?的實例,即[email protected]
為非null- 聲明使用Contract的方法和其被調用的方法都需要使用
@ExperimentalContracts
(後面章節會提到)
其他的契約實現
上面的契約為returns(true) implies
,除此之外,還有
- returns(false) implies
- returns(null) implies
- returns implies
- returnsNotNull implies
- callsInPlace
returns(false) implies
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@ExperimentalContracts fun News?.isFake(): Boolean { contract { returns(false) implies (this@isFake is News) } return this == null || this.publisherId == 1980 } @ExperimentalContracts fun testNewsIsFake(news: News?) { if (news.isFake()) { news?.title } else { news.title } } |
---|
- 當方法
News?.isFake
返回false,則表明[email protected]
是News
實例,非null
return(null) implies
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@ExperimentalContracts fun News?.copy(): Any? { contract { returns(null) implies (this@copy is News) } return if (this == null) { "EMPTY" } else { null } } @ExperimentalContracts fun testNewsCopy(news: News?) { if (news.copy() == null) { news.title } else { news?.title } } |
---|
- 當方法
News?.copy
返回null時,[email protected]
是News
實例,非null
returns implies
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@ExperimentalContracts fun News?.validate() { contract { returns() implies (this@validate is News) } if (this == null) { throw IllegalStateException("null instance") } if (publisherId < 0) { throw IllegalStateException("publisherId is less than 0") } if (title.isEmpty()) { throw IllegalStateException("title is empty") } } @ExperimentalContracts fun testNewsValidate(news: News?) { news.validate() news.title } |
---|
- 如果方法
News?.validate()
順利執行完畢,不拋出異常,則[email protected]
是News
實例,非null
returnsNotNull implies
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@ExperimentalContracts fun News?.getTitleHashCode(): Int? { contract { returnsNotNull() implies (this@getTitleHashCode is News) } return this?.title?.hashCode() } @ExperimentalContracts fun testNewsGetTitleHashCode(news: News?) { if (news.getTitleHashCode() != null) { news.title } else { news?.title } } |
---|
- 如果
News?.getTitleHashCode()
返回為非null,則[email protected]
是News
實例,非null
callsInPlace 原地調用
callsInPlace(lambda, kind)和之前的契約不同,它讓我們有能力告知編譯器,lambda在什麼時候,什麼地方,以及執行次數等資訊。
同樣,我們繼續看這樣一段程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
package com.example.androidcontractsample fun getAppVersion() { val appVersion: Int safeRun { appVersion = 50 } } //安全運行runFunction,捕獲異常 inline fun safeRun(runFunction: () -> Unit) { try { runFunction.invoke() } catch(t: Throwable) { t.printStackTrace() } } |
---|
當我們執行編譯的時候,會得到這樣的錯誤資訊Captured values initialization is forbidden due to possible reassignment
因為上面的程式碼,也存在這裡開發者知道一些資訊,而編譯器不知道的情況
對於編譯器來說
- 無法確定
runFunction
實參是否會執行 - 無法確定
runFunction
實參是否只執行一次還是多次(val賦值多次會出錯) - 無法確定
runFunction
實參執行時,是否getappVersion已經執行完畢
可能的結果
runFunction
沒有執行,appVersion
處於未初始化狀態runFunction
執行多次,appVersion
被多次賦值,對於val是禁止的。
改進方案
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract @ExperimentalContracts fun getAppVersion() { val appVersion: Int safeRun { appVersion = 50 } } @ExperimentalContracts fun safeRun(runFunction: () -> Unit) { contract { //使用EXACTLY_ONCE callsInPlace(runFunction, InvocationKind.EXACTLY_ONCE) } try { runFunction() } catch (t: Throwable) { t.printStackTrace() } } |
---|
通過契約上面的程式碼實現了
safeRun
會在getAppVersion
執行的過程中執行,不會等到getAppVersion
執行完畢後執行safeRun
會確保runFunction
只會執行一次,不會多次執行
注意:官方說使用callsInPlace作用的方法必須inline(A function declaring the callsInPlace effect must be inline.)。但是經過驗證不inline也沒有問題,只是對應的實現方式不同。
除此之外,上面提到的InvocationKind 有這樣幾個變數
- AT_MOST_ONCE 做多調用一次
- EXACTLY_ONCE 只調用一次
- AT_LEAST_ONCE 最少執行一次
- UNKNOWN (the default). 未知,默認值
應用Contract的問題
由於目前Contract還處於實驗階段,需要使用相關的註解來表明開發者明確這一特性(以後可能修改,並自願承擔相應的變動和後果)。
目前我們可以使用UseExperimental
和ExperimentalContracts
兩種註解,以下為具體的使用示例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@UseExperimental(ExperimentalContracts::class) fun String?.isOK(): Boolean { contract { returns(true) implies(this@isOK is String) } return this != null && this.isNotEmpty() } @ExperimentalContracts fun String?.isGood(): Boolean { contract { returns(true) implies(this@isGood is String) } return this != null && this.isNotEmpty() } |
---|
非 Android項目
對於非 Android項目,會有另外一個非註解的方式,那就是為模組增加編譯選項。如下圖。

當然,你也可以在模組的配置文件,增加-Xuse-experimental=kotlin.contracts.ExperimentalContracts
到compilerSettings
的additionalArguments
中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
<module type="JAVA_MODULE" version="4"> <component name="FacetManager"> <facet type="kotlin-language" name="Kotlin"> <configuration version="3" platform="JVM 1.8" useProjectSettings="false"> <compilerSettings> <option name="additionalArguments" value="-version -Xuse-experimental=kotlin.contracts.ExperimentalContracts" /> </compilerSettings> <compilerArguments> <option name="jvmTarget" value="1.8" /> <option name="languageVersion" value="1.3" /> <option name="apiVersion" value="1.3" /> </compilerArguments> </configuration> </facet> </component> <component name="NewModuleRootManager" inherit-compiler-output="true"> <exclude-output /> <content url="file://$MODULE_DIR$"> <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> </content> <orderEntry type="inheritedJdk" /> <orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="library" name="KotlinJavaRuntime" level="project" /> </component> </module |
---|
當方法行為與契約不符
- 這種情況是可能且容易出現的,因為Contract並沒有校驗機制處理。
- 當這種情況出現,就意味著我們向編譯器提供了虛假的輔助資訊
- 一旦問題出現,對應的結果結果就是導致應用運行時崩潰。
比如下面的例子,我們的方法與契約不符
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@ExperimentalContracts fun validateByMistake(news: News?): Boolean { contract { returns(true) implies (news is News) } return true } @ExperimentalContracts fun testValidateByMistake(news: News?) { if (validateByMistake(news)) { news.title } } |
---|
當然隨之而來的就是運行時的崩潰
1 2 3 4 5 6 7 8 |
java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String com.example.androidcontractsample.News.getTitle()' on a null object reference at com.example.androidcontractsample.NewsKt.testValidateByMistake(News.kt:91) at com.example.androidcontractsample.MainActivity.onCreate(MainActivity.kt:13) at android.app.Activity.performCreate(Activity.java:7698) at android.app.Activity.performCreate(Activity.java:7687) at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1299) at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3096) … 11 more |
---|
所以作為開發者,我們需要小心謹慎避免犯這種錯誤。
注意事項
- Contract 自1.3才引入,而且是實驗性的功能,未來的實現方式可能會有變動
- Contract 目前只適用於top-level的方法,否則將會編譯失敗
Contract 如今還是實驗功能,用還是不用
- 是的,正如前面提到的Contract屬於實驗階段,後期的規劃,可能是作為正式功能引入還是變更實施方案,還是相對未知的。
- 但是僅以個人的觀點來看,還是推薦使用的。因為我覺得有些技術不需要等到穩定或者正式階段就可以應用。
References
- https://www.kotlindevelopment.com/help-yourself-and-the-compiler-with-contracts/
- https://ncorti.com/blog/discovering-kotlin-contracts