译注(3): NULL-计算机科学上最糟糕的失误

时间:2023-03-10 02:22:55
译注(3): NULL-计算机科学上最糟糕的失误

原文the worst mistake of computer science

注释:有些术语不知道怎么翻译,根据自己理解的意思翻译了,如有不妥,敬请提出:)

致谢: @vertextao @fracting

比windows反斜杠还丑,比===还古老,比PHP还常见,比跨域资源共享(CORS)还不幸,比Java泛型还令人失望,比XMLHttpRequest还不一致,比C语言的预处理器还让人糊涂,比MongoDB还古怪,比UTF-16还令人遗憾。计算机科学里最糟糕的失误在1965年被引入。(:可分别参考索引[1]-[9])

译注(3): NULL-计算机科学上最糟糕的失误

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结尾的字节数组。例如:

译注(3): NULL-计算机科学上最糟糕的失误

因此,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接口的返回值产生了二义性!它可能意味着:

  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)。

:有一句话说“任何一个软件问题都可以通过添加一个抽象层解决”,但是这个说法不是万能的,例如文章作者吐槽的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的问题。例如

  1. string一开始是一个可空类型,你就不能让string变成非可空类型。
  2. 即使string是一个非可空类型,从而string?是可空类型。你仍然不能区分这种情况,是否有string??

:C#实际上已经提供了解决方案。)

解决方案(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]的自类型:

译注(3): NULL-计算机科学上最糟糕的失误

对于不熟悉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<T>>压扁成Option<T>

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在不同语言里的支持得分情况

: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

译注(3): NULL-计算机科学上最糟糕的失误

评分规则如下:

译注(3): NULL-计算机科学上最糟糕的失误

什么时候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