程式裡怎麼表達“沒有”
最近忙著調研RPC/">gRPC做服務治理,嘗試用protobuf3重寫現有的介面邏輯,發現了一個問題:protobuf3的基本型別不支援nullable。如果想表達“沒有”,就只能用對應資料型別的預設值,比如,字串的預設值是"",整數是0,布林型別是false。在團隊裡展開了一個討論——程式裡要不要表達“沒有”,和怎麼表達“沒有”。本文就是討論中一些關鍵內容的總結啦。
能不能不要“沒有”?
很簡單——不能。 ”沒有“這個概念是業務上非常普遍存在的現象 。比如我們根據id查詢資料,可能因為某種原因,這個資料不存在,而我們的程式需要某種方式表達這個“不存在”。
當然,這時可以用 NoSuchElementException
的方式表達,但如果在系統中這個情況是正常情況,而非異常,那麼用異常處理會顯得比較臃腫;並且因為一般RPC協議都沒有異常支援,所以也不能很好的跨系統表達這個異常。
另外一個例子是資料因為某種原因缺失。比如,我們會從第三方資料來源讀取所有中國公募基金的淨值資料,那麼就有極小的概率錄入錯誤,或者當天淨值就是不公佈,因此 沒有資料 。這時,淨值在系統中必須表達為“沒有”,而不是0。在產品的介面上看到的也應該是"--"。其他類似的資料,比如年化收益、業績表現等也是如此。

