“NULL”:计算机科学中的最严重错误,造成十亿美元损失

2015-11-01 10:24


杯具啊!我们公司有个职工姓 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),图灵奖得主

为纪念 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 的地址,所以编译失败。无论如何,如果我们将数字改为0(在 C++ 中 0 是 NULL),那么可以编译通过:

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

和 123 一样,NULL 实际上不是一个 char 的地址。但是这次编译器还是允许它编译通过,因为 0(NULL)是一个特例。

字符串

还有另一个特例,即发生在 C 语言中以 NULL 结尾的字符串。这与其它的例子有点不同,因为这里没有指针或者引用。但是不是一个值却又起到一个值的作用这个思想还在,此处以不是一个 char 却起到一个 char 的形式存在。

一个 C 字符串是一连串的字节,并且以 NUL (0) 字节结尾。

C-string

因此,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] 的子类型,这两个子类型可能保存了一个值,也可能没有值。

不熟悉 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 指针,但是它们对于语言自身并没有实际的用处。

  • 例子: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 也仅仅是基本的哨兵问题的一种形式。