译注(3): NULL-计算机科学上最糟糕的失误
原文:the worst mistake of computer science
注釋:有些術語不知道怎么翻譯,根據自己理解的意思翻譯了,如有不妥,敬請提出:)
致謝: @vertextao @fracting
比windows反斜杠還丑,比===還古老,比PHP還常見,比跨域資源共享(CORS)還不幸,比Java泛型還令人失望,比XMLHttpRequest還不一致,比C語言的預處理器還讓人糊涂,比MongoDB還古怪,比UTF-16還令人遺憾。計算機科學里最糟糕的失誤在1965年被引入。(注:可分別參考索引[1]-[9])
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.
 – Tony Hoare, inventor of ALGOL W.
為了紀念Hoare([10],[11],[13],[14],[15],[16],英國計算機科學家東尼·霍爾,霍爾邏輯的發明者,他還發明了并發理論Communicating Sequential Processes(CSP))的‘null’誕生50周年,這篇文章解釋了null是什么,為什么它是如此糟糕,以及如何正確解決它。
NULL錯在哪?
最簡短的答案是:NULL是個沒有值的值,那便是問題所在。( The short answer: NULL is a value that is not a value. And that’s a problem. 感謝 @vertextao 對本句翻譯的推薦)
它已經在最流行的編程語言中潰爛(festered)了,有各種叫法:NULL, nil, None, Nothing, Nil, nullptr等。每個編程語言里都有一些細微都差別。(注:C/C++:NULL, Lua: nil, python:None, VB:Nothing, ObjectC:Nil, C++11: nullptr)
NULL帶來的問題,有些是在特定語言里才有的,有些則是普遍的,少數是同一個問題在不同語言里的不同表現。
NULL是:
- 破壞類型(subverts types)
 - 草率的(is sloppy)
 - 特例(is a special case)
 - 使API捉襟見肘(makes poor APIs)
 - 加劇了不好的編程策略(exacerbates poor language decisions)
 - 難以調試(is difficult to debug)
 - 不可組合的(is non-composable)
 
1. NULL破壞類型(NULL subverts types)
靜態類型語言不需要執行程序就可以檢查程序中類型的使用,從而對程序的行為提供一定程度的保證。
例如,在Java里面,我可以寫x.toUppercase(),編譯器就會檢查x的類型。如果x是個String類型,類型檢測就通過;如果x是個Socket類型,類型檢測就失敗。
靜態類型檢測在編寫大型、復雜軟件中十分有用。但是對于Java,這些漂亮的編譯時檢測有著致命的缺陷(suffer from a fatal flaw):任何引用都可能是個null,而且在一個null對象上調用方法會導致拋出NullPointerException異常。因此:
- toUppercase可以被不是null的String對象安全地調用。
 - read()可以被不是null的InputStream對象安全地調用。
 - toString()可以被不是null的Object對象安全地調用。
 
Java并不是唯一犯錯的編程語言。許多其他編程語言都有這個缺陷,當然也包括了ALGOL語言。
在這些語言里,NULL默默地跳過了類型檢測,等到運行時爆發各種NULL引用錯誤,所有的類型都用NULL表示沒有這個語義。
2. NULL是草率的(is sloppy)
許多時候,使用null是沒有意義的。然而不幸的是,只要語言允許任意對象可以是NULL,那么任意對象就可能是NULL。
從而Java程序員可能會因為總是要寫如下的代碼而患上腕管綜合癥。
if(str==null || str.equals("")){}因為這個慣用法太常見,C#語言給String類型增加了String.IsNullOrEmpty方法:
if(string.IsNullOrEmpty(str)){}真是令人憎惡。
Every time you write code that conflates null strings and empty strings, the Guava team weeps.
 – Google Guava
說的好。但是當你的類型系統(例如Java和C#)允許到處使用NULL,你就不能排除NULL的可能出現,并且它一定會傳遞的到處都是。
Null的普遍存在導致了Java8增加了一個@NonNull修飾關鍵字讓類型系統有效地修正這個缺陷。
3. NULL是個特例(is a special-case)
由于NULL是一個沒有值的值,在許多情況下NULL變成了一個需要特別處理的地方。
指針(Pointers)
例如,考慮C++語言:
char c = 'A'; char *myChar = &c; std::cout<<*myChar<<std::endl;myChar是一個char*類型,也就是一個指針,既指向char類型變量的內存地址。編譯器會檢測它的類型,因此下面的代碼是無效的:
char *myChar = 123; // 編譯錯誤 std::cout<< *myChar << std::endl;由于123不能保證是一個char類型變量的地址,編譯器直接報錯。但是如果我們把數字換成0(在C++里0代表NULL),那么編譯器就可以通過:
char *myChar = 0; std::cout << *myChar << std::endl; // 運行時錯誤就像123一樣,NULL也不是一個有效的char變量地址,運行時就報錯,但是由于0(NULL)是一個特例,編譯器通過了它。
字符串(Strings)
另一個特例是C語言的null結尾字符串。這個例子和其他例子有點不同,沒有指針或引用。但是同樣是由NULL是個沒有值的值這個做法導致的,在C語言的字符串里,0是一個不是字符(char)的字符(char)。
一個C風格字符串是一串以0結尾的字節數組。例如:
因此,C風格字符串里的字符可以是任意的256字節,除了0(NULL 字符)。這導致了C風格字符串的長度計算是O(n)的時間復雜度,更糟糕的是,C風格字符串不能表示ASCII或者擴展ASCII,而只能表示ASCIIZ。
(注:0和NULL是不同的,文章里的這個地方似乎沒有說明這點,這個例子有待商榷,但不妨礙文章對NULL存在問題的分析。但是其實char* 只是一個容器,你可以往char* 數組里塞入任何編碼的字符串數據,只要你解碼的時候能轉的回去就可以,例如你可以在里面塞入UTF-8字符串,當然這是計算機的另一面:任何數據的意義都取決于如何理解/解碼)
這個NULL字符特例,導致了許多問題:怪異的API,安全漏洞和緩存溢出。NULL是計算機科學里最糟糕的失誤,特別的,NULL結尾字符串是最糟糕的1字節擴展失誤。
4. NULL使API捉襟見肘(makes poor APIs)
下一個例子里,我們考察下動態語言的情況,你會看到在動態語言里NULL依然被證明是個糟糕的失誤。
鍵值存儲(Key-value store)
假設我們在Ruby語言里創建了一個類用來做鍵值的存儲。例如一個緩存類,或者一個Key-value類型的數據庫存儲接口等。我們創建如下簡單的通用API:
class Store### associate key with value# def set(key, value)...end### get value associated with key, or return nil if there is no such key#def get(key)...end end你可以想象下這個接口在其他語言里(Python、JavaScript、Java、C#等)的情況,大同小異。假設我們的程序里查找用戶的電話是一個很慢的資源密集型的方式,有可能訪問了一個web service來查找。為了提高性能,我們會使用Store來做緩存,使用用戶名字做鍵,用戶電話做值。
store = Store.new() store.set('Bob', '801-555-5555') store.get('Bob') # returns '801-555-5555', which is Bob’s number store.get('Alice') # returns nil, since it does not have Alice但是現在get接口的返回值產生了二義性!它可能意味著:
一種情況下需要耗時的重新計算,另一種情況下則是秒回。但是我們的程序并沒有足夠充分地區分這兩種情況。在實際的代碼里,這種情況經常出現,以一種復雜而微妙的方式呈現,并不容易直接識別。從而,本來簡潔通用的API需要做各種特殊情況的處理,而增加了代碼的繁雜。
雙重麻煩
JavaScript語言有同樣的問題,而且對于每個對象都存在該問題。如果一個對象的屬性(property)不存在,JavaScript返回了一個值來表示,JavaScript的設計者可以選擇使用null來表示。
但是他們擔心屬性可能是存在,但是值被設置為了null。糟糕的是,JavaScript增加了一個undefined對象來區分null屬性和不存在兩種情況。
但是如果一個屬性是存在的,可是被設置為undefined了呢?JavaScript沒有考慮這點。實際上你沒辦法區分屬性不存在和屬性是undefined。
因此,JavaScript應該只使用一個,而不是造出了兩個不同的NULL。
(注:事實上,許多JavaScript編程規范也建議只用xx==null和xx!=null來比較一個值是null或undefined,而不建議使用===做與null和undefined的比較,其實就是只把它們當作一個NULL來看待)
5. NULL加劇了不好的編程策略(exacerbates poor language decisions)
Java語言會默默地在引用類型(reference types)和基本類型(Primitive types)之間做轉換(裝箱和拆箱),這使得問題變得更怪異。
例如,下面的代碼無法通過編譯:
int x = null; // compile error但是,下面的代碼可以通過編譯,但是運行時卻會拋出NullPointerException:
Integer i = null; int x = i; // runtime error成員方法可以被null調用已經夠糟糕了,更糟的是你根本沒看見成員方法被調用。
6. NULL難以調試(difficult to debug)
C++語言是NULL的重災區。在NULL指針上調用一個方法甚至不會導致程序的立刻崩潰,而是:它可能會導致程序崩潰。
#include <iostream> struct Foo {int x;void bar() {std::cout << "La la la" << std::endl;}void baz() {std::cout << x << std::endl;} }; int main() {Foo *foo = NULL;foo->bar(); // okayfoo->baz(); // crash }如果使用GCC編譯上述代碼,第一個調用會成功,而第二個調用會崩潰。為什么呢?這是因為foo->bar()的值編譯期可以確定,所以編譯器直接繞過了運行時查找vtable,轉成了調用一個靜態的方法Foo_bar(foo),并且把this作為第1個參數傳遞進去。由于bar方法里并沒有對NULL指針做解引用(dereference)動作,因此不會崩潰。然而baz就沒這么幸運了,直接導致了segmentation fault。
但是假設,我們讓bar成為一個virtual方法,意味著它可能被子類覆蓋。
...virtual void bar() { ...作為一個虛函數,foo->bar()需要在運行時對vtable做查找,以確認bar()方法是否被子類覆蓋。而由于foo是個NULL指針,當調用foo->bar()的時候,程序就會因為對NULL做解引用而崩潰。
int main() {Foo *foo = NULL;foo->bar(); // crashfoo->baz(); }NULL讓調試變得十分不直觀,讓調試變得十分困難。準確地說,對NULL指針做解引用是一個未定義的C++行為(C++標準并沒有規定),所以不同的編譯器(平臺、版本)都可能有不同的做法,技術上來說你根本不知道會發生什么。再一次,在實際的程序里,這種情況往往隱藏在復雜的代碼里,而不是如上面代碼那樣直接可以觀察到。
7.NULL帶來不可組合(non-composable)
編程語言是構建在組合的基礎上:在一個抽象層上使用另一個抽象層的能力。這可能是唯一的對所有編程語言(programing language)、類庫(library)、框架(framework)、范式(paradigm)、API來說都重要的特性(feature)。
(注:有一句話說“任何一個軟件問題都可以通過添加一個抽象層解決”,但是這個說法不是萬能的,例如文章作者吐槽的Java泛型就是一個例子,底層不修改,只通過擦除的方式支持泛型,在運行期就會丟失泛型信息,參考[6])
事實上,組合性是許多問題背后的根本問題。但是,像上面的Store類的API,返回nil既可能是用戶不存在,也可能是用戶存在但沒有電話號碼,就不具有可組合性。
C#添加了一些語法特性來解決NULL帶來的問題。例如,Nullable<T>。你可以使用“可空”(nullable)類型。示例代碼如下:
int a = 1; // integer int? b = 2; // optional integer that exists int? c = null; // optional integer that does not exist但是Nullable里面的T只能是非可空類型,這并不能更好的解決Store的問題。例如
(注:C#實際上已經提供了解決方案。)
解決方案(The solution)
NULL到處都是,從低級語言到高級語言里都有。以至于大家默認假設NULL是必要的,就像整型運算、或者I/O一樣。
然而并非如此!你可以使用一個完全沒有NULL的語言。問題的根本在于NULL是表示沒有值的值(non-value value),作為一個哨兵,作為一個特殊例子,蔓延到到處。
我們需要一個包含信息的實體,它應該具備:
例如,在Scala語言里,Some[T]持有一個類型為T的值。None持有“沒有值”。它們都是Option[T]的自類型:
對于不熟悉Maybe/Options類型的讀者來說,可能認為這換湯不換藥,只是從一種垃圾(NULL類型)轉成了另一種垃圾(NULL類型)。然而它們之間有著細微而關鍵的不同。
在一個靜態語言里,你無法用None代替任意類型繞過類型系統。None只能在我們確實需要一個Option類型的地方使用。Option被類型系統顯式化了。
在一個動態語言里,你不能混淆Maybe/Option和一個含有值的類型。
讓我們回到最開始的Store類,但是這次我們假設ruby被升級為了“ruby-possibly”語言。如果值存在,Store類會返回了Some類型,而如果值不存在,會返回None類型。對于電話號碼這個例子,Some被用來表示一個電話號碼,None被用來表示沒有電話號碼。因此,存在兩層的“存在/不存在”表示:
最根本的區別是,“不存在”和“值是垃圾”之間不再混合在一起。
維護Maybe/Option
讓我們繼續展示更多的non-NULL代碼。假設在Java8+,我們有一個整數可能存在或不存在,如果存在,我們就把它打印出來。
Optional<Integer> option = ... if (option.isPresent()) {doubled = System.out.println(option.get()); }這個代碼已經解決了問題,但是許多Maybe/Option的實現,提供了更好的函數式方案,例如Java:
option.ifPresent(x -> System.out.println(x)); // or option.ifPresent(System.out::println)代碼更短只是一個方面,更重要的是這更安全一些。記住如果一個值不存在,那么option.get()會拋出錯誤。前面的例子里,get()方法的調用在一個if判斷語句的保護范圍內。而在這個例子里,ifPresent()是get()調用的保證。這個代碼明顯沒有BUG,這比沒有明顯的BUG好很多。(It makes there obviously be no bug, rather than no obvious bugs.)
Options可以被看作是一個長度為1的容器。例如,我們可以讓有值的時候放大兩倍,沒值的時候保持為空:
option.map(x -> 2 * x);我們也可以在option對象上做一個操作,讓它返回一個option對象,然后再壓扁它。(注:也就把Option<Option<T>>壓扁成Option<T>)
option.flatMap(x -> methodReturningOptional(x));我們可以為option提供一個默認值,如果它不存在的話:
option.orElseGet(5);小結一下,Maybe/Option的價值在于:
Down with NULL!
NULL的糟糕設計在持續的造成編寫代碼的痛點。只有一些語言提供了正確的解決方案來避免錯誤。如果你必須選擇一個含有NULL的語言,至少你應該理解這些缺點,并使用Maybe/Option等價的策略。
下面是NULL/Maybe在不同語言里的支持得分情況
(注:C#實際得分應該更高,文章后有評論提到“C# should have 4 stars as it has support for your proposed solution (since .NET version 2.0… which came out in 2005) via the Nullable struct.”)
 (注: 這個圖里沒有包括最新的TypeScript,TypeScript的設計者和C#的設計者都是 Anders Hejlsberg )
評分規則如下:
什么時候NULL是合適的(When is NULL okay)
在少數特殊的情況下,0和NULL在減少CPU周期,改進性能方面,是有用的。例如在C語言里,有用的0和NULL應該被保留。
真正的問題
NULL背后反應的本質問題是:一個同樣的值含有兩種或多種不同的語義,例如indexOf返回-1,NUL終結的C風格string是另一個例子。
(注:但是其實數據本身是沒有意義的,程序如何解釋數據,不僅僅依靠類型,只是說如果類型沒有提供好的內置支持,痛點總是存在和更容易傳播,參考破窗效應[12]。)
(注:沒有Maybe的時候,文章中的例子,解決二義性問題當然可以用不同錯誤碼解決,但是null問題無處不在,每個case你都要面對,不信查查你的代碼。)
references
(注:我根據需要,補充了這些資料,也都很有意思,可點開進一步閱讀。)
[1] Why Windows Uses Backslashes and Everything Else Uses Forward Slashes
 [2] Why is the DOS path character "/"?
 [3] JavaScript equality game
 [4] Why does PHP suck?
 [5] wiki:CORS
 [6] Java Generics Suck
 [7] MDN:XMLHttpRequest
 [8] GCC:Macro
 [9] wiki:UTF-16
 [10] wiki:Tony Hoare
 [11] wiki-zh-cn: Tony Hoare
 [12] wiki: Broken windows theory(破窗效應)
 [13] wiki: Hoare logic
 [14] wiki-zh-cn: Hoare logic
 [15] Communicating Sequential Processes(CSP)
 [16] A Conversation with Sr. Tony Hoare
轉載于:https://www.cnblogs.com/math/p/null.html
總結
以上是生活随笔為你收集整理的译注(3): NULL-计算机科学上最糟糕的失误的全部內容,希望文章能夠幫你解決所遇到的問題。
                            
                        - 上一篇: 8天玩转并行开发——第五天 同步机制(下
 - 下一篇: Perl 中级教程 第5章课后习题