蓦然回首万事空 ————空指针漫谈

在目前大多数的编程语言中,都存在一个很有意思的特殊的指针(或者引用),它代表指向的对象为“空”,名字一般叫做nullnilNone, Nothingnullptr等。这个空指针看似简单,但它引发的问题却一点也不少,空指针错误对许多朋友来说都不陌生,它在许多编程语言中都是非常非常常见的。用Java举例来说,我们有一个String类型的引用,String str = null;。如果它的值为null,那么接下来,用它调用成员函数的时候,那么程序就会抛出一个NullPointerException。如果不catch住这个异常呢,整个程序就会crash掉。据说,这一类问题,已经造成了业界无法估量的巨大损失。

源起

在2009年的一个会议中,著名的“快速排序”算法的发明者,Tony Hoare向全世界道歉,忏悔他曾经发明了“空指针”这个玩意。他是这么说的:

I call it my billion-dollar mistake. It was the invention of the null reference in 1965.
At that time, I was designing the first comprehensive type system for references in an
object oriented language (ALGOL W). 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都表示后悔了呢?

问题详解

空指针最大的问题在于:null是一个合法存在的不合理的值。许多语言让所有的指针类型都具有“可空性”(nullability)。
比如,在Java中,除了基本类型之外,其他所有类型的引用都是可以赋值为null的。许多程序员已经习惯于使用null来表示某个特殊的状态。
在某些地方,程序员可能会觉得某个变量从逻辑上可以保证它不会为空,于是就省略掉了空指针检查。
可是,时过境迁之后,因为代码的各种变化,导致这样的前提不再成立的时候,空指针异常就发生了。
代码因此非常脆弱。而有些谨慎的程序员,为未雨绸缪计,会在各个地方都加上保护性的空指针检查,又让代码变得非常臃肿。

那么病根究竟是出在哪里呢?

  1. 空指针引发的第一个问题在于,空指针违背了类型系统的初衷。

    我们再来回忆一下,什么是“类型”?类型是用于规范程序的各个组件调用关系的一组规定。我们如果有一个类型Thing,它有一个成员函数doSomeThing(),那么只要是这个类型的变量,它就一定应该可以调用doSomeThing()函数,完成同样的操作,返回同样类型的返回值。

    但是,null违背了这样的约定。一个正常的指针,和一个null指针,哪怕它们是同样的类型,做同样的操作,所得到的结果也不一样。那么,凭什么说,null指针是和普通指针是一个类型?

    在C#标准文档(ECMA C# launguage specification)中,我们可以找到这样的对null literal的描述:

    The type of a null-literal is the null type (§11.2.7).

    总而言之,null实际上是在类型系统上打开了一个缺口,引入了一个必须在运行期特殊处理的一个特殊的“值”。
    它就像一个全局的无类型的singleton变量一样,可以无处不在,可以随意与任意指针实现自动类型转换。它让编译器的类型检查在此处失去了意义。

  2. 空指针引发的第二个问题在于,它鼓励API设计者使用空指针作为标记符号(sentinel value)

    所谓“标记符号”指的是一种特殊的值,用于标记特殊的状态。它指的是这样的一种设计模式:当你需要多个类型A、B、C……的时候,
    不是去创建多个类型来匹配需求,而是转而使用一个简单的、容易实现的类型T,然后把多个类型映射到一个类型的多个区间的值。

    比如说,有些这样的API设计

    • 使用int作为函数的返回值,负数代表错误,非负数代表正常的结果,由使用者去判断这个值的真实含义;
    • 在需要使用enum的场合,使用int类型,然后在每个使用它的地方小心翼翼地检查这个值是否合理;

    关于这一类行为,有网友机智地将其称之为”Primitive Obsession”(基本类型偏执)。空指针就是这一设计的典范。
    从底层原理上来说,指针本身实际上就是用一个整数来表示的,它当然可以取值为0,也就是空指针。
    但是,从语言设计层面,逻辑上来说,我们不该将指针类型与整数类型等同起来,它们所起的作用完全不同,它们能执行的操作完全不同,它们在抽象层面的概念完全不同, 即便它们在机器码层面的表示方式是一模一样的。

  3. 空指针让程序设计语言变得更复杂

    在C++中,我们考虑以下代码,把一个整数赋值给一个指针,它会产生编译错误

1
2
char *myChar = 123;                // compile error
std::cout << *myChar << std::endl;
但是,我们把整数的值变一下,它又可以编译通过了
1
2
char *myChar = 0;
std::cout << *myChar << std::endl; // runtime error
在Java中,我们考虑以下代码,它是编译不过的
1
int x = null;       // compile error
但是,我们改个类型,于是就编译通过了
1
2
Integer i = null;
int x = i; // runtime error
可惜这样更糟糕,它会在运行阶段抛出异常,导致整个逻辑不能继续进行。而且,它发生在隐蔽的地方,我们连函数都没调用。

在javascript中,问题更有意思。如果一个object为空,那么我们说它的值为null。

