原文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程序员可能会因为总是要写如下的代码而患上腕管综合症。

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

因为这个惯用法太常见,C#语言给String类型增加了String.IsNullOrEmpty方法:

  1. if(string.IsNullOrEmpty(str)){
  2. }

真是令人憎恶。

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++语言:

  1. char c = 'A';
  2. char *myChar = &c;
  3. std::cout<<*myChar<<std::endl;

myChar是一个char*类型,也就是一个指针,既指向char类型变量的内存地址。编译器会检测它的类型,因此下面的代码是无效的:

  1. char *myChar = 123; // 编译错误
  2. std::cout<< *myChar << std::endl;

由于123不能保证是一个char类型变量的地址,编译器直接报错。但是如果我们把数字换成0(在C++里0代表NULL),那么编译器就可以通过:

  1. char *myChar = 0;
  2. 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:

  1. class Store
  2. ##
  3. # associate key with value
  4. #
  5. def set(key, value)
  6. ...
  7. end
  8. ##
  9. # get value associated with key, or return nil if there is no such key
  10. #
  11. def get(key)
  12. ...
  13. end
  14. end

你可以想象下这个接口在其他语言里(Python、JavaScript、Java、C#等)的情况,大同小异。假设我们的程序里查找用户的电话是一个很慢的资源密集型的方式,有可能访问了一个web service来查找。为了提高性能,我们会使用Store来做缓存,使用用户名字做键,用户电话做值。

  1. store = Store.new()
  2. store.set('Bob', '801-555-5555')
  3. store.get('Bob') # returns '801-555-5555', which is Bob’s number
  4. 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)之间做转换(装箱和拆箱),这使得问题变得更怪异。

例如,下面的代码无法通过编译:

  1. int x = null; // compile error

但是,下面的代码可以通过编译,但是运行时却会抛出NullPointerException:

  1. Integer i = null;
  2. int x = i; // runtime error

成员方法可以被null调用已经够糟糕了,更糟的是你根本没看见成员方法被调用。

6. NULL难以调试(difficult to debug)

C++语言是NULL的重灾区。在NULL指针上调用一个方法甚至不会导致程序的立刻崩溃,而是:它可能会导致程序崩溃。

  1. #include <iostream>
  2. struct Foo {
  3. int x;
  4. void bar() {
  5. std::cout << "La la la" << std::endl;
  6. }
  7. void baz() {
  8. std::cout << x << std::endl;
  9. }
  10. };
  11. int main() {
  12. Foo *foo = NULL;
  13. foo->bar(); // okay
  14. foo->baz(); // crash
  15. }

如果使用GCC编译上述代码,第一个调用会成功,而第二个调用会崩溃。为什么呢?这是因为foo->bar()的值编译期可以确定,所以编译器直接绕过了运行时查找vtable,转成了调用一个静态的方法Foo_bar(foo),并且把this作为第1个参数传递进去。由于bar方法里并没有对NULL指针做解引用(dereference)动作,因此不会崩溃。然而baz就没这么幸运了,直接导致了segmentation fault。

但是假设,我们让bar成为一个virtual方法,意味着它可能被子类覆盖。

  1. ...
  2. virtual void bar() {
  3. ...

作为一个虚函数,foo->bar()需要在运行时对vtable做查找,以确认bar()方法是否被子类覆盖。而由于foo是个NULL指针,当调用foo->bar()的时候,程序就会因为对NULL做解引用而崩溃。

  1. int main() {
  2. Foo *foo = NULL;
  3. foo->bar(); // crash
  4. foo->baz();
  5. }

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)类型。示例代码如下:

  1. int a = 1; // integer
  2. int? b = 2; // optional integer that exists
  3. 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]的自类型:

对于不熟悉Maybe/Options类型的读者来说,可能认为这换汤不换药,只是从一种垃圾(NULL类型)转成了另一种垃圾(NULL类型)。然而它们之间有着细微而关键的不同。

在一个静态语言里,你无法用None代替任意类型绕过类型系统。None只能在我们确实需要一个Option类型的地方使用。Option被类型系统显式化了。

在一个动态语言里,你不能混淆Maybe/Option和一个含有值的类型。

让我们回到最开始的Store类,但是这次我们假设ruby被升级为了“ruby-possibly”语言。如果值存在,Store类会返回了Some类型,而如果值不存在,会返回None类型。对于电话号码这个例子,Some被用来表示一个电话号码,None被用来表示没有电话号码。因此,存在两层的“存在/不存在”表示:

  1. 外层的Maybe表示用户是否存在。
  2. 内层的Maybe表示存在的用户是否含有电话号码。
  1. cache = Store.new()
  2. cache.set('Bob', Some('801-555-5555'))
  3. cache.set('Tom', None())
  4. bob_phone = cache.get('Bob')
  5. bob_phone.is_some # true, Bob is in cache
  6. bob_phone.get.is_some # true, Bob has a phone number
  7. bob_phone.get.get # '801-555-5555'
  8. alice_phone = cache.get('Alice')
  9. alice_phone.is_some # false, Alice is not in cache
  10. tom_phone = cache.get('Tom')
  11. tom_phone.is_some # true, Tom is in cache
  12. tom_phone.get.is_some #false, Tom does not have a phone number

最根本的区别是,“不存在”和“值是垃圾”之间不再混合在一起。

维护Maybe/Option

让我们继续展示更多的non-NULL代码。假设在Java8+,我们有一个整数可能存在或不存在,如果存在,我们就把它打印出来。

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

