Kotlin教程(四)可空性

胡奚冰發表於2018-03-28

寫在開頭:本人打算開始寫一個Kotlin系列的教程,一是使自己記憶和理解的更加深刻,二是可以分享給同樣想學習Kotlin的同學。系列文章的知識點會以《Kotlin實戰》這本書中順序編寫,在將書中知識點展示出來同時,我也會新增對應的Java程式碼用於對比學習和更好的理解。

Kotlin教程(一)基礎
Kotlin教程(二)函式
Kotlin教程(三)類、物件和介面
Kotlin教程(四)可空性
Kotlin教程(五)型別
Kotlin教程(六)Lambda程式設計
Kotlin教程(七)運算子過載及其他約定
Kotlin教程(八)高階函式
Kotlin教程(九)泛型


這一章實際上在《Kotlin實戰》中是第六章,在Lambda之後,但是這一章的內容實際上是Kotlin的一大特色之一。因此,我將此章的內容提到了前面彙總。

可空性

可空性是Kotlin型別系統中幫助你避免NullPointerException錯誤的特性。

可空型別

如果一個變數可能為null,對變數的方法的呼叫就是不安全的,因為這樣會導致NullPointerException。例如這樣一個Java函式:

int strLen(String s) {
    return s.length();
}
複製程式碼

如果這個函式被呼叫的時候,傳給它的是一個null實參,它就會丟擲NullPointerException。那麼你是否需要在方法中增加對null的檢查呢?這取決與你是否期望這個函式被呼叫的時候傳給它的實參可以為null。如果不可以的話,我們用Kotlin可以這樣定義:

fun strLen(s: String) = s.length
複製程式碼

看上去與Java沒有區別,但是你嘗試呼叫strLen(null) 就會發現在編譯期就會被標記成錯誤。因為在Kotlin中String 只能表示字串,而不能表示null,如果你想支援這個方法可以傳null,則需要在型別後面加上?

fun strLen(s: String?) = if(s != null) s.length else 0
複製程式碼

? 可以加在任何型別的後面來表示這個型別的變數可以儲存null引用:String?Int?MyCustomType?等。

一旦你有一個可空型別的值,能對它進行的操作也會受到限制。例如不能直接呼叫它的方法:

    val s: String? = ""
//    s.length  //錯誤,only safe(?.) or non-null asserted (!!.) calls are allowed
    s?.length   //表示如果s不為null則呼叫length屬性
    s!!.length  //表示斷言s不為null,直接呼叫length屬性,如果s執行時為null,則同樣會crash
複製程式碼

也不能把它賦值給非空型別的變數:

    val x: String? = null
//    val y: String = x  //Type mismatch
複製程式碼

也就是說,加? 和不加可以看做是兩種型別,只有與null進行比較後,編譯器才會智慧轉換這個型別。

fun strLen(s: String?) = if(s != null) s.length else 0  
複製程式碼

這個例子就與null進行比較,於是String? 型別被智慧轉換成String 型別,所以可以直接獲取length屬性。

Java有一些幫助解決NullPointerException問題的工具。比如,有些人會使用註解(@Nullable和@NotNull)來表達值得可空性。有些工具可以利用這些註解來發現可能丟擲NullPointerException的位置,但這些工具不是標準Java編譯過程的一部分,所以很難保證他們自始至終都被應用。而且在整個程式碼庫中很難使用註解標記所有可能發生錯誤的地方,讓他們都被探測到。

Kotlin的可空型別完美得解決了空指標的發生。 注意,可空的和非空的物件在執行時沒有什麼區別:可空型別並不是非空型別的包裝。所有的檢查都發生在編譯器。這意味著使用Kotlin的可空型別並不會在執行時帶來額外的開銷。

安全呼叫運算子:"?."

Kotlin的彈藥庫中最有效的一種工具就是安全呼叫運算子:?. ,它允許你爸一次null檢查和一次方法呼叫合併成一個操作。例如表示式s?.toUpperCase() 等同於if (s != null) s.toUpperCase() else null 。 換句話說,如果你檢視呼叫一個非空值得方法,這次方法呼叫會被正常地執行。但如果值是null,這次呼叫不會發生,而整個表示式的值為null。因此表示式s?.toUpperCase() 的返回型別是String?

安全呼叫同樣也能用來訪問屬性,並且可以連續獲取多層屬性:

class Address(val street: String, val city: String, val country: String)

class Company(val name: String, val address: Address?)

class Person(val name: String, val company: Company?)

