“NULL”:計算機科學中的最嚴重錯誤,造成十億美元損失

wwwxd 9年前發布 | 23K 次閱讀 計算機

杯具啊!我們公司有個職工姓 Null,當用他的姓氏做查詢詞時,把所有員工查詢應用給弄崩潰了! 我該腫么辦?

在 1965 年有人提出了這個計算機科學中最糟糕的錯誤,該錯誤比 Windows 的反斜線更加丑陋、比 === 更加怪異、比 PHP 更加常見、比 CORS 更加不幸、比 Java 泛型更加令人失望、比 XMLHttpRequest 更加反復無常、比 C 預處理器更加難以理解、比 MongoDB 更加容易出現碎片問題、比 UTF-16 更加令人遺憾。

“我把 Null 引用稱為自己的十億美元錯誤。它的發明是在1965 年,那時我用一個面向對象語言( ALGOL W )設計了第一個全面的引用類型系統。我的目的是確保所有引用的使用都是絕對安全的,編譯器會自動進行檢查。但是我未能抵御住誘惑,加入了Null引用,僅僅是因為實現起來非常容易。它導致了數不清的錯誤、漏洞和系統崩潰,可能在之后 40 年中造成了十億美元的損失。近年來,大家開始使用各種程序分析程序,比如微軟的 PREfix 和 PREfast 來檢查引用,如果存在為非 Null 的風險時就提出警告。更新的程序設計語言比如 Spec# 已經引入了非 Null 引用的聲明。這正是我在1965年拒絕的解決方案。” 

—— 《Null References: The Billion Dollar Mistake》托尼·霍爾(Tony Hoare),圖靈獎得主

“NULL”:計算機科學中的最嚴重錯誤,造成十億美元損失

為紀念 Hoare 先生的 null 錯誤五十周年,這篇文章將會解釋何為 null、為什么它這么可怕以及如何避免。

NULL 怎么了?

簡單來說:NULL 是一個不是值的值。那么問題來了。

這個問題已經在有史以來最流行的語言中惡化,而它現在有很多名字:NULL、nil、null、None、Nothing、Nil 和 nullptr。每種語言都有自己的細微差別。

NULL 導致的問題,有一些只涉及某種特定的語言,而另一些則是普遍存在的;少量只是某個問題的不同方面。

NULL…

  1. 顛覆類型
  2. 是凌亂的
  3. 是一個特例
  4. 使 API 變得糟糕
  5. 使錯誤的語言決策更加惡化
  6. 難以調試
  7. 是不可組合的

1. NULL 顛覆類型

靜態類型語言不需要實際去執行程序,就可以檢查程序中類型的使用,并且提供一定的程序行為保證。

例如,在 Java 中,如果我編寫 x.toUppercase(),編譯器會檢查 x 的類型。如果 x 是一個 String,那么類型檢查成功;如果 x 是一個 Socket,那么類型檢查失敗。

在編寫龐大的、復雜的軟件時,靜態類型檢查是一個強大的工具。但是對于 Java,這些很棒的編譯時檢查存在一個致命缺陷:任何引用都可以是 null,而調用一個 null 對象的方法會產生一個 NullPointerException。所以,

  • toUppercase() 可以被任意 String 對象調用。除非 String 是 null。
  • read() 可以被任意 InputStream 對象調用。除非 InputStream 是 null。
  • toString() 可以被任意 Object 對象調用。除非 Object 是 null。

Java 不是唯一引起這個問題的語言;很多其它的類型系統也有同樣的缺點,當然包括 AGOL W 語言。

在這些語言中,NULL 超出了類型檢查的范圍。它悄悄地越過類型檢查,等待運行時,最后一下子釋放出一大批錯誤。NULL 什么也不是,同時又什么都是。

2. NULL 是凌亂的

在很多情況下 null 是沒有意義的。不幸的是,如果一種語言允許任何東西為 null,好吧,那么任何東西都可以是 null。

Java 程序員冒著患腕管綜合癥的風險寫下

if (str == null || str.equals("")) {
}

而在 C# 中添加 String.IsNullOrEmpty 是一個常見的語法

if (string.IsNullOrEmpty(str)) {
}

真可惡!

每次你寫代碼,將 null 字符串和空字符串混為一談時,Guava 團隊都要哭了。– Google Guava

