转载

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

杯具啊!我们公司有个职工姓 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 团队都要哭了。 说得好。但是当你的类型系统(例如,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 字符串的每个字符可以是 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 也仅仅是基本的哨兵问题的一种形式。

正文到此结束
Loading...