1. 程式人生 > 其它 >Kotlin學習之空指標檢查

Kotlin學習之空指標檢查

技術標籤:筆記kotlinandroidkotlin

空指標檢查

空指標是一種不受程式語言檢查的執行時異常,只能由程式設計師主動通過邏輯判斷來避免,但即使是最出色的程式設計師,也不可能將所有潛在的空指標異常全部考慮到。

我們來看一段非常簡單的Java程式碼:

public void doStudy(Study study) {
    study.readBooks();
    study.doHomework();
}

這是我們在之前編寫過的一個doStudy()方法,我將它翻譯成了Java版

/*這段程式碼安全嗎?不一定,因為這要取決於呼叫方傳入的引數是什麼,如果我們向doStudy()方法傳入了一個null引數,那麼毫無疑問這裡就會發生空指標異常。因此,更加穩妥的做法是在呼叫引數的方法之前先進行一個判空處理,如下所示:*/
public void doStudy(Study study) { if (study != null) { study.readBooks(); study.doHomework(); } }

這樣就能保證不管傳入的引數是什麼,這段程式碼始終都是安全的。

由此可以看出,即使是如此簡單的一小段程式碼,都有產生空指標異常的潛在風險,那麼在一個大型專案中,想要完全規避空指標異常幾乎是不可能的事情,這也是它高居各類崩潰排行榜首位的原因。

1 可空型別系統

然而,Kotlin卻非常科學地解決了這個問題,它利用編譯時判空檢查的機制幾乎杜絕了空指標異常

還是回到剛才的doStudy()函式,現在將這個函式再翻譯回Kotlin版本,程式碼如下所示:

fun doStudy(study: Study) {
    study.readBooks()
    study.doHomework()
}

這段程式碼看上去和剛才的Java版本並沒有什麼區別,但實際上它是沒有空指標風險的,因為Kotlin預設所有的引數和變數都不可為空,所以這裡傳入的Study引數也一定不會為空,我們可以放心地呼叫它的任何函式。如果你嘗試向doStudy()函式傳入一個null引數,則會提示如圖所示的錯誤。

Snipaste_2021-01-01_12-21-07

也就是說,Kotlin將空指標異常的檢查提前到了編譯時期,如果我們的程式存在空指標異常的風險,那麼在編譯的時候會直接報錯,修正之後才能成功執行,這樣就可以保證程式在執行時期不會出現空指標異常了。

回到剛才的doStudy()函式,如果我們希望傳入的引數可以為空,那麼就應該將引數的型別由Study改成Study?,如圖2.31所示。

Snipaste_2021-01-01_12-22-42

可以看到,現在在呼叫doStudy()函式時傳入null引數,就不會再提示錯誤了。然而你會發現,在doStudy()函式中呼叫引數的readBooks()和doHomework()方法時,卻出現了一個紅色下滑線的錯誤提示,這又是為什麼呢?

其實原因也很明顯,由於我們將引數改成了可為空的Study?型別,此時呼叫引數的readBooks()和doHomework()方法都可能造成空指標異常,因此Kotlin在這種情況下不允許編譯通過。

那麼該如何解決呢?很簡單,只要把空指標異常都處理掉就可以了,比如做個判斷處理,如下所示:

fun doStudy(study: Study?) {
    if (study != null) {
        study.readBooks()
        study.doHomework()
    }
}

現在程式碼就可以正常編譯通過了,並且還能保證完全不會出現空指標異常。

2 判空輔助工具

首先學習最常用的?.操作符。這個操作符的作用非常好理解,就是當物件不為空時正常呼叫相應的方法,當物件為空時則什麼都不做。比如以下的判空處理程式碼:

if (a != null) {
    a.doSomething()
}

這段程式碼使用?.操作符就可以簡化成:

a?.doSomething()

瞭解了?.操作符的作用,下面我們來看一下如何使用這個操作符對doStudy()函式進行優化,程式碼如下所示:

fun doStudy(study: Study?) {
    study?.readBooks()
    study?.doHomework()
}

可以看到,這樣我們就藉助?.操作符將if判斷語句去掉了。

下面我們再來學習另外一個非常常用的?:操作符。這個操作符的左右兩邊都接收一個表示式,如果左邊表示式的結果不為空就返回左邊表示式的結果,否則就返回右邊表示式的結果。觀察如下程式碼:

val c = if (a ! = null) {
    a
} else {
    b
}

這段程式碼的邏輯使用?:操作符就可以簡化成:

val c = a ?: b

接下來我們通過一個具體的例子來結合使用?.和?:這兩個操作符,從而讓你加深對它們的理解。

比如現在我們要編寫一個函式用來獲得一段文字的長度,使用傳統的寫法就可以這樣寫:

fun getTextLength(text: String?): Int {
    if (text != null) {
        return text.length
    }
    return 0
}

由於文字是可能為空的,因此我們需要先進行一次判空操作,如果文字不為空就返回它的長度,如果文字為空就返回0。