fun Person.countryName(): String {
    val country = this.company?.address?.country  //多個安全呼叫連結在一起
    return if (country != null) country else "Unknown"
}
複製程式碼

Kotlin 可以讓null檢查的變得非常簡潔。在這個例子中你用一個值和null比較,如果這個值不為空就返回這個值,否則返回其他的值。在Kotlin中有更簡單的寫法。

Elvis運算子:"?:"

if (country != null) country else "Unknown" 通過Elvis運算子改寫成:

country ?: "Unknown"
複製程式碼

Elvis運算子接受兩個運算數,如果第一個運算數不為null,運算結果就是第一個運算數,如果第一個運算數為null,運算結果就是第二個運算數。 fun strLen(s: String?) = if(s != null) s.length else 0 這個例子也可以用Elvis運算子簡寫:fun strLen(s: String?) = s?.length ?: 0

安全轉換:"as?"

之前我們學習了as 運算子用於Kotlin中的型別轉換。和Java一樣,如果被轉換的值不是你試圖轉換的型別,就會丟擲ClassCastException異常。當然你可以結合is 檢查來確保這個值擁有合適的型別。但Kotlin作為一種安全簡潔的語言,有優雅的解決方案。 as? 運算子嘗試把值轉換成指定的型別,如果值不合適的型別就返回null。 一種常見的模式是把安全轉換和Elvis 運算子結合使用。例如equals方法的時候這樣的用法非常方便:

class Person(val name: String, val company: Company?) {
    override fun equals(other: Any?): Boolean {
        val o = other as? Person ?: return false  //檢查型別不匹配直接返回false
        return o.name == name && o.company == company //在安全轉換後o被智慧地轉換為Person型別
    }

    override fun hashCode(): Int = name.hashCode() * 31 + (company?.hashCode() ?: 0)
}
複製程式碼

非空斷言:"!!"

非空斷言是Kotlin提供的最簡單直接的處理可空型別值得工具,它可以把任何值轉換成非空型別。如果對null值做非空斷言,則會丟擲異常。 之前我們也演示過非空斷言的用法了:s!!.length

你可能注意到雙感嘆號看起來有點粗暴,就像你衝著編譯器咆哮。這是有意為之的,Kotlin的設計設檢視說服你思考更好的解決方案,這些方案不會使用斷言這種編譯器無法驗證的方式。

但是確實存在這樣的情況,某些問題適合用非空斷言來解決。當你在一個函式中檢查一個值是否為null。而在另一個函式中使用這個值時,這種情況下編譯器無法識別這種用是否安全。如果你確信這樣的檢查一定在其他某個函式中存在,你可能不想在使用這個值之前重複檢查。這時你就可以使用非空斷言。

"let" 函式

let函式讓處理可空表示式變得更容易。和安全呼叫運算子一起,它允許你對錶達式求值,檢查求值結果是否為null,並把結果儲存為一個變數。所有這些動作都砸系統一個簡潔的表示式中。 可空引數最常見的一種用法應該就是被傳遞給一個接受非空引數的函式。比如說下面這個函式,它接收一個String型別的引數並向這個地址傳送一封郵件,這個函式在Kotlin中是這樣寫的:

fun sendEmailTo(email: String) { ... }
複製程式碼

不能把null傳給這個函式,因此通常需要先判斷一下然後呼叫函式: if(email != null) sendEmailTo(email) 。 但我們有另一種方式:使用let函式,並通過安全呼叫來呼叫它。let函式做的所有事情就是把一個呼叫它的物件變成lambda表示式的引數: email?.let{ email -> sendEmailTo(email) } let函式只有在email的值非空時才被呼叫,如果email值為null則{} 的程式碼不會執行。 使用自動生成的名字it 這種簡明語法之後,可以寫成:email?.let{ sendEmailTo(it) } 。(Lambda的語法在只有章節會詳細講)

延遲初始化的屬性

很多框架會在物件例項建立之後用專門的方法來初始化物件。例如Android中,Activity的初始化就發生在onCreate方法中。而JUnit則要求你把初始化的邏輯放在用@Brefore註解的方法中。 但是你不能再狗仔方法中完全放棄非空屬性的初始化器。僅僅在一個特殊的方法裡初始化它。Kotlin通常要求你在構造方法中初始化所有屬性,如果某個屬性時非空型別,你就必須提供非空的初始化值。否則,你就必須使用可空型別。如果你這樣做,該屬性的每次訪問都需要null檢查或者!! 運算子。