但是,如果object有一个属性,它的返回值是null,那么我们该怎么区分这个属性不存在,还是这个属性存在,但是值为null?
javascript的设计者于是又添加了一个undefined全局属性来区分这两种情况。
:(。实质上,javascript为了解决null的问题,在语言中又加入了另外一种不同形态的null。

解决方案

空指针在许多程序设计语言中太常见了,以至于有许多人误以为它就像空气和水一样,是我们不可或缺的一份子。恰恰相反,错!

那么,解决方案是什么呢?那就是,把null当成一个“类型”来处理,而不是当成一个特殊的“值”来处理。
编译器和静态检查工具不可能知道一个变量在运行期的“值”,但是可以检查所有变量所属的“类型”,来判断它是否符合了类型系统的各种约定。
如果我们把null从一个“值”上升为一个“类型”,那么静态检查就可以发挥其功能了。

在许多的程序设计语言中,实际上早就已经有了这样的一个设计,叫做Option Type。在scala、haskell、Ocaml、F# 等许多语言中已经存在了许多年。

下面我们以Rust为例,介绍一下Option是如何解决掉空指针问题的。在Rust中,Option实际上只是一个标准库中普通的enum:

1
pub enum Option {
    /// No value
    None,
    /// Some value `T`
    Some(T)
}

Rust中的enum实际上是一个sum type, 它要求,在使用的时候,必须“完整匹配”。意思是说,enum中的每一种可能性,都必须处理,不能遗漏。比如,有一个可空的字符串msg,我们想打印出其中包含的信息,可以这么做:

1
let msg : Option<&str> = Some("howdy");
match msg {
    Some(m) => println!("{}", m), // 如果是Some类型,则m匹配到&str类型,于是它可以调用&str所属的成员函数
    None => () // 如果是None类型,那么它无法访问msg内部数据
}

我们可以看到,对于一个可空的类型,我们没有办法直接调用该类型的成员函数,必须用match语句把其中的内容“拆”出来,然后分情况使用。

而对于普通非空类型呢,Rust不允许赋值为None,也不允许不初始化就使用。Rust中,也没有null这样的关键字。所以,在Rust语言中,根本就没有空指针错误这样的问题。

实际上,C++/C#等语言也发现了初始设计中的缺点,并且开发了一些补救措施。C++标准库中加入了std::optional类型,C#中加入了System.Nullable类型。可惜的是,受限于早期版本兼容性的要求,这些设计已经不能作为强制要求使用,因此其作用也就弱化了许多。

Option类型有许多非常方便的成员函数可供使用,如下所示:

1
fn main() {
    // not_nullable是String类型,因此它永远不可能为None,它可以放心调用String的成员函数
    let not_nullable : String = String::from("not nullable");
    println!("call member function directly. string lenght is {}", not_nullable.len());

    // nullable1是Option类型,它可以使用unwrap_or函数,该函数可以提取出里面的值,如果为None,则返回参数中提供的默认值
    let nullable1 : Option<String> = Some("hello world".to_owned());
    get_length1(nullable1);

    // nullable2是Option类型,它可以使用map函数,该函数把一个Option类型通过一个closure映射到另外一个Option类型
    let nullable2 : Option<String> = Option::None;
    get_length2(nullable2);
}

fn get_length1(nullable : Option<String>) {
    let len = nullable.unwrap_or("default value".to_owned()).len();
    println!("fall back to default value. string length is {}", len);
}

fn get_length2(nullable : Option<String>) {
    let len = nullable.map(|s| s.len());
    println!("map an Option to another Option. string length is {:?}.", len);
}
// 编译执行,输出结果为:
call member function directly. string lenght is 12
fall back to default value. string length is 11
map an Option to another Option. string length is None.

总结来说,Rust这样的设计有以下几个优点:

  1. 再次强调显式比隐式好。如果从逻辑上说,我们需要一个变量确实是可空的,那么就应该显式标明其类型为Option 否则应该直接声明为T类型。
    从类型系统的角度来说,这二者有本质区别,切不可混为一谈。
  2. 代码更安全。因为类型系统的存在,空指针现在可以被编译器完美检测,从根源上杜绝了这个问题,不可能有漏网之鱼,大幅提高了程序的健壮性。
  3. 执行效率更高。Null不再是到处都可能出现的一个怪物,不再需要程序员到处检查空指针问题。多余的空指针检查是完全没有必要的。
  4. 大家也不必担心这样的设计会导致大量的match语句,使得程序可读性变差。因为Option类型有许多方便的成员函数,
    再配合上闭包功能,实际上在表达能力和可读性上要更胜一筹。

所以说,空指针的确是一个编程语言设计史上的重大失误,该错误流毒之广,影响之巨,难有其匹。
怪不得Tony老爷子要感叹一句:一失足成千古恨,再回头已百年身!

参考资料:
1. http://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare
2. https://doc.rust-lang.org/stable/std/option/
3. http://ericlippert.com/2013/07/25/what-is-the-type-of-the-null-literal/

img

留言

本站总访问量

留言