這段程式碼看上去也並不複雜,但是我們卻可以藉助操作符讓它變得更加簡單,如下所示:

fun getTextLength(text: String?) = text?.length ?: 0

這裡我們將?.和?:操作符結合到了一起使用,首先由於text是可能為空的,因此我們在呼叫它的length欄位時需要使用?.操作符,而當text為空時,text?.length會返回一個null值,這個時候我們再借助?:操作符讓它返回0。

不過Kotlin的空指標檢查機制也並非總是那麼智慧,有的時候我們可能從邏輯上已經將空指標異常處理了,但是Kotlin的編譯器並不知道,這個時候它還是會編譯失敗。

觀察如下的程式碼示例:

var content: String? = "hello"

fun main() {
    if (content != null) {
        printUpperCase()
    }
}

fun printUpperCase() {
    val upperCase = content.toUpperCase()
    println(upperCase)
}

這裡我們定義了一個可為空的全域性變數content,然後在main()函式裡先進行一次判空操作,當content不為空的時候才會呼叫printUpperCase()函式,在printUpperCase()函式裡,我們將content轉換為大寫模式,最後打印出來。

看上去好像邏輯沒什麼問題,但是很遺憾,這段程式碼一定是無法執行的。因為printUpperCase()函式並不知道外部已經對content變數進行了非空檢查,在呼叫toUpperCase()方法時,還認為這裡存在空指標風險,從而無法編譯通過。

在這種情況下,如果我們想要強行通過編譯,可以使用非空斷言工具,寫法是在物件的後面加上!!,如下所示:

fun printUpperCase() {
    val upperCase = content!!.toUpperCase()
    println(upperCase)
}

這是一種有風險的寫法,意在告訴Kotlin,我非常確信這裡的物件不會為空,所以不用你來幫我做空指標檢查了,如果出現問題,你可以直接丟擲空指標異常,後果由我自己承擔。

最後我們再來學習一個比較與眾不同的輔助工具——let。let既不是操作符,也不是什麼關鍵字,而是一個函式。這個函式提供了函式式API的程式設計介面,並將原始呼叫物件作為引數傳遞到Lambda表示式中。示例程式碼如下:

obj.let { obj2 ->
    // 編寫具體的業務邏輯
}

可以看到,這裡呼叫了obj物件的let函式,然後Lambda表示式中的程式碼就會立即執行,並且這個obj物件本身還會作為引數傳遞到Lambda表示式中。不過,為了防止變數重名,這裡我將引數名改成了obj2,但實際上它們是同一個物件,這就是let函式的作用。

你可能就要問了,這個let函式和空指標檢查有什麼關係呢?其實let函式的特性配合?.操作符可以在空指標檢查的時候起到很大的作用。

我們回到doStudy()函式當中,目前的程式碼如下所示:

fun doStudy(study: Study?) {
    study?.readBooks()
    study?.doHomework()
}

雖然這段程式碼我們通過?.操作符優化之後可以正常編譯通過,但其實這種表達方式是有點囉嗦的,如果將這段程式碼準確翻譯成使用if判斷語句的寫法,對應的程式碼如下:

fun doStudy(study: Study?) {
    if (study != null) {
        study.readBooks()
    }
    if (study != null) {
        study.doHomework()
    }
}

也就是說,本來我們進行一次if判斷就能隨意呼叫study物件的任何方法,但受制於?.操作符的限制,現在變成了每次呼叫study物件的方法時都要進行一次if判斷。

這個時候就可以結合使用?.操作符和let函式來對程式碼進行優化了,如下所示:

fun doStudy(study: Study?) {
    study?.let { stu ->
        stu.readBooks()
        stu.doHomework()
    }
}

我來簡單解釋一下上述程式碼,?.操作符表示物件為空時什麼都不做,物件不為空時就呼叫let函式,而let函式會將study物件本身作為引數傳遞到Lambda表示式中,此時的study物件肯定不為空了,我們就能放心地呼叫它的任意方法了。

另外還記得Lambda表示式的語法特性嗎?當Lambda表示式的引數列表中只有一個引數時,可以不用宣告引數名,直接使用it關鍵字來代替即可,那麼程式碼就可以進一步簡化成:

fun doStudy(study: Study?) {
    study?.let {
        it.readBooks()
        it.doHomework()
    }
}

let函式是可以處理全域性變數的判空問題的,而if判斷語句則無法做到這一點。比如我們將doStudy()函式中的引數變成一個全域性變數,使用let函式仍然可以正常工作,但使用if判斷語句則會提示錯誤

之所以這裡會報錯,是因為全域性變數的值隨時都有可能被其他執行緒所修改,即使做了判空處理,仍然無法保證if語句中的study變數沒有空指標風險。從這一點上也能體現出let函式的優勢。

本文章參考自郭霖的第一行程式碼——Android(第3版)