說得好。但是當你的類型系統(例如,Java 或者 C#)到處都允許 NULL 時,你就不能可靠地排除 NULL 的可能性,并且不可避免的會在某個地方混淆。

null 無處不在的可能性造成了這樣一個問題,Java 8 添加了 @NonNull 標注,嘗試著在它的類型系統中以追溯方式解決這個缺陷。

3. NULL 是一個特例

考慮到 NULL 不是一個值卻又起到一個值的作用,NULL 自然地成為各種特別處理方法的課題。

指針

例如,請看下面的 C++ 代碼:

char c = 'A';
char *myChar = &c;
std::cout << *myChar << std::endl;

myChar 是一個 char *,意味著它是一個指針——即,將一個內存地址保存到一個 char中。編譯器會對此進行檢驗。因此,下面的代碼是無效的:

char *myChar = 123; // compile error
std::cout << *myChar << std::endl;

因為 123 不保證是一個 char 的地址,所以編譯失敗。無論如何,如果我們將數字改為(在 C++ 中 0 是 NULL),那么可以編譯通過:

char *myChar = 0;
std::cout << *myChar << std::endl; // runtime error

和 123 一樣,NULL 實際上不是一個 char 的地址。但是這次編譯器還是允許它編譯通過,因為 (NULL)是一個特例。

字符串

還有另一個特例,即發生在 C 語言中以 NULL 結尾的字符串。這與其它的例子有點不同,因為這里沒有指針或者引用。但是不是一個值卻又起到一個值的作用這個思想還在,此處以不是一個 char 卻起到一個 char 的形式存在。

一個 C 字符串是一連串的字節,并且以 NUL (0) 字節結尾。

“NULL”:計算機科學中的最嚴重錯誤,造成十億美元損失

因此,C 字符串的每個字符可以是 256 個字節中的任意一個,除了 0(即 NUL 字符)。這不僅使得字符串長度成為一個線性時間的運算;甚至更糟糕,它意味著 C 字符串不能用于 ASCII 或者擴展的 ASCII。相反,它們只能用于不常用的 ASCIIZ。

單個 NUL 字符的例外已經導致無數的錯誤:API 的怪異行為、安全漏洞和緩沖區溢出。

NULL 是 C 字符串中最糟糕的錯誤;更確切地說,以 NUL 結尾的字符串是最昂貴的一字節錯誤

4. NULL 使 API 變得糟糕

下一個例子,我們將會踏上旅程前往動態類型語言的王國,在那里 NULL 將再一次證明它是一個可怕的錯誤。

鍵值存儲

假設我們創建一個 Ruby 類充當一個鍵值存儲。這可能是一個緩存、一個用于鍵值數據庫的接口等等。我們將會創建簡單通用的 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# 等)。

現在假設我們的程序有一個慢的或者占用大量資源的方法,來找到某個人的電話號碼——可能通過連通一個網絡服務。

為了提高性能,我們將會使用本地存儲作為緩存,將一個人名映射到他的電話號碼上。

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

然而,一些人沒有電話號碼(即他們的電話號碼是 nil)。我們仍然會緩存那些信息,所以我們不需要在后面重新填充那些信息。

store = Store.new()
store.set('Ted', nil) # Ted has no phone number
store.get('Ted') # returns nil, since Ted does not have a phone number

但是現在意味著我們的結果模棱兩可!它可能表示:

  1. 這個人不存在于緩存中(Alice)
  2. 這個人存在于緩存中,但是沒有電話號碼(Tom)

一種情形要求昂貴的重新計算,另一種需要即時的答復。但是我們的代碼不夠精密來區分這兩種情況。

在實際的代碼中,像這樣的情況經常會以復雜且不易察覺的方式出現。因此,簡單通用的 API 可以馬上變成特例,迷惑了 null 凌亂行為的來源。

用一個 contains() 方法來修補 Store 類可能會有幫助。但是這引入重復的查找,導致降低性能和競爭條件。

雙重麻煩

JavaScript 有相同的問題,但是發生在每個單一的對象

如果一個對象的屬性不存在,JS 會返回一個值來表示該對象缺少屬性。JavaScript 的設計人員已經選擇了此值為 null。

然而他們擔心的是當屬性存在并且該屬性被設為 null 的情況。“有才”的是,JavaScript 添加了 undefined 來區分值為 null 的屬性和不存在的屬性。

但是如果屬性存在,并且它的值被設為 undefined,將會怎樣?奇怪的是,JavaScript 在這里停住了,沒有提供“超級 undefined”。

JavaScript 提出了不僅一種,而是兩種形式的 NULL。

5. NULL 使錯誤的語言決策更加惡化

Java 默默地在引用和主要類型之間轉換。加上 null,事情變得更加奇怪。

例如,下面的代碼編譯不過:

int x = null; // compile error

這段代碼則編譯通過:

Integer i = null;
int x = i; // runtime error

雖然當該代碼運行時會報出 NullPointerException 的錯誤。

成員方法調用 null 是夠糟糕的;當你從未見過該方法被調用時更糟糕。

6. NULL 難以調試

來解釋 NULL 是多么的麻煩,C++ 是一個很好的例子。調用成員函數指向一個 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() 在編譯時是已知的,所以編譯器避免一個運行時虛表查找,并將它轉換成一個靜態調用,類似 Foo_bar(foo),以此為第一個參數。由于 bar 沒有間接引用 NULL 指針,所以它成功運行。但是 baz 有引用 NULL 指針,所以導致一個段錯誤。

但是解設我們將 bar 變成虛函數。這意味著它的實現可能會被一個子類重寫。

...
virtual void bar() {
...

作為一個虛函數,foo->bar() 為 foo 的運行時類型做虛表查找,以防 bar() 被重寫。由于 foo 是 NULL,現在的程序會在 foo->bar() 這句崩潰,這全都是因為我們把該函數變成虛函數了。

int main() {
    Foo *foo = NULL;
    foo->bar(); // crash
    foo->baz();
}

NULL 已經使得 main 函數的程序員調試這段代碼變得非常困難和不直觀。

的確,在 C++ 標準中沒有定義引用 NULL,所以技術上我們不應該對發生的任何情況感到驚訝。還有,這是一個非病態的、常見的、十分簡單的、真實的例子,這個例子是在實踐中 NULL 變化無常的眾多例子中的一個。

7. NULL 是不可組合的

程序語言是圍繞著可組合性構建的:即將一個抽象應用到另一個抽象的能力。這可能是任何語言、庫、框架、模型、API 或者設計模式的一個最重要的特征:正交地使用其它特征的能力。

實際上,可組合性確實是很多這類問題背后的基本問題。例如,Store API 返回 nil 給不存在的值與存儲 nil 給不存在的電話號碼之間不具有可組合性。

C# 用 Nullable 來處理一些關于 NULL 的問題。你可以在類型中包括可選性(為空性)。

int a = 1;     // integer
int? b = 2;    // optional integer that exists
int? c = null; // optional integer that does not exist

但是這造成一個嚴重的缺陷,那就是 Nullable 不能應用于任何的 T。它僅僅能應用于非空的 T。例如,它不會使 Store 的問題得到任何改善。

  1. 首先 string 可以是空的;你不能創建一個不可空的 string
  2. 即使 string 是不可空的,以此創建 string?可能吧,但是你仍然無法消除目前情況的歧義。沒有 string??

解決方案

NULL 變得如此普遍以至于很多人認為它是有必要的。NULL 在很多低級和高級語言中已經出現很久了,它似乎是必不可少的,像整數運算或者 I/O 一樣。

不是這樣的!你可以擁有一個不帶 NULL 的完整的程序語言。NULL 的問題是一個非數值的值、一個哨兵、一個集中到其它一切的特例。

相反,我們需要一個實體來包含一些信息,這些信息是關于(1)它是否包含一個值和(2)已包含的值,如果存在已包含的值的話。并且這個實體應該可以“包含”任意類型。這是 Haskell 的 Maybe、Java 的 Optional、Swift 的 Optional 等的思想。

例如,在 Scala 中,Some[T] 保存一個 T 類型的值。None 沒有值。這兩個都是 Option[T] 的子類型,這兩個子類型可能保存了一個值,也可能沒有值。

“NULL”:計算機科學中的最嚴重錯誤,造成十億美元損失

不熟悉 Maybes/Options 的讀者可能會想我們已經把一種沒有的形式(NULL)替代為另一種沒有的形式(None)。但是這有一個不同點——不易察覺,但是至關重要。

在一種靜態類型語言中,你不能通過替代 None 為任意值來繞過類型系統。None 只能用在我們期望一個 Option 出現的地方。可選性顯式地表現于類型中。

而在動態類型語言中,你不能混淆 Maybes/Options 和已包含值的用法。

讓我們回到先前的 Store,但是這次可能使用 ruby。如果存在一個值,則 Store 類返回帶有值的 Some,否則反回 None。對于電話號碼,Some 是一個電話號碼,None 代表沒有電話號碼。因此有兩級的存在/不存在:外部的 Maybe 表示存在于 Store 中;內部的 Maybe表示那個名字對應的電話號碼。我們已經成功組合了多個 Maybe,這是我們無法用 nil 做到的。

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

本質的區別是不再有 NULL 和其它任何類型之間的聯合——靜態地類型化或者動態地假設,不再有一個存在的值和不存在的值之間的聯合。

使用 Maybes/Options

讓我們繼續討論更多沒有 NULL 的代碼。假設在 Java 8+ 中,我們有一個整數,它可能存在,也可能不存在,并且如果它存在,我們就把它打印出來。

Optional<Integer> option = ...
if (option.isPresent()) {
   doubled = System.out.println(option.get());
}

這樣很好。但是大多數的 Maybe/Optional 實現,包括 Java,支持一種更好的實用方法:

option.ifPresent(x -> System.out.println(x));
// or option.ifPresent(System.out::println)

不僅因為這種實用的方法更加簡潔,而且它也更加安全。需要記住如果該值不存在,那么 option.get() 會產生一個錯誤。在早些時候的例子中,get() 受到一個 if 保護。在這個例子中,ifPresent() 完全省卻了我們對 get() 的需要。它使得代碼明顯地沒有 bug,而不是沒有明顯的 bug。

Options 可以被認為是一個最大值為 1 的集合。例如,如果存在值,那么我們可以將該值乘以 2,否則讓它空著。

option.map(x -> 2 * x)

我們可以可選地執行一個運算,該運算返回一個可選的值,并且使結果趨于“扁平化”。

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

如果 none 存在,我們可以提供一個默認的值:

option.orElseGet(5)

總的來說,Maybe/Option 真正的價值是

  1. 降低關于值存在和不存在的不安全的假設
  2. 更容易安全地操作可選的數據
  3. 顯式地聲明任何不安全的存在假設(例如,.get() 方法)

不要 NULL!

NULL 是一個可怕的設計缺陷,一種持續不斷地、不可估量的痛苦。只有很少語言設法避免它的可怕。

如果你確實選擇了一種帶 NULL 的語言,那么至少要有意識地在你自己的代碼中避免這種不快,并使用等效的 Maybe/Option

常用語言中的 NULL:

“NULL”:計算機科學中的最嚴重錯誤,造成十億美元損失

“分數”是根據下面的標準來定的:

“NULL”:計算機科學中的最嚴重錯誤,造成十億美元損失

評分

對于上述表格的“評分”不要太認真。真正的問題是總結各種語言 NULL 的狀態和介紹 NULL 的替代品,并不是為了把常用的語言分等級。

部分語言的信息已經被修正過。出于運行時兼容性的原因,一些語言會有某種 null 指針,但是它們對于語言自身并沒有實際的用處。

  • 例子:Haskell 的 Foreign.Ptr.nullPtr 被用于 FFI(Foreign Function Interface),給 Haskell 編組值和從 Haskell 中編組值。
  • 例子:Swift 的 UnsafePointer 必須與 unsafeUnwrap 或者 ! 一起使用。
  • 反例:Scala,盡管習慣性地避免 null,仍然與 Java 一樣對待 null,以增強互操作。val x: String = null

什么時候 NULL 是 OK 的?

值得說明的是,當減少 CPU 周期時,一個大小一致的特殊值,像 0 或者 NULL 可以很有用,用代碼質量換取性能。當這真正重要的時候,它對于那些低級語言很方便,像 C,但是它真應該離開那里。

真正的問題

NULL 更加常見的問題是哨兵值:這些值與其它值一樣,但是有著完全不同的含義。從 indexOf 返回一個整數的索引或者整數 -1 是一個很好的示例。以 NULL 結尾的字符串是另一個例子。這篇文章主要關注 NULL,給出它的普遍性和真實的影響,但是正如 Sauron 僅僅是 Morgoth 的仆人,NULL 也僅僅是基本的哨兵問題的一種形式。

 本文由用戶 wwwxd 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!