1. 程式人生 > >JetBrains開發者日見聞(二)之Kotlin1.3的新特性(Contract契約與協程篇)

JetBrains開發者日見聞(二)之Kotlin1.3的新特性(Contract契約與協程篇)

簡述: 上接上篇文章,今天我們來講點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,歡迎加入我們~~~