class Activity {
    var view: View? = null

    fun onCreate() {
        view = View()
    }

    fun other() {
        //use view
        view!!.onLongClickListener = ...
    }
}
複製程式碼

這樣使用起來比較麻煩,為了解決這個麻煩,使用lateinit 修飾符來宣告一個不需要初始化器的非空型別的屬性:

class Activity {
    lateinit var view: View

    fun onCreate() {
        view = View()
    }

    fun other() {
        //use view
        view.onLongClickListener = ...
    }
}
複製程式碼

注意,延遲初始化的屬性都是var 因為需要在構造方法外修改它的值,而val 屬性會被編譯成必須在構造方法中初始化的final欄位。儘管這個屬性時非空型別,但是你不需要再構造方法中初始化它。如果在屬性被初始化之前就訪問了它,會得到異常"lateinit property xx has not been initialized" ,說明屬性還沒有被初始化。

注意lateinit屬性常見的一種用法是依賴注入。在這種情況下,lateinit屬性的值是被依賴注入框架從外部設定的。為了保證和各種Java框架的相容性,Kotlin會自動生成一個和lateinit屬性具有相同可見性的欄位,如果屬性的可見性是public,申城欄位的可見性也是public。

public final class Activity {
   public View view;

   public final View getView() {
      View var10000 = this.view;
      if(this.view == null) {
         Intrinsics.throwUninitializedPropertyAccessException("view");
      }
      return var10000;
   }

   public final void setView(@NotNull View var1) {
      Intrinsics.checkParameterIsNotNull(var1, "<set-?>");
      this.view = var1;
   }

   public final void onCreate() {
      this.view = new View();
   }

   public final void other() {
   }
}
複製程式碼

可空性的擴充套件

為可空型別定義擴充套件函式是一種更強大的處理null值的方式。可以允許接收者為null的(擴充套件函式)呼叫,並在該函式中處理null,而不是在確保變數為null之後再呼叫它的方法。 Kotlin標準庫中定義的String的兩個擴充套件函式isEmptyisBlank 就是這樣的例子。第一個函式判斷字串是否是一個空的字串"" 。第二個函式判斷它是否是空的或則只包含空白字元。通常用這些函式來檢查字串是有價值的,以確保對它的操作是有意義的。你可能意識到,像處理無意義的空字串和空白字串這樣處理null也很有用。事實上,你的確可以這樣做:函式isEmptyOrNullisNullOrBlank 就可以由String? 型別的接收者呼叫。

fun verifyUserInput(input: String?) {
    if (input.isNullOrBlank()) { //此方法是String?的方法,不需要安全呼叫
        println("Please fill in the required fields")
    }
}
複製程式碼

無論input是null還是字串都不會導致任何異常。我們來看下isNullOrBlank 函式的定義:

public inline fun CharSequence?.isNullOrBlank(): Boolean = this == null || this.isBlank()
複製程式碼

可以看到擴充套件函式是定義給CharSequence? (String的父類),因此不像呼叫String的方法那樣需要安全呼叫。 當你為一個可空型別定義擴充套件函式時,這以為這你可以對可空的值呼叫這個函式;並且函式體中this可能為null,所以你必須顯示地檢查。在Java中,this永遠是非空的,因為他引用的時當前你所在這個類的例項。而在Kotlin中,這並不永遠成立:在可空型別的擴充套件函式中,this可以為null。 之前討論的let 函式也能被可空的接收者呼叫,但它並不檢查值是否為null。如果你在一個可空型別直接呼叫let 函式,而沒有使用安全呼叫運算子,lambda的實參將會是可空的:

val person: Person? = ...
person.let { sendEmailTo(it) }  //沒有安全呼叫,所以it是可空型別

ERROR: Type mismatch:inferred type is Person? but Person was expected
複製程式碼

因此,如果想要使用let來檢查非空的實參,你就必須使用安全呼叫運算子?. 就像之前看到的程式碼一樣:person?.let{ sentEmailTo(it) }

當你定義自己的擴充套件函式時,需要考慮該擴充套件是否需要可空型別定義。預設情況下,應該把它定義成非空型別的擴充套件函式。如果發現大部分情況下需要在可空型別上使用這個函式,你可以稍後再安全地修改他(不會破壞其他程式碼)。

型別引數的可空性

Kotlin中所有泛型和泛型函式的型別引數預設都是可空的。任何型別,包括可空型別在內,都可以替換型別引數。這種情況下,使用型別引數作為型別宣告都允許為null,儘管型別引數T並沒有用問號結尾。

