3. 類型聲明與空安全(Void Safety)

  • 2020 年 2 月 11 日
  • 筆記

上一篇文章介紹了 Koltin 的聲明類型語法,但我有意避開了 Kotlin 類型系統里最重要的部分:空安全(Void Safety/Null Safety)。在 Kotlin 中,不可能為空的變數和可能為空的變數被強行分開了(Java 有 @Nullable 和 @NonNull 注釋,但只會提供警告)。那 Kotlin 為什麼要這樣設計呢?我們來看一下今天的程式碼場景:(只想看使用辦法的可以跳過這一節)

0. 場景分析

某一天你正在優雅的編寫新業務程式碼,老闆突然告訴你,有一個線上的空指針 crash,趕緊處理一下。你趕緊 git stash 了自己的程式碼,切換到出問題的那個類。

這是一個管理音頻播發的類,叫 PlayerController,用來播放用戶上傳的 ugc 音頻內容。播放是一個很基礎通用的功能,所以這個類依賴了一個播放庫 AudioPlayer,PlayerController 主要是實現業務功能。

這個類之前的維護者剛離職,你臨時接任,對裡面的結構是不夠熟悉的。這個類年代久遠,在某個初期版本就上線了,承載了無數的業務變更。裡面程式碼邏輯混亂,業務和通用程式碼耦合在了一起。你想過重構,但功能實在太多了,需要很長的時間,且現在功能也比較穩定了,重構的收益對業務增長沒有明顯幫助。那還是先打個修補程式唄。

我們來看看程式碼:

PlayerController.java:

