【譯】說服Kotlin編譯器程式碼安全

WilsonWu發表於2017-06-30

Kotlin 這門語言最出色的特點之一就是它內部自帶的空值安全系統。如果你在要求非空的情況下使用空值,那麼編譯器會就會發出警告。

不過確保空值安全偶爾也會造成一些棘手的情況。你所熟知的毋庸置疑的程式碼也會佈滿空值的隱患...至少從編譯器的角度來說是這樣。

操縱 Map

來看一個例子。假設我們想將一個 List<String> 轉化為 Map<String, Int>, 其中每一個 Int 代表對應 String 在數列中所出現的次數。我們可以這麼寫:

fun countInstances(list: List<String>): Map<String, Int> {
  val map = mutableMapOf<String, Int>()
  for (key in list) {
    if (key !in map) {
      map[key] = 0
    }
    map[key] = map[key] + 1
  }
  return map
}複製程式碼

程式碼邏輯正確但卻無法編譯。 Kotlin 認為這行程式碼有問題:

map[key] = map[key] + 1複製程式碼

map[key] 等同於 map.get(key) 。嚴格上來講 get() 會返回 T? 型別,因為你可以給它提供一個本身不存在的關鍵詞。即使知道 map[key] 不是空值,編譯器意識不到你在每次使用 map[key] 前都會將其初始化。

我發現我在使用 Map.get() 時經常出現這個問題。我自己總是通過思考程式碼的邏輯來保障非空值的使用是否安全,但編譯器無法對此進行核實。

我可以依賴使用運算子!!,但它看上去就像是一種警告 - 你不能無視編譯器所產生的的錯誤。以下是其他幾種可以解決這種問題的方法。

空值檢查

不直接在 Map 上進行操作,而是通過先提取數值並儲存於本地變數中再進行空值檢查。

val oldValue = map[key]
if (oldValue != null) {
  map[key] = oldValue + 1
}
else {
  map[key] = 1
}複製程式碼

雖然 oldValue 是可為空型別( Int?),但它是一個本地變數,所以其他執行緒無法接觸到它。這意味著編譯器能確保在條件判斷後這個變數的值不會再發生改變。結果就是 Kotlin 將其視為非空變數。

空值檢查可以用,但是這個方法較為繁瑣。

Elvis運算子

我們可以通過結合 Elvis 運算子將空值檢查的解法壓縮為單行程式碼:

map[key] = (map[key] ?: 0) + 1複製程式碼

Elvis 運算子會選擇 map[key]0中第一個為非空值的那一個。這樣能保證結果為整數型別,以便後期對其進行增值。

絕地心術

如果我們直接宣告“這些都不是非空值”會發生什麼呢?

其實 Kotlint 專門為此提供了 Map.getValue()。這個函式會返回 T 類而不是 T? 類。因此, map.getValue(key) 具有 map[key] 所不具備的功能:

map[key] = map.getValue(key) + 1複製程式碼

如果本來就沒有值會發生什麼?這種情況下,它會生成一個異常! getValue() 本身長這樣:

val value = map[key] ?: throw new NoSuchElementException()複製程式碼

結合前文可得知, getValue()!!其實差不多。如果有空值存在它們都會生成異常,然而...

預設值

你可以通過使用 Map.withDefault() 來給你的 Map 提供預設值。使用這個方法的話, Map.getValue() 在找不到關鍵詞的情況下會返回預設值:

fun countInstances(list: List<String>): Map<String, Int> {
  val map = mutableMapOf<String, Int>().withDefault { 0 }
  for (key in list) {
    map[key] = map.getValue(key) + 1
  }
  return map
}複製程式碼

在這種情況下, Map.getValue() 肯定!!好,因為它不可能產生異常。

如果你不想為整個 Map 設定預設值,你也可以分情況使用預設值,比如用 Map.getOrDefault():

map[key] = map.getOrDefault(key, 0) + 1複製程式碼

除了使用數值作為預設值,你還可以使用 Map.getOrElse() 將函式作為預設值:

map[key] = map.getOrElse(key, { 0 }) + 1複製程式碼

在這個例子裡這麼寫很不明智,但如果預設值的計算很費時,這個方法會節省很多時間。(同時,由於 getOrDefault() 最近才新增到 Android 中,除非你所使用的最低開發版本為24,你還得用 Kotlin 的 getOrElse() 函式。)在這個例子中,設預設值和使用 Elvis 運算子都行。

集合的變形

除了遍歷集合中的每一個元素,我們也可以將整個集合進行一次變形。變形能避免空值檢查,因為我們所遍歷的元素一定存在於集合中。

Kotlin 的標準庫中內建很多不錯的函式正好能解決我們的問題:

fun countInstances(list: List<String>) = list.groupingBy { it }.eachCount()複製程式碼

這裡我們首先將 List 轉化為 Grouping,然後我們用 Grouping.eachCount() 將其變形為 Map<String, Int>

集合層面的操作能力十分強大,經常會比遍歷整個集合有用很多(主要是因為標準庫會在背後進行優化)。

哪個最好?

我已經示範了幾種能保證程式碼通過編譯器的策略:

  1. 空值檢查
  2. Elvis 運算子
  3. 轉化為非空值 (可能出現異常)
  4. 預設值
  5. 集合變形

(我這麼寫並不意味著這就是所有的策略;不同情況可能有其它選擇)

一般要根據程式碼的上下文來判斷哪種方法最適合。在我們的例子中, groupingBy().eachCount() 肯定最好。它簡潔,有效,不難理解,而且完全避免了空值檢查。


感謝 Jake Wharton 對這篇文章的幫助

掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章