在目前大多数的编程语言中,都存在一个很有意思的特殊的指针(或者引用),它代表指向的对象为“空”,名字一般叫做、 nil
、 None
, Nothing
、 ptr
等。
这个空指针看似简单,但它引发的问题却一点也不少,空指针错误对许多朋友来说都不陌生,它在许多编程语言中都是非常非常常见的。
用Java举例来说,我们有一个String类型的引用, String str =
;。如果它的值为,那么接下来,用它调用成员函数的时候,那么程序就会抛出一个 PointerException
。如果不catch住这个异常呢,整个程序就会crash掉。据说,这一类问题,已经造成了业界无法估量的巨大损失。
源起
在2009年的一个会议中,著名的“快速排序”算法的发明者,Tony Hoare向全世界道歉,忏悔他曾经发明了“空指针”这个玩意。他是这么说的:
原来,在程序语言中加入空指针设计,其实并非是经过深思熟虑的结果,而仅仅是因为它很容易实现而已。
这个设计是如此的影响深远,以至于后来的编程语言都不假思索的继承了这一设计,这个范围几乎包括了目前业界所有的流行的编程语言。
对许多程序员来说,早就已经习惯了空指针的存在,就像日常生活中的空气和水一样。那么,空指针究竟有什么问题,以至于图灵奖的获得者Tony Hoare都表示后悔了呢?
问题详解
空指针最大的问题在于:是一个合法存在的不合理的值。许多语言让所有的指针类型都具有“可空性”(ability)。
比如,在Java中,除了基本类型之外,其他所有类型的引用都是可以赋值为的。许多程序员已经习惯于使用来表示某个特殊的状态。
在某些地方,程序员可能会觉得某个变量从逻辑上可以保证它不会为空,于是就省略掉了空指针检查。
可是,时过境迁之后,因为代码的各种变化,导致这样的前提不再成立的时候,空指针异常就发生了。
代码因此非常脆弱。而有些谨慎的程序员,为未雨绸缪计,会在各个地方都加上保护性的空指针检查,又让代码变得非常臃肿。
那么病根究竟是出在哪里呢?
空指针引发的第一个问题在于,空指针违背了类型系统的初衷。
我们再来回忆一下,什么是“类型”?类型是用于规范程序的各个组件调用关系的一组规定。我们如果有一个类型Thing,它有一个成员函数doSomeThing,那么只要是这个类型的变量,它就一定应该可以调用doSomeThing函数,完成同样的操作,返回同样类型的返回值。
但是,违背了这样的约定。一个正常的指针,和一个指针,哪怕它们是同样的类型,做同样的操作,所得到的结果也不一样。那么,凭什么说,指针是和普通指针是一个类型?
在C#标准文档(ECMA C# launguage specification)中,我们可以找到这样的对 literal的描述:
The type of a -literal is the type (§11.2.7).
总而言之,实际上是在类型系统上打开了一个缺口,引入了一个必须在运行期特殊处理的一个特殊的“值”。
它就像一个全局的无类型的singleton变量一样,可以无处不在,可以随意与任意指针实现自动类型转换。它让编译器的类型检查在此处失去了意义。
空指针引发的第二个问题在于,它鼓励API设计者使用空指针作为标记符号(sentinel value)
所谓“标记符号”指的是一种特殊的值,用于标记特殊的状态。它指的是这样的一种设计模式:当你需要多个类型A、B、C……的时候,
不是去创建多个类型来匹配需求,而是转而使用一个简单的、容易实现的类型T,然后把多个类型映射到一个类型的多个区间的值。
比如说,有些这样的API设计
使用int作为函数的返回值,负数代表错误,非负数代表正常的结果,由使用者去判断这个值的真实含义;
在需要使用enum的场合,使用int类型,然后在每个使用它的地方小心翼翼地检查这个值是否合理;
关于这一类行为,有网友机智地将其称之为”Primitive Obsession”(基本类型偏执)。空指针就是这一设计的典范。
从底层原理上来说,指针本身实际上就是用一个整数来表示的,它当然可以取值为0,也就是空指针。
但是,从语言设计层面,逻辑上来说,我们不该将指针类型与整数类型等同起来,它们所起的作用完全不同,它们能执行的操作完全不同,它们在抽象层面的概念完全不同, 即便它们在机器码层面的表示方式是一模一样的。
空指针让程序设计语言变得更复杂 在C++中,我们考虑以下代码,把一个整数赋值给一个指针,它会产生编译错误
char *myChar = 123; // compile error std::cout << *myChar << std::endl;
但是,我们把整数的值变一下,它又可以编译通过了
char *myChar = 0; std::cout << *myChar << std::endl; // runtime error
在Java中,我们考虑以下代码,它是编译不过的
int x = ; // compile error
但是,我们改个类型,于是就编译通过了
Integer i = ; int x = i; // runtime error
可惜这样更糟糕,它会在运行阶段抛出异常,导致整个逻辑不能继续进行。而且,它发生在隐蔽的地方,我们连函数都没调用。
在javascript中,问题更有意思。如果一个object为空,那么我们说它的值为。
但是,如果object有一个属性,它的返回值是,那么我们该怎么区分这个属性不存在,还是这个属性存在,但是值为?
javascript的设计者于是又添加了一个undefined全局属性来区分这两种情况。
实质上,javascript为了解决的问题,在语言中又加入了另外一种不同形态的。
解决方案
空指针在许多程序设计语言中太常见了,以至于有许多人误以为它就像空气和水一样,是我们不可或缺的一份子。恰恰相反,错!
那么,解决方案是什么呢?那就是,把当成一个“类型”来处理,而不是当成一个特殊的“值”来处理。
编译器和静态检查工具不可能知道一个变量在运行期的“值”,但是可以检查所有变量所属的“类型”,来判断它是否符合了类型系统的各种约定。
如果我们把从一个“值”上升为一个“类型”,那么静态检查就可以发挥其功能了。
在许多的程序设计语言中,实际上早就已经有了这样的一个设计,叫做Option Type。在scala、haskell、Ocaml、F# 等许多语言中已经存在了许多年。
下面我们以Rust为例,介绍一下Option是如何解决掉空指针问题的。在Rust中,Option实际上只是一个标准库中普通的enum:
pub enum Option<T> { /// No value None, /// Some value `T` Some(T) }
Rust中的 enum
实际上是一个sum type, 它要求,在使用的时候,必须“完整匹配”。意思是说,enum中的每一种可能性,都必须处理,不能遗漏。比如,有一个可空的字符串msg,我们想打印出其中包含的信息,可以这么做:
let msg : Option<&str> = Some("howdy"); match msg { Some(m) => println!("{}", m), //如果是Some类型,则m匹配到&str类型,于是它可以调用&str所属的成员函数 None => // 如果是None类型,那么它无法访问msg内部数据 }
我们可以看到,对于一个可空的类型,我们没有办法直接调用该类型的成员函数,必须用 match
语句把其中的内容“拆”出来,然后分情况使用。
而对于普通非空类型呢,Rust不允许赋值为 None
,也不允许不初始化就使用。Rust中,也没有这样的关键字。所以,在Rust语言中,根本就没有空指针错误这样的问题。
实际上,C++/C#等语言也发现了初始设计中的缺点,并且开发了一些补救措施。C++标准库中加入了 std::optional<T>
类型,C#中加入了 System.able<T>
类型。可惜的是,受限于早期版本兼容性的要求,这些设计已经不能作为强制要求使用,因此其作用也就弱化了许多。
Option类型有许多非常方便的成员函数可供使用,如下所示:
fn main { // not_able是String类型,因此它永远不可能为None,它可以放心调用String的成员函数 let not_able : String = String::from("not able"); println!("call member function directly. string lenght is {}", not_able.len); // able1是Option类型,它可以使用unwrap_or函数,该函数可以提取出里面的值,如果为None,则返回参数中提供的默认值 let able1 : Option<String> = Some("hello world".to_owned); get_length1(able1); // able2是Option类型,它可以使用map函数,该函数把一个Option类型通过一个closure映射到另外一个Option类型 let able2 : Option<String> = Option::None; get_length2(able2); } fn get_length1(able : Option<String>) { let len = able.unwrap_or("default value".to_owned).len; println!("fall back to default value. string length is {}", len); } fn get_length2(able : Option<String>) { let len = able.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.
性能分析
Option
类型不仅在表达能力上非常优秀,而且运行开销也非常小。在这里我们可以再次看到“零性能损失的抽象”能力。示例如下:
fn main { println!("size of isize : {}", std::mem::size_of::<isize> ); println!("size of Option<isize> : {}", std::mem::size_of::<Option<isize>> ); println!("size of &isize : {}", std::mem::size_of::<&isize> ); println!("size of Box<isize> : {}", std::mem::size_of::<Box<isize>> ); println!("size of Option<&i64> : {}", std::mem::size_of::<Option<&isize>> ); println!("size of Option<Box<i64>> : {}", std::mem::size_of::<Option<Box<isize>>> ); }
这个示例分析了 Option
类型在执行阶段所占用的内存空间大小,结果为:
size of isize : 8 size of Option<isize> : 16 size of &isize : 8 size of Box<isize> : 8 size of Option<&i64> : 8 size of Option<Box<i64>> : 8
其中,不带 Option
的类型的大小我们早已经分析过。 isize
&isize
Box<isize>
这几个类型占用空间大小都等于该系统一个指针占用空间大小,不足为奇。 Option<isize>
类型实际表示的含义是“可能不存在的整数”,因此它除了需要存储一个 isize
空间的大小之外,还需要一个标记位,来表示该值存在还是不存在的状态。理想状态下, 8 + 1 = 9
个byte的空间就足够了。这里的结果是16,猜测可能是因为内存对齐的原因。
最让人惊奇的是,2个“可空的指针”类型,占用空间竟然和一个指针占用空间相同,并未多浪费一点点的空间来表示“指针是否为空”的状态。
这是因为Rust在这里做了一个小优化:根据Rust的设计,借用指针 &
和所有权指针 Box
从语义上来说,都是不可能为“0”的状态。它们必须被合适地初始化,不能通过其它类型强制转换而来,也不能做算术运算。因此 Option<&isize>
和 Option<Box<isize>>
类型可以利用这个特点,使用“0”值代表当前状态为“空”。这意味着,使用 Option
类型对指针的包装,在编译后的机器码层面,与C/C++的指针完全没有任何区别。
Rust是如何做到这一点的呢?在标准库中,Rust设计了这样的一个 struct
:
/// A wrapper type for raw pointers and integers that will never be /// or 0 that might allow certain optimizations. #[lang = "non_zero"] #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Hash)] pub struct NonZero<T: Zeroable>(T);
它有一个attribute #[lang = "..."]
表明这个结构体是Rust语言的一部分,它是被编译器特殊处理的。凡是被这个结构体包起来的类型,编译器都将其视为“不可能为0”的。
我们再翻一下看看 Box<T>
是什么定义:
#[lang = "owned_box"] #[fundamental] pub struct Box<T: ?Sized>(Unique<T>);
其中, Unique<T>
的定义是:
pub struct Unique<T: ?Sized> { pointer: NonZero<*const T>, _marker: PhantomData<T>, }
其中 PhantomData<T>
是一个零大小的类型 pub struct PhantomData<T:?Sized>;
,它的作用是在 unsafe
编程的时候辅助编译器静态检查的,在运行阶段无性能开销,此处暂时略过。
把以上代码综合起来,可以发现, Option<Box<T>>
的实际内部表示形式是 Option<NonZero<*const T>>
。只要编译器知道 NonZero
类型是不可能为0的值,那它就自然有能力将这个类型的占用空间压缩到与 * const T
类型占用空间一致。大家搞明白这一点后,我们自定义的类型如果也符合同样的条件,也可以利用这个特性,来完成优化。
总结
总结来说,Rust这样的设计有以下几个优点:
再次强调显式比隐式好。如果从逻辑上说,我们需要一个变量确实是可空的,那么就应该显式标明其类型为
Option<T>
否则应该直接声明为T类型。从类型系统的角度来说,这二者有本质区别,切不可混为一谈。
代码更安全。因为类型系统的存在,空指针现在可以被编译器完美检测,从根源上杜绝了这个问题,不可能有漏网之鱼,大幅提高了程序的健壮性。
执行效率更高。不再是到处都可能出现的一个怪物,不再需要程序员到处检查空指针问题。多余的空指针检查是完全没有必要的。
大家也不必担心这样的设计会导致大量的
match
语句,使得程序可读性变差。因为Option再配合上闭包功能,实际上在表达能力和可读性上要更胜一筹。
所以说,空指针的确是一个编程语言设计史上的重大失误,该错误流毒之广,影响之巨,难有其匹。
怪不得Tony老爷子要感叹一句:一失足成千古恨,再回头已百年身!
参考资料:
※内容转载自:Android程序员