fun <T> printHashCode(t: T) {
    println(t?.hashCode())
}
複製程式碼

在該函式中,型別引數T推匯出的型別是可空型別Any? 因此,儘管沒有用問號結尾。實參t依然允許持有null。 要使用型別引數非空,必須要為它指定一個非空的上界,那樣泛型會拒絕可空值作為實參:

fun <T: Any> printHashCode(t: T) {
    println(t.hashCode())
}
複製程式碼

後續章節會講更多的泛型細節,這裡你只需要記得這一點就可以了。

可空性和Java

我們在Kotlin中通過可空性可以完美地處理null了,但是如果是與Java交叉的專案中呢?Java的型別系統是不支援可空性的,那麼該如果處理呢? Java中可空性資訊通常是通過註解來表達的,當程式碼中出現這種資訊時,Kotlin就會識別它,轉換成對應的Kotlin型別。例如:@Nullable String -> String?@NotNull String -> String。 Kotlin可以識別多種不同風格的可空性註解,包括JSR-305標準的註解(javax.annotation包下)、Android的註解(android.support.annitation) 和JetBrans工具支援的註解(org.jetbrains.annotations)。那麼還剩下一個問題,如果沒有註解怎麼辦呢?

平臺型別

沒有註解的Java型別會變成Kotlin中的平臺型別 。平臺型別本質上就是Kotlin不知道可空性資訊的型別。即可以把它當做可空型別處理,也可以當做非空型別處理。這意味著,你要像在Java中一樣,對你在這個型別上做的操作負有全部責任。編譯器將會允許所有操作,它不會把對這些值得空安全操作高亮成多餘的,但它平時卻是這樣對待非空型別值上的空安全操作的。 比如我們在Java中定義一個Person類:

public class Person {
    private  String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
複製程式碼

我們在Kotlin中使用這個類:

fun yellAt(person: Person) {
    println(person.name.toUpperCase()) //不考慮null情況,但是如果為null則丟擲異常
    println((person.name ?: "Anyone").toUpperCase()) //考慮null的可能
}
複製程式碼

我們即可以當成非空型別處理,也可以當成可空型別處理。

Kotlin平臺型別在表現為:Type!

val i: Int = person.name

ERROR: Type mistach: inferred type is String! but Int was expected
複製程式碼

但是你不能宣告一個平臺型別的變數,這些型別只能來自Java程式碼。你可以用你喜歡的方式來解釋平臺型別:

val person = Person()
val name: String = person.name
val name2: String? = person.name
複製程式碼

當然如果平臺型別是null,賦值給非空型別時還是會丟擲異常。

為什麼需要平臺型別? 對Kotlin來說,把來自Java的所有值都當成可空的是不是更安全?這種設計也許可行,但是這需要對永遠不為空的值做大量冗餘的null檢查,因為Kotlin編譯器無法瞭解到這些資訊。 涉及泛型的話這樣情況就更糟糕了。例如,在Kotlin中,每個來自Java的ArrayList 都被當作ArrayList<String?>?,每次訪問或者轉換型別都需要檢查這些值是否為null,這將抵消掉安全性帶來的好處。編寫這樣的檢查非常令人厭煩,所以Kotlin的設計者作出了更實用的選擇,讓開發者負責正確處理來自Java的值。

繼承

當在Kotlin中重寫Java的方法時,可以選擇把引數和返回型別定義成可空的,也可以選擇把它們定義成非空的。例如,我們來看一個例子:

/* Java */
interface StringProcessor {
    void process(String value);
}
複製程式碼

Kotlin中下面兩種實現編譯器都可以接收:

class StringPrinter : StringProcessor {
    override fun process(value: String) {
        println(value)
    }
}

class NullableStringPrinter : StringProcessor {
    override fun process(value: String?) {
        if (value != null) {
            println(value)
        }
    }
}
複製程式碼

注意,在實現Java類或者介面的方法時一定要搞清楚它的可空性。因為方法的實現可以在非Kotlin的程式碼中被呼叫,Kotlin編譯器會為你宣告的每一個非空的引數生成非空斷言。如果Java程式碼傳給這個方法一個null值,斷言將會觸發,你會得到一個異常,即便你從沒有在你的實現中訪問過這個引數的值。

因此,建議你只有在確保呼叫該方法時絕對不會出現空值時,才用非空型別取接收平臺型別。

相關文章