/**    * 用戶音頻 ugc 播放器。    * 如果看到奇怪的邏輯,請不要隨便刪除,那都是為了規避    * AudioPlayer 庫一些奇怪的 bug,或者是為了兼容業務做的處理。    */  public class PlayerController {      private AudioPlayer mAudioPlayer;        public PlayerController() {        }        /** 初始化,只會初始化一次 */      public void init () {          // 構造播放組件          if (mAudioPlayer != null) {              mAudioPlayer = AudioPlayer();          }      }        /** 播放前需要先初始化數據 **/      public void prepare(String audioPath) {          // 設置音頻文件路徑          if (mAudioPlayer != null) {              mAudioPlayer.prepare(audioPath);          }      }        /** 開始播放 **/      public void play() {          if (mAudioPlayer != null) {              // 前置條件判斷              // ...              mAudioPlayer.play();          }      }        /** 暫停 **/      public void pause() {          if (mAudioPlayer != null) {              mAudioPlayer.pause();          }      }        /** 跳轉到指定時間 **/      public void seekTo(long time) {          if (mAudioPlayer != null) {              mAudioPlayer.seekTo(time);          }      }        public void stop() {          if (mAudioPlayer != null) {              // 數據處理              // ...              mAudioPlayer.stop(); // 該行空指針錯誤了          }      }        public void release() {          if (mAudioPlayer != null) {              mAudioPlayer.release();              mAudioPlayer = null;          }      }  }

這是個很典型的依賴了底層組件的封裝類。初始化,釋放,播放,暫停這些是外部介面。裡面還充斥著很多空判斷和 proxy 的程式碼。這樣寫程式碼迅速膨脹了起來。

這個類在後面講解很多 Kotlin 特性的時候都會引用它,可以多看兩眼

開始 crash 分析。通過錯誤上報,我發現是 mAudioPlayer.stop()這行空指針錯誤了。mAudioPlayer 在init()時被賦值,release()時被釋放,且為了防止記憶體泄漏被設置為 null。再考慮到並發操作,即mAudioPlayer這個變數在任何使用的時候都可能為 null。

但外部已經有空條件判斷了,且這是最新的版本才暴露的問題,為什麼會這樣呢?

我通過 git 提交記錄排查後了解到,是mAudioPlayer.stop()之前新增了一些業務程式碼,而新增程式碼有耗時操作。這導致了在空判斷時非空,但進入 if 程式碼塊之後,執行緒被切換了,上層調用了release(),等執行緒再切回來的時候 mAudioPlayer 已經變成 null 了,再執行就出現了空指針錯誤。

那最簡單的解決辦法就是**給mAudioPlayer.stop()單獨再包一層***。雖然很醜,但很管用,大夥也很喜歡用,特別是灰度不允許大幅改動的時候。

又或者是給所有 mAudioPlayer 操作都加上鎖 synchronized。不過考慮到裡面 API 有耗時操作,這樣寫有可能會造成 UI 卡頓。

不加鎖的話也有多次調用,即破壞冪等性的風險。

總之事情就這樣暫時解決了。程式碼隨著時間的遷移,越來越多變數可能為空的地方加上了if (xxx != null)的保護程式碼,甚至可能一個類 10% 的行都是空指針保護!涉及到邏輯冗長的地方,空保護的嵌套甚至到達了 5 層以上!那畫面太美。。

這確實是我們 Java Boy 的最通用解決辦法。那麼 Kotlin Boy 可以如何優雅的解決這個問題呢?

1. Kotlin 非空類型/可空類型(NonNull/Nullable)聲明

最開始時我們提到:在 Kotlin 中,不可能為空的變數和可能為空的變數被強行分開了。具體是怎麼分開的呢?很簡單,默認的類型聲明不能為空,類型後面跟問號」?」則可以為空。我們來看下面這段程式碼:

Nullable.kt:

fun main() {      var string1: String = "123" // ok      string1 = "456" // ok        var string2: String = null // 編譯器報錯了        var string3: String? = null // ok      string3 = "456" // ok      string3 = null // ok        var string4 = "123" // ok,類型推斷為 String      string4 = null // 編譯器報錯了        var string5 = null // ok,類型推斷為 Nothing?      string5 = "123" // 編譯器報錯了  }

觀察 string1,string2 我們可以得出: 當你像 Java 那樣聲明一個 String 對象的時候,他在之後的賦值也是不能被賦值為空的。這意味著如果一個變數的類型為 String,則他在任何時候都不可能為空。

觀察 string3 我們可以得出: 聲明對象為 String? 類型,可以將其設置為空。典型場景是,在你初始化這個變數的時候,還暫時無法得到其值,就必須用可空類型的聲明方法了。

觀察 string4,string5 我們可以得出: 類型推斷是完全根據初始化時的賦值來確定的。他不會根據後面的賦值作為依據來推斷這個變數的類型。所以我們需要像 string3 那樣顯式聲明為 String?。至於 Nothing 類型我們暫且不管,實際也很少用到,後面再分析。

2. Kotlin 可空(Nullable)類型的調用

聲明一個非空變數,意味著你可以隨意的調用他的方法而不用擔心空指針錯誤,相對應的,可空變數則無法保證了。Kotlin 通過不允許可空變數直接調用方法來保證不會出現空指針錯誤。那麼可空變數應該怎麼調用呢?

Kotlin 可空變數的調用方法是:調用的」.」號前加」?」或」!!」。前者的行為是,如果非空則調用,否則不調用;後者行為是,如果非空則調用,否則拋出 Illegalstateexception。來看看例子:

Nullable2.kt:

/** 很普通的一個類,有一個「成員變數」,一個返回該變數的方法 **/  class A {      var code = 0        fun getMyCode(): Int { // 返回 Int 類型,就像是 Java 的 Integer 那樣          return code      }  }    fun main() {      var a1 = A()      a1.code = 3      a1.getMyCode() // ok        var a2: A? = A()      a2.code = 3 // 編譯錯誤      a2.getMyCode() // 編譯錯誤        var a3: A? = A()      a3?.getMyCode() // ok      a3!!.getMyCode() // ok  }

生產環境不建議使用雙嘆號!!,一般只用於測試環境。使用雙嘆號可以理解為放棄 Kotlin 的空安全特性。

3. Kotlin 可空(Nullable)的傳遞性

如果一個可空對象調用了方法,因為這個方法有可能不被執行,那麼如果我們接收它的返回值,那麼返回值的類型應該是什麼呢?我們繼續使用上面A這個類,來看看這個例子:

/** 很普通的一個類,有一個「成員變數」,一個返回該變數的方法 **/  class A {      var code = 0        fun getMyCode(): Int { // 返回 Int 類型,就像是 Java 的 Integer 那樣          return code      }  }    var a4: A? = null    fun main() {      var myCode: Int = a4?.getMyCode() // 編譯錯誤      var myCode2: Int? = a4?.getMyCode() // ok        myCode2.toFloat() // 編譯錯誤      myCode2?.toFloat() // ok        var myCode3: Int? = a4!!.getMyCode() // ok      myCode3.toFloat() // ok  }

我們可以看到,本來getMyCode()方法返回的是 Int 類型,但由於調用時 a4 為可空類型,所以 myCode 被編譯器認為是 Int? 類型。所以,可空是具有傳遞性的。

雙嘆號由於在變數為空時會拋出異常,所以它的返回值就還是為 Int,因為拋了異常的話,後面的程式碼已經不會被執行了。

這個 a4 要寫在外面的原因是,如果聲明為局部變數,即使 a4 被聲明為 A?,但由於局部變數的關係,編譯器會把 myCode 糾正為 Int,而不是 Int?。

如果鏈式調用的話,就會變成這個樣子:

myCode2?.toFloat()?.toLong()?.toByte()  myCode2!!.toFloat().toLong().toByte()

看起來比較 ugly。。但不用擔心,Kotlin 有其他的特性來協助你處理可空變數,不用寫出像這樣的嘲諷程式碼(瘋狂打問號 ???)。請繼續期待後面的文章吧!

4. 回到場景

如果用 Kotlin 來實現場景中的程式碼,只需要將 mAudioPlayer 聲明為可空類型就可以了:

PlayerController.kt:

/**    * 用戶音頻 ugc 播放器。    * 如果看到奇怪的邏輯,請不要隨便刪除,那都是為了規避    * AudioPlayer 庫一些奇怪的 bug,或者是為了兼容業務做的處理。    */    class PlayerController {      private var mAudioPlayer: AudioPlayer?  = null        /** 初始化,只會初始化一次  */      fun init() {          // 構造播放組件          if (mAudioPlayer != null) {              mAudioPlayer = AudioPlayer()          }      }        /** 播放前需要先初始化數據  */      fun prepare(audioPath: String) {          // 設置音頻文件路徑          mAudioPlayer?.prepare(audioPath)      }        /** 開始播放  */      fun play() {          // 前置條件判斷          // ...          mAudioPlayer?.play()      }        /** 暫停  */      fun pause() {          mAudioPlayer?.pause()      }        /** 跳轉到指定時間  */      fun seekTo(time: Long) {          mAudioPlayer?.seekTo(time)      }        fun stop() {          // 數據處理          // ...          mAudioPlayer?.stop() // 不會空指針錯誤了      }        fun release() {          mAudioPlayer?.release()          mAudioPlayer = null      }  }

寫起來方便很多,而且少了一層嵌套,人也舒服了。

空安全特性首次出現在 F#(2005) 上,此外 Swift 和 TypeScript 等也是空安全語言。 空指針首次出現在 Algol W(1965) 上,用作者的原話說,就是:後悔,非常的後悔。。(I call it my billion-dollar mistake) 參考:https://en.wikipedia.org/wiki/Void_safety 版權所有,轉載請註明出處: https://sickworm.com/?p=1779

共享此文章:

相關

Tags: