1. 程式人生 > >譯註(3): NULL-電腦科學上最糟糕的失誤

譯註(3): NULL-電腦科學上最糟糕的失誤

原文the worst mistake of computer science
註釋:有些術語不知道怎麼翻譯,根據自己理解的意思翻譯了,如有不妥,敬請提出:)

比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])的‘null’誕生50週年,這篇文章解釋了null是什麼,為什麼它是如此糟糕,以及如何正確解決它。

NULL錯在哪?

最簡短的答案是:NULL是個沒有值的值。並且那是個問題。

它已經在最流行的程式語言中潰爛(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並不是唯一犯錯的程式語言。許多其他程式語言都有這個缺陷,當然也包括了AGOLW語言。

在這些語言裡,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。(注:但是其實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介面的返回值產生了二義性!它可能意味著:

  1. 快取裡不存在該使用者,例如Alice。
  2. 快取裡存在該使用者,但是該使用者沒有電話號碼。

一種情況下需要耗時的重新計算,另一種情況下則是秒回。但是我們的程式並沒有足夠充分地區分這兩種情況。在實際的程式碼裡,這種情況經常出現,以一種複雜而微妙的方式呈現,並不容易直接識別。從而,本來簡潔通用的API需要做各種特殊情況的處理,而增加了程式碼的繁雜。

雙重麻煩

JavaScript語言有同樣的問題,而且對於每個物件都存在該問題。如果一個物件的屬性(property)不存在,JavaScript返回了一個值來表示,JavaScript的設計者可以選擇使用null來表示。

但是他們擔心屬性可能是存在,但被設定為了null但值。糟糕的是,JavaScript增加了一個undefined物件來區分null屬性和不存在兩種情況。

但是如果一個屬性是存在的,可是被設定為undefined了呢?JavaScript沒有考慮這點。實際上你沒辦法區分屬性不存在和屬性是undefined。

因此,JavaScript應該只使用一個,而不是造出了兩個不同的NULL。(注:事實上,許多JavaScript程式設計規範也建議只用xx==nullxx!=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(); // okay
    foo->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(); // crash
    foo->baz();
}

NULL讓除錯變得十分不直觀,讓除錯變得十分困難。準確的說,對NULL指標做解引用是一個為定義的C++行為(C++標準並沒有規定),所以不同的編譯器(平臺、版本)都可能有不同的做法,技術上來說你根本不知道會發生什麼。再一次,在實際的程式裡,這種情況往往隱藏在複雜的程式碼裡,而不是如上面程式碼那樣直接可以觀察到。

7.NULL帶來不可組合(non-composable)

程式語言是構建在組合的基礎上:在一個抽象層上使用另一個抽象層的能力。(注:任何一個軟體問題都可以通過新增一個抽象層解決)。這可能是唯一的對所有程式語言(programing language)、類庫(library)、框架(framework)、正規化(paradigm)、API來說都重要的特性(feature)。

事實上,組合性是許多問題背後的根本問題。但是,像上面的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的問題。例如

  1. string一開始是一個可空型別,你就不能讓string變成非可空型別。
  2. 即使string是一個非可空型別,從而string?是可空型別。你仍然不能區分這種情況,是否有string??

:C#實際上已經提供瞭解決方案。(since .NET version 2.0… which came out in 2005) via the Nullable struct.)

解決方案(The solution)

NULL到處都是,從低階語言到高階語言裡都有。以至於大家預設假設NULL是必要的,就像整型運算、或者I/O一樣。

然而並非如此!你可以使用一個完全沒有NULL的語言。問題的根本在於NULL是表示沒有值的值(non-value value),作為一個哨兵,作為一個特殊例子,蔓延到到處。

我們需要一個包含資訊的實體,它應該具備:

  1. 能確定裡面是否含有值。
  2. 如果有值,可以包含任意型別。這正是Haskel的Maybe,Java的Optional,以及Swift的Optional等型別。

例如,在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被用來表示沒有電話號碼。因此,存在兩層的“存在/不存在”表示:

  1. 外層的Maybe表示使用者是否存在。
  2. 內層的Maybe表示存在的使用者是否含有電話號碼。
cache = Store.new()
cache.set('Bob', Some('801-555-5555'))
cache.set('Tom', None())

bob_phone = cache.get('Bob')
bob_phone.is_some # true, Bob is in cache
bob_phone.get.is_some # true, Bob has a phone number
bob_phone.get.get # '801-555-5555'

alice_phone = cache.get('Alice')
alice_phone.is_some # false, Alice is not in cache

tom_phone = cache.get('Tom')
tom_phone.is_some # true, Tom is in cache
tom_phone.get.is_some #false, Tom does not have a phone number

最根本的區別是,“不存在”和“值是垃圾”之間不再混合在一起。

維護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 >壓扁成Option

option.flatMap(x -> methodReturningOptional(x));

我們可以為option提供一個預設值,如果它不存在的話:

option.orElseGet(5);

小結一下,Maybe/Option的價值在於:

  1. 減少了對值存在和不存在假設的風險。(注:if語句很容易被程式設計師漏掉)
  2. 使得在option型別的資料上的操作簡單而又安全。
  3. 顯式地宣告任意的不安全的存在性假設(例如,使用.get()方法)。

Down with NULL!

NULL的糟糕設計在持續的造成編寫程式碼的痛點。只有一些語言提供了正確的解決方案來避免錯誤。如果你必須選擇一個含有NULL的語言,至少你應該理解這些缺點,並使用Maybe/Option等價的策略。

下面是NULL/Maybe在不同語言裡的支援得分情況

注1: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.
注2: 這個圖裡沒有包括最新的TypeScript,TypeScript的設計者和C#的設計者都是 Anders Hejlsberg

評分規則如下:

什麼時候NULL是合適的(When is NULL okay)

在少數特殊的情況下,0和NULL在減少CPU週期,改進效能方面,是有用的。例如在C語言裡,有用的0和NULL應該被保留。

真正的問題

NULL背後反應的本質問題是:一個同樣的值含有兩種或多種不同的語義,例如indexOf返回-1,NUL終結的C風格string是另一個例子。(注:但是其實資料本身是沒有意義的,程式如何解釋資料,不僅僅依靠型別,只是說如果型別沒有提供好的內建支援,痛點總是存在和更容易傳播,參考破窗效應)。

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(破窗效應)