您的位置 首页 java

暮然回首万事空–空指针漫谈

暮然回首万事空--空指针漫谈

在目前大多数的编程语言中,都存在一个很有意思的特殊的指针(或者引用),它代表指向的对象为“空”,名字一般叫做、 nil None , Nothing ptr 等。

这个空指针看似简单,但它引发的问题却一点也不少,空指针错误对许多朋友来说都不陌生,它在许多编程语言中都是非常非常常见的。

用Java举例来说,我们有一个String类型的引用, String str = ;。如果它的值为,那么接下来,用它调用成员函数的时候,那么程序就会抛出一个 PointerException 。如果不catch住这个异常呢,整个程序就会crash掉。据说,这一类问题,已经造成了业界无法估量的巨大损失。

源起

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

原来,在程序语言中加入空指针设计,其实并非是经过深思熟虑的结果,而仅仅是因为它很容易实现而已。

这个设计是如此的影响深远,以至于后来的编程语言都不假思索的继承了这一设计,这个范围几乎包括了目前业界所有的流行的编程语言。

对许多程序员来说,早就已经习惯了空指针的存在,就像日常生活中的空气和水一样。那么,空指针究竟有什么问题,以至于图灵奖的获得者Tony Hoare都表示后悔了呢?

问题详解

空指针最大的问题在于:是一个合法存在的不合理的值。许多语言让所有的指针类型都具有“可空性”(ability)。

比如,在Java中,除了基本类型之外,其他所有类型的引用都是可以赋值为的。许多程序员已经习惯于使用来表示某个特殊的状态。

在某些地方,程序员可能会觉得某个变量从逻辑上可以保证它不会为空,于是就省略掉了空指针检查。

可是,时过境迁之后,因为代码的各种变化,导致这样的前提不再成立的时候,空指针异常就发生了。

代码因此非常脆弱。而有些谨慎的程序员,为未雨绸缪计,会在各个地方都加上保护性的空指针检查,又让代码变得非常臃肿。

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

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

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

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

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

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

    总而言之,实际上是在类型系统上打开了一个缺口,引入了一个必须在运行期特殊处理的一个特殊的“值”。

    它就像一个全局的无类型的singleton变量一样,可以无处不在,可以随意与任意指针实现自动类型转换。它让编译器的类型检查在此处失去了意义。

  2. 空指针引发的第二个问题在于,它鼓励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这样的设计有以下几个优点:

  1. 再次强调显式比隐式好。如果从逻辑上说,我们需要一个变量确实是可空的,那么就应该显式标明其类型为 Option<T> 否则应该直接声明为T类型。

    从类型系统的角度来说,这二者有本质区别,切不可混为一谈。

  2. 代码更安全。因为类型系统的存在,空指针现在可以被编译器完美检测,从根源上杜绝了这个问题,不可能有漏网之鱼,大幅提高了程序的健壮性。

  3. 执行效率更高。不再是到处都可能出现的一个怪物,不再需要程序员到处检查空指针问题。多余的空指针检查是完全没有必要的。

  4. 大家也不必担心这样的设计会导致大量的 match 语句,使得程序可读性变差。因为Option

    再配合上闭包功能,实际上在表达能力和可读性上要更胜一筹。

所以说,空指针的确是一个编程语言设计史上的重大失误,该错误流毒之广,影响之巨,难有其匹。

怪不得Tony老爷子要感叹一句:一失足成千古恨,再回头已百年身!

参考资料:

※内容转载自:Android程序员

文章来源:智云一二三科技

文章标题:暮然回首万事空–空指针漫谈

文章地址:https://www.zhihuclub.com/191063.shtml

关于作者: 智云科技

热门文章

网站地图