Java 中 Null 的設計並不是一個錯誤
我使用 Java 開發專案很多年了,瞭解如何用它來開發大型的專案。在工業界,我看到大家做了很多努力來規避NullPointerException
(NPE),對其膽戰心驚。NPE
的發明人 Tony Hoare 在 2009年承認:「null
引用的設計是一個十億美元的錯誤」。
我把null
引用稱為自己的十億美元錯誤。它的發明是在1965 年,那時我用一個面嚮物件語言( ALGOL W )設計了第一個全面的引用型別系統。我的目的是確保所有引用的使用都是絕對安全的,編譯器會自動進行檢查。但是我未能抵禦住誘惑,加入了null
引用,僅僅是因為實現起來非常容易。它導致了數不清的錯誤、漏洞和系統崩潰,可能在之後 40 年中造成了十億美元的損失。近年來,大家開始使用各種程式分析程式,比如微軟的 PREfix 和 PREfast 來檢查引用,如果存在為非null
的風險時就提出警告。更新的程式設計語言比如 Spec# 已經引入了非null
引用的宣告。這正是我在1965年拒絕的解決方案。” —— 《Null References: The Billion Dollar Mistake》託尼·霍爾(Tony Hoare),圖靈獎得主。
在1996年,Java 1.0 出現的時候,這種對 NPE 的排斥還部署很明顯。下面讓我們看一個比較常見的例子:Java API 中獲取檔案列表File.list()
這個方法,該方法可以如下使用,列出一個資料夾下的所有檔名稱:
for (String name : new File("directory").list()) { System.out.println(name); }
上面這段程式碼,這有在資料夾 “directory” 存在的時候才能正常執行,否則會丟擲一個NPE
,因為list()
不能接受一個NPE
。相信我們是不會寫出這樣的程式碼的,首先list()
方法已經清楚明白地表面了,如果 “directory” 為空,則會丟擲NPE
(通過throw
關鍵字宣告),其次,現在的一些 IDE 其實也會提示我們,這段程式碼這有使用會出現NPE
問題。
但是,為啥我們在使用 Java 程式設計時i,還經常犯 NPE 的錯誤呢,這裡就不展開了,有很多資料能給出原因。事實上,在絕大多數情況下,我們的 API 在被呼叫時,是不希望返回null
的。當然,在極端情況下,比如某些屬性缺失,我們可能會返回一個「空物件」(比如空的集合,未定義的領域模型等),而不是直接返回null
或者丟擲異常,這樣做能給我們減少些許罪惡感…這也就是 File.list 的「現代」版本:Files.newDirectoryStream
的設計哲學:沒有null
了。
注:Files.newDirectoryStream
的方法簽名如下:
public static DirectoryStream<Path> newDirectoryStream(Path dir, DirectoryStream.Filter<? super Path> filter) throws IOException{ return provider(dir).newDirectoryStream(dir, filter); }
因此,當我們看到null
在某些情況(比如效能優化,沒有初始化的引用等)下作為方法的返回結果時,會讓正常的程式碼流變得糟糕,因為沒有合適的方法去處理。其實,很少需要去處理null
,但是為了不讓程式出錯,又不得不去寫一些又臭又長的程式碼去 處理null
:
String[] list = new File("directory").list(); if (list != null) { for (String name : list) { System.out.println(name); } }
這種繁瑣且不優雅的程式碼,我們當然是拒絕的~除非,你的客戶告訴你線上出故障了,這個時候你只能屈服了。
對null
的恐懼,會出現一些極端的編碼實踐。比如,有一些 Java 的編碼規範就完全禁止null
的使用,迫使研發使用一些非常規的手段去規避這個問題。不知道你有沒有看過一個程式碼庫,裡面的每一類領域模型,都需要去整合一個null
的介面,而且需要去手動編寫所謂的「空物件」例項。如果你沒有見過,那好,我相信你肯定見過類似Optional<T>
這種包裝器型別了吧,而這僅僅是為了避免null
的使用,就汙染了一片 Java 程式碼。
有一些和集合相關的 API,已經禁止null
這種元素了,使用這類 API 少了不少風險。有很多 Java 團隊的核心成員認為,在 Java 的Collection SDK
中支援null
是一種錯誤的做法,真是一個悲傷的故事~
而事實上,null
這個概念不是一個錯誤,出錯的是 Java 的型別系統——後者將null
視為所有型別的成員。比如,“abc” 是一個有效的 String 型別,但是null
不是一個有效的 String 型別。對於前者而言,你能使用所有 String 下的方法,而對於後者,任何呼叫都會在執行時出錯。這真的是所謂的型別安全麼?並不是。如果說,執行時下,某幾個操作在特定的一些值上會出錯(比如除數為0),我們可以視為是正常的,那麼如果有有那麼一個值,它會導致所有的操作,我們是不是首先應該考慮,這個值它是否應該屬於這個型別呢?在 Java 語言中,所有的NPE
錯誤都表明,Java 的型別系統是存在缺陷的。
更像「型別安全」的一些語言,比如說 Kotlin,通過將null
和型別系統的結合來修復上面提到的缺陷。檢查機制或增加告警,也能有一些作用,但還不夠。很顯然,在型別安全的系統裡,只會允許合理的值存在,比如String
型別,那麼 String 型別的值是可以支援所有String
的操作的。在 Kotlin 裡,把null
放到一個String
型別的變數時,不僅是警告,還會直接報錯,這就類似於把 42 這種數字賦值給 String 型別變數一樣。
在型別系統裡,合理地使用null
可以為我們的 API 設計工作引入一些變化。沒有任何理由去恐懼null
,返回為null
的方法(本應返回String
型別)和返回非null
型別的 String 應該視為一體,就如返回 String 型別的方法和返回為Integer
型別的方法一樣。這些方法的差異只是返回值不同,我們只要有安全的編碼應對這些返回值即可。
型別安全的null
當然是更好的選擇,因為更高效,更簡潔,避免各種形式化地處理 “空返回結果”。讓我們看一下 Kotlin 的標準方法庫,或許能獲取一些靈感。例如,String.toIntOrNull() 這個方法可以將一個字串解析成一個整型,或者返回一個null
,使用起來很方便。我們可以寫一個命令列的應用,這個應用接受一個整型引數,當引數異常時,會給出合理的反饋。
fun main(args: Array<String>) { val id = args.getOrNull(0)?.toIntOrNull() ?: error("id expected") // ... }
大膽地在你的 API 中使用null
吧,在 Kotlin 裡,null
會是你的朋友。我們沒有任何理由去害怕null
,也沒必要使用空物件、包裝類、拋異常等方式去消除null
。在 API 中合理地使用null
,可以使得你的程式碼的易讀性、健壯性更上層樓,也不用受一些開發模式的限制。