用"--"表示沒有資料
在業務開發中,不管用什麼開發語言,一般都會用空來表示“沒有”,比如Java中的null,SQL/">MySQL中的NULL,js中的null和undefined,Python中的None等等。這個用法儘管存在一些問題,但已經形成了事實的標準。
回到我最初的問題,儘管protobuf3不支援nullable的基本型別,“沒有”還是要表達。於是大家想各種辦法來曲折的解決這個問題。比較有代表性的有"oneof"法和“Wrapper”法。具體細節的討論可以見 ofollow,noindex">這裡 。
但是null的確存在問題(特別是在靜態語言開發者的眼中),它會讓型別系統的消除程式錯誤的功能失效。
從null到Optional
計算機科學裡有一個著名的梗叫做“billion-dollar mistake“問題。大神Hoare(C. A. R. Hoare,CSP和ALGOL的發明者,結構化程式思想的先驅),曾經描述到:
I call it my billion-dollar mistake… At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.
這段解釋了最早 null
引用是怎麼來的,以及這個東西對隨後幾十年軟體工業帶來的無數鬧心的問題。
靜態型別語言強調“儘可能的在編譯期找到程式的錯誤”,而null這個奇葩的存在無疑是與這個目標對著幹。比如C++裡,你如果這樣寫:
char * p = 123;
編譯器會告訴你123不是個表示字元資料的地址,這很好。但,編譯器卻允許:
char * p = 0;
因為0在C++裡表示空指標,所以編譯器做了特殊處理,視作合法。直到執行時觸發了segment fault。
Java也類似,你可以
Integer a = null;
這可以繞開編譯器,然後有可能在執行時得到一個NPE。
於是靜態語言們開始逐漸採用一個新的方法,即用 Optional
來明確的表達”可能沒有“。比如C++ 17增加了 std:optional
,Java 8 開始也支援了 Optional<T>
(其實guava很早就有Optional了),Scala支援了 Option[T]
。Optional用型別的方法描述了一個數據 可能不存在 ,這樣就可以寫出看起來比較優雅的程式碼,比如:
Optional<User> userOpt = findUserById(userId); userOpt.ifPresent(user -> System.out.println(user.getName())) .orElseThrow(NoSuchElementException::new);
這個似乎就比下面if + null的形式進行檢查更好一些:
User user = findUserById(userId); if (user == null) throw new NoSuchElementException(); System.out.println(user.getName());
Optional是個好辦法嘛?
很可惜,直到目前為止,我的周邊基本上沒有聽說誰真的廣泛的採用Optional的方案。原因很簡單:很多程式都是跨多個元件的程式,而其中一兩個地方有Optional支援,其他地方沒有,那整體得到的麻煩和混亂比用if + null的寫法還要多。
比如,一個常見的Web程式需要訪問資料庫,並把結果用json傳輸到客戶端。也許程式本身有Optional支援,但是資料庫和json並沒有“Optional”的概念。當然,已經能看到一些相關的努力,比如Hibernate 5.2開始支援Optional。而Json用Jackson的一些的hook的機制也能解決,但是整體上面對原有的,基於null形成的前中後端的事實約定,還是太小眾了些。推動起來會形成相當的阻力。
另外一個更嚴重一些的問題是,也許從語言的角度會覺得用一個有型別的”沒有“替代null形式的“沒有”感覺更優雅,但實際上從上層開發的角度,並沒有什麼明顯的區別。開發者還是要去檢查這個東西到底有沒有,無論是用if + null,還是用 Optional#ifPresent
,都是一樣的。要做好檢查,就要求開發者有這個意識去做這個檢查。如果說開發者看到了Optional就有了檢查的意識,也就意味著開發者在傳統做法時,也應該有這個意識去做檢查。如果使用了Optional,但是強行直接get,一旦“沒有”發生了,也會得到一個如NoSuchElementExcepiton這樣的異常。這個異常和NPE並沒有什麼本質區別。
有人說Optional可以幫助讓開發人員區別哪些變數是必須非null的,哪些是真的可能是null 的。但現實當中往往是,你一直認為一個變數是非null的,直到它第一次是null。比如你會覺得某個資料一定是經過嚴格的錄入檢查,必然不為空;你可能會覺得某個資料在資料庫裡是"not null",所以不可能為空等等。但是隻要程式是可以改的,資料是可以改的,就會出現一個非null的資料轉變為nullable資料,並且影響一片將其視作必然非null的程式的可能性。一旦發生這種情況,你就需要把一片程式從原始的型別都改成Optional的寫法,改動量也比較大。
應該承認使用Optional時對開發者做檢查的推動力是要強過if + null式檢查,並且還很“型別”,但從使用者的角度整體的價效比還是很差。
如果細細反思Optional這個方案就能發現Optional並非是問題的關鍵,而真正的關鍵是:
- 用一個最簡單的辦法來表達“沒有”,這個表達容易在前中後端形成約定,就連初學者都很容易明白和使用;
- 想辦法“助推”,讓開發者能主動寫好對“沒有”的檢查。
我查來查去,終於發現Kotlin的方案是比較靠譜的。
Kotlin的方案
Kotlin是這樣解決問題的。首先Kotlin裡有null。這就解決了上面第一個問題,大家都會很喜歡和習慣於使用,也很方便和其他系統整合。
但kotlin中的null不能隨便用。kotlin要求開發人員要自己控制一個變數的型別是nullable還是非nuallable的。比如
var user: User user = null // 編譯失敗
是會編譯失敗的。為了能讓變數賦值為null,必須宣告變數的型別是nullable的。
var nullableUser: User? // ?表示nullable nullableUser = null // 合法
然後是最關鍵的,如果你試直接訪問一個nullable的變數的屬性,或者呼叫其方法,會直接編譯報錯。
nullableUser.doSomething(); // 編譯報錯
因為kotlin並不確認 nullableUser
是不是為null,所以選擇直接報編譯錯誤,逼著開發者一定要明確這裡可能是null。
nullableUser?.doSomething();
通過這個語法,如果 nullableUser
是null的話,表示式就會直接返回null,而不是丟擲一個NPE。當然,如果開發者想自定義如果是null的處理程式碼的話,可以這樣寫:
nullableUser?.doSomething() ?: handleNullError()
如果開發人員真的認為這段程式碼一定不應該存在null,一旦有了null,最好立刻丟擲NPE,立刻修問題,可以這樣做:
nullableUser!!.doSomething();
kotlin的做法對實際的工程開發非常友好。不像Optional僅僅是提出了一個“優雅”但實際上難以用起來的方案。kotlin給了開發者一個選擇:對於null,到底是要嚴格對待(立刻拋NPE),還是容忍著對待(預設返回null或者自定義處理)。如果一個數據本來認為是非null的,隨後修改為nullable的,改動也比Optional方案小很多。在我看來,這就是一種助推,可以很積極的鼓勵開發者更好的處理“沒有”的問題。
助推的含義是“自由主義的溫和專制主義”,詳見 Richard H. Thaler 和 Cass R. Sunstein合著的《助推》。
值得提一句,像kotlin這樣處理null的語言還有C#和swfit。此外,Groovy也有"?."這樣的操作符,但是因為Groovy算是動態語言,並不會用編譯錯誤迫使開發者做對null的處理。
提示一下:我先看的kotlin,再看的其他幾門語言。因此,本文用kotlin舉例子,並不代表C#,swfit和kotlin在這個功能的設計上誰先誰後。
使用其他語言的該怎麼辦
Java目前看最好的方案就是半吊子的Optional了。並且個人建議是如果是已有程式碼的話,不要遷移到這套方案上,因為代價很高,卻沒有解決什麼問題,而應該繼續使用傳統的if + null判斷,以及嚴格的code review。
順便歪歪一下,靜態型別語言的開發者往往會習慣於編譯器能處理大部分錯誤,然後在“沒有”需要執行時檢測這個事情上意識不足。對此我鼓勵所有的靜態型別語言的開發者都要至少嘗試寫一種動態程式碼,吸收一些編譯器搞不定的情況下如何避免出問題的思路和習慣。現實開發中總有編譯器無法防範的問題。
而動態語言,當然就做執行時的檢查了。以javascript為例,它的coerce語法和"||"操作符會讓這個事情很輕鬆。
let user = findUserByUserId(userId) || {}; // 如果真的沒找到,這裡可以選擇性的返回一個空object
此外,lodash也是個非常好用的幫手。
const _ = require('lodash'); // lodash是個好東西 if (_.isEmpty(user)) { /* 處理空 */ }
最後的話
“沒有”這個事情在業務上是普遍存在的,是剛需,並不以某些人的一廂情願而消失。在編寫程式碼的時候,我們需要用最簡單的方式來表達“沒有”,這個方式就是null。但是在傳統的靜態語言中,null會繞開編譯器,因此容易造成null safety問題。對於此問題,最好的辦法不是幹掉“沒有”,而是想方設法讓開發者儘可能方便和靈活的檢查null,儘量避免不檢查帶來的問題。如果你用的程式語言恰好有這種機制,好好利用它;如果沒有,就要學會“不相信”你的資料來源,多做檢查,多做code review。
最後的最後,不管是靜態語言,還是動態語言,都應該好好寫測試。測試才是能確認程式不出問題的最終手段。