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