这个代码已经解决了问题,但是许多Maybe/Option的实现,提供了更好的函数式方案,例如Java:

  1. option.ifPresent(x -> System.out.println(x));
  2. // 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的容器。例如,我们可以让有值的时候放大两倍,没值的时候保持为空:

  1. option.map(x -> 2 * x);

我们也可以在option对象上做一个操作,让它返回一个option对象,然后再压扁它。(:也就把Option<Option<T>>压扁成Option<T>

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

我们可以为option提供一个默认值,如果它不存在的话:

  1. 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

评分规则如下:

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

译注(3): NULL-计算机科学上最糟糕的失误的更多相关文章

  1. 30号快手笔试(三道ac两道半)————-历史上最大的网络失误orz

    case  50 ,20,100 做题以来第一次重大失误:最后两分钟发现手机关机了,然后充电开机orz 页面是js代码, 钟表是一直会走的, 手机没电了, 电脑连接的手机的热点: 只顾在调试,先过了第 ...

  2. feilong's blog | 目录

    每次把新博客的链接分享到技术群里,我常常会附带一句:蚂蚁搬家.事实上也确实如此,坚持1篇1篇的把自己做过.思考过.阅读过.使用过的技术和教育相关的知识.方法.随笔.索引记录下来,并持续去改进它们,希望 ...

  3. java图片上传,通过MultipartFile方式,如果后台获取null检查是否缺少步骤

    本方法基于springMvc 1.首先需要在webap下创建images 2.在springmvc.xml上引入 <bean id="multipartResolver" c ...

  4. spring mvc文件上传(单个文件上传|多个文件上传)

    单个文件上传spring mvc 实现文件上传需要引入两个必须的jar包    1.所需jar包:                commons-fileupload-1.3.1.jar       ...

  5. PHP图片上传类

    前言 在php开发中,必不可少要用到文件上传,整理封装了一个图片上传的类也很有必要. 图片上传的流程图 一.控制器调用 public function upload_file() { if (IS_P ...

  6. js null 和 undefined

    undefined是一个特殊类型,null本质上是一个对象 typeof undefined//"undefined"typeof null//"object" ...

  7. 可拖拽和带预览图的jQuery文件上传插件ssi-uploader

    插件描述:ssi-uploader是一款带预览图并且可以拖拽文件的jQuery ajax文件上传插件.该文件上传插件支持AJAX,支持多文件上传,可控制上的文件格式和文件大小,提供各种回调函数,使用非 ...

  8. 转载:oracle null处理

    (1)NULL的基础概念,NULL的操作的基本特点NULL是数据库中特有的数据类型,当一条记录的某个列为NULL,则表示这个列的值是未知的.是不确定的.既然是未知的,就有无数种的可能性.因此,NULL ...

  9. Android填坑系列:Android JSONObject 中对key-value为null的特殊处理

    在与服务端通过JSON格式进行交互过程中,不同版本的JSON库在对于key-value为null情况上的处理不同. Android自带的org.json对key-value都要求不能为null,对于必 ...

随机推荐

  1. WelcomeActivity【欢迎界面】

    版权声明:本文为HaiyuKing原创文章,转载请注明出处! 前言 简单记录下欢迎界面的布局以及倒计时和跳过功能. 效果图 代码分析 1.修改APP整个主题为无标题栏样式:styles.xml文件 & ...

  2. 高效并发JUC锁-砖石

    JUC包的锁(可重入锁和读写锁) Lock是JAVA5增加的内容,在JUC(java.util.concurrent.locks)包下面,作者是并发大师Doug Lea.JUC包提供了很多封装的锁,包 ...

  3. ABP框架连接Mysql数据库

    开始想用Abp框架来搭建公司的新项目,虽然一切还没有定数,但是兵马未动,粮草先行,我先尝试一下整个过程,才能够更好的去争取机会. 此次技术选型:Abp(Asp.Net core mvc)+mysql( ...

  4. springboot~hazelcast缓存中间件

    缓存来了 在dotnet平台有自己的缓存框架,在java springboot里当然了集成了很多,而且缓存的中间件也可以进行多种选择,向redis, hazelcast都是分布式的缓存中间件,今天主要 ...

  5. vue-cli项目使用mock数据的方法(借助express)

    前言 现如今前后端分离开发越来越普遍,前端人员写好页面后可以自己模拟一些数据进行代码测试,这样就不必等后端接口,提高了我们开发效率.今天就来分析下前端常用的mock数据的方式是如何实现的. 主体 项目 ...

  6. C++ 编译期封装-Pimpl技术

    Pimpl技术——编译期封装 Pimpl 意思为“具体实现的指针”(Pointer to Implementation), 它通过一个私有的成员指针,将指针所指向的类的内部实现数据进行隐藏, 是隐藏实 ...

  7. Data Lake Analytics + OSS数据文件格式处理大全

    0. 前言 Data Lake Analytics是Serverless化的云上交互式查询分析服务.用户可以使用标准的SQL语句,对存储在OSS.TableStore上的数据无需移动,直接进行查询分析 ...

  8. springcloud情操陶冶-springcloud config server(二)

    承接前文springcloud情操陶冶-springcloud config server(一),本文将在前文的基础上讲解config server的涉外接口 前话 通过前文笔者得知,cloud co ...

  9. python3-随机生成10位包含数字和字母的密码

    方法一: 知识点:random.sample(sequence, k) 从指定序列中随机获取指定长度的片断 import random,string num=string.ascii_letters+ ...

  10. golang实现aes-cbc-256加密解密过程记录

    我为什么吃撑了要实现go的aes-cbc-256加密解密功能? 之前的项目是用php实现的,现在准备用go重构,需要用到这个功能,这么常用的功能上网一搜一大把现成例子,于是基于go现有api分分钟实现 ...