原文:the worst mistake of computer science注释:有些术语不知道怎么翻译,根据自己理解的意思翻译了,如有不妥,敬请提出:)致谢: @vertextao @fracting
比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],[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结尾的字节数组。例如:
因此,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接口的返回值产生了二义性!它可能意味着:
- 缓存里不存在该用户,例如Alice。
- 缓存里存在该用户,但是该用户没有电话号码。
一种情况下需要耗时的重新计算,另一种情况下则是秒回。但是我们的程序并没有足够充分地区分这两种情况。在实际的代码里,这种情况经常出现,以一种复杂而微妙的方式呈现,并不容易直接识别。从而,本来简洁通用的API需要做各种特殊情况的处理,而增加了代码的繁杂。
双重麻烦
JavaScript语言有同样的问题,而且对于每个对象都存在该问题。如果一个对象的属性(property)不存在,JavaScript返回了一个值来表示,JavaScript的设计者可以选择使用null来表示。
但是他们担心属性可能是存在,但是值被设置为了null。糟糕的是,JavaScript增加了一个undefined对象来区分null属性和不存在两种情况。
但是如果一个属性是存在的,可是被设置为undefined了呢?JavaScript没有考虑这点。实际上你没办法区分属性不存在和属性是undefined。
因此,JavaScript应该只使用一个,而不是造出了两个不同的NULL。
(注:事实上,许多JavaScript编程规范也建议只用xx==null
和xx!=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的问题。例如
string
一开始是一个可空类型,你就不能让string变成非可空类型。- 即使
string
是一个非可空类型,从而string?
是可空类型。你仍然不能区分这种情况,是否有string??
。
(注:C#实际上已经提供了解决方案。)
解决方案(The solution)
NULL到处都是,从低级语言到高级语言里都有。以至于大家默认假设NULL是必要的,就像整型运算、或者I/O一样。
然而并非如此!你可以使用一个完全没有NULL的语言。问题的根本在于NULL是表示没有值的值(non-value value),作为一个哨兵,作为一个特殊例子,蔓延到到处。
我们需要一个包含信息的实体,它应该具备:
- 能确定里面是否含有值。
- 如果有值,可以包含任意类型。这正是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被用来表示没有电话号码。因此,存在两层的“存在/不存在”表示:
- 外层的Maybe表示用户是否存在。
- 内层的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的价值在于:
- 减少了对值存在和不存在假设的风险。(注:if语句很容易被程序员漏掉)
- 使得在option类型的数据上的操作简单而又安全。
- 显式地声明任意不安全的存在性假设(例如,使用.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 )
评分规则如下:
什么时候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