Skip to content

8.3 再谈 Option 类型

Rust 中的 Option 类型解决了许多编程语言中的“空指针”问题。

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

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

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.

原来,在程序语言中加入空指针设计,其实并非是经过深思熟虑的结果,而仅仅是因为它很容易实现而已。 这个设计的影响是如此深远,以至于后来许多编程语言都不假思索地继承了这一设计,范围几乎包括了目前业界所有流行的编程语言。

空指针最大的问题在于,它违背了类型系统的初衷。我们再来回忆一下什么是“类型”。 按维基百科的定义,类型是:

Type is a classification identifying one of various types of data,that determines the possible values for that type, the operations that can be done on values of that type,the meaning of the data,and the way values of that type can be stored.

“类型”规定了数据可能的取值范围,规定了在这些值上可能的操作,也规定了这些数据代表的含义,还规定了这些数据的存储方式。

我们如果有一个类型 Thing,它有一个成员函数doSomeThing(),那么只要是这个类型的变量,就一定应该可以调用doSomeThing()函数,完成同样的操作,返回同样类型的返回值。

但是,null 违背了这样的约定。一个正常的指针和一个 null 指针,哪怕它们是同样的类型,做同样的操作,所得到的结果也是不一样的。那么,凭什么说 null 指针和普通指针是一个类型呢?null 实际上是在类型系统上打开了一个缺口,引入一个必须在运行期特殊处理的特殊“值”。它就像一个全局的、无类型的 singleton 变量一样,可以无处不在,可以随意与任意指针实现自动类型转换。它让编译器的类型检查在此处失去了意义。

那么,Rust 里面的解决方案是什么呢? 那就是,利用类型系统(ADT)将空指针和非空指针区别开来,分别赋予它们不同的操作权限,禁止针对空指针执行解引用操作。编译器和静态检查工具不可能知道一个变量在运行期的“值”,但是可以检查所有变量所属的“类型”,来判断它是否符合了类型系统的各种约定。如果我们把 null 从一个“值”上升为一个“类型”,那么静态检查就可以发挥其功能了。实际上早就已经有了这样的设计,叫作 Option Type,并在scala、haskell、Ocaml、F#等许多程序设计语言中存在了许多年。

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


rust
pub enum Option<T> {
    /// No value
    None,
    /// Some value `T`
    Some(T)
}

Rust 中的 enum 要求,在使用的时候必须“完整匹配”。意思是说,enum 中的每一种可能性都必须处理,不能遗漏。 对于一个可空的Option<T>类型,我们没有办法直接调用 T 类型的成员函数,要么用模式匹配把其中的类型 T 的内容“拆”出来使用,要么调用 Option 类型的成员方法。

而对于普通非空类型,Rust 不允许赋值为None,也不允许不初始化就使用。 在 Rust 中,也没有null这样的关键字。另外,Option 类型参数可以是常见的指针类型,如 Box 和&等,也可以是非指针类型,它的表达能力其实已经超过了“可空的指针”这一种类型。

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

Option 类型有许多非常方便的成员函数可供使用,另外我们还可以利用if-letwhile-let等语法糖。许多情况下,没必要每次都动用match语句。

比如,map方法可以把一个Option<U>类型转为另外一个Option<V>类型:


rust
let maybe_some_string = Some(String::from("Hello, World!"));
let maybe_some_len = maybe_some_string.map(|s| s.len());
assert_eq!(maybe_some_len, Some(13));

再比如,and_then方法可以把一系列操作串联起来:

rust
fn sq(x: u32) -> Option<u32> { Some(x * x) }
fn nope(_: u32) -> Option<u32> { None }

assert_eq!(Some(2).and_then(sq).and_then(sq), Some(16));

unwrap方法则是从Option<T>中提取出 T。如果当前状态是None,那么这个函数会执行失败导致 panic。正因为如此,除非是写不重要的小工具之类的,否则这个方法是不推荐使用的。 哪怕你很确定此时 Option 的状态一定是 Some,最好也用expect方法代替,至少它在发生 panic 的时候还会打印出一个字符串,方便我们查找原因。

这个类型还有许多有用的方法,各位读者应该去查阅一下标准库的文档,对它的成员方法烂熟于心。

Option 类型不仅在表达能力上非常优秀,而且运行开销也非常小。在这里我们还可以再次看到“零性能损失的抽象”能力。示例如下:

rust
use std::mem::size_of;

fn main() {
    println!("size of isize            : {}",
        size_of::<isize>() );
    println!("size of Option<isize>    : {}",
        size_of::<Option<isize>>() );

    println!("size of &isize           : {}",
        size_of::<&isize>() );
    println!("size of Box<isize>       : {}",
        size_of::<Box<isize>>() );

    println!("size of Option<&isize>     : {}",
        size_of::<Option<&isize>>() );
    println!("size of Option<Box<isize>> : {}",
        size_of::<Option<Box<isize>>>() );

    println!("size of *const isize     : {}",
        size_of::<* const isize>() );
    println!("size of Option<*const isize> : {}",
        size_of::<Option<*const isize>>() );
}

这个示例分析了 Option 类型在执行阶段所占用的内存空间大小,结果为:


rust
size of isize                      : 8
size of Option<isize>              : 16
size of &isize                     : 8
size of Box<isize>                 : 8
size of Option<&isize>             : 8
size of Option<Box<isize>>         : 8
size of *const isize               : 8
size of Option<*const isize>       : 16

其中,不带 Option 的类型大小完全在意料之中。isize &isize Box<isize>这几个类型占用空间的大小都等于该系统平台上一个指针占用的空间大小,不足为奇。Option<isize>类型实际表示的含义是“可能为空的整数”,因此它除了需要存储一个 isize 空间的大小之外,还需要一个标记位(至少 1bit)来表示该值存在还是不存在的状态。这里的结果是 16,猜测可能是因为内存对齐的原因。

最让人惊奇的是,那两个“可空的指针”类型占用的空间竟然和一个指针占用的空间相同,并未多浪费一点点的空间来表示“指针是否为空”的状态。这是因为 Rust 在这里做了一个小优化:根据 Rust 的设计,借用指针&和所有权指针 Box 从语义上来说,都是不可能为0的状态。有些数值是不可能成为这几个指针指向的地址的,它们的取值范围实际上小于 isize 类型的取值范围。因此Option<&isize>Option<Box<isize>>类型可以利用这个特点,使用0值代表当前状态为“空”。这意味着,在编译后的机器码层面,使用 Option 类型对指针的包装,与 C/C++ 的指针完全没有区别。

Rust 是如何做到这一点的呢?在目前的版本中,Rust 设计了NonZero这样一个 struct。(这个设计目前还处于不稳定状态,将来很可能会有变化,但是基本思路应该是不变的。)

NonZero 已经废弃了?


rust
/// A wrapper type for raw pointers and integers that will never be
/// NULL 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>的定义:


rust
#[lang = "owned_box"]
#[fundamental]
pub struct Box<T: ?Sized>(Unique<T>);

其中,Unique<T>的定义是:


rust
pub struct Unique<T: ?Sized> {
    pointer: NonZero<*const T>,
    _marker: PhantomData<T>,
}

其中PhantomData<T>是一个零大小的类型pub struct Phantom-Data<T: ?Sized>;,它的作用是在 unsafe 编程的时候辅助编译器静态检查,在运行阶段无性能开销,此处暂时略过。

把以上代码综合起来可以发现,Option<Box<T>>的实际内部表示形式是Option<NonZero<*const T>>。因此编译器就有能力将这个类型的占用空间压缩到与*const T类型占用的空间一致。

而对于*const T类型,它本身是有可能取值为0的,因此这种类型无法执行优化,Option<*const T>的大小就变成了两个指针大小。大家搞明白这一点后,我们自定义的类型如果也符合同样的条件,也可以利用这个特性来完成优化。

总结起来,对于 Rust 中的 Option 类型,读者需要注意以下几点。

1)如果从逻辑上说,我们需要一个变量确实是可空的,那么就应该显式标明其类型为Option<T>,否则应该直接声明为 T 类型。从类型系统的角度来说,这二者有本质区别,切不可混为一谈。

2)不要轻易使用unwrap方法。这个方法可能会导致程序发生 panic。对于小工具来说无所谓,在正式项目中,最好是使用 lint 工具强制禁止调用这个方法。

3)相对于裸指针,使用 Option 包装的指针类型的执行效率不会降低,这是“零开销抽象”。

4)不必担心这样的设计会导致大量的match语句,使得程序可读性变差。因为Option<T>类型有许多方便的成员函数,再配合上闭包功能,实际上在表达能力和可读性上要更胜一筹。

unwrap 方法

在 Rust 中,unwrap 方法是一个非常方便的使用方法,它可以将一个 Option<T>Result<T, E> 的值转换为其内部的值,但如果它们不被设置,就会导致程序 panic。因此,在使用 unwrap 时需要注意它的使用场景。

对于 Option<T> 类型,unwrap 方法用来从 Some(T) 的 Option 中提取 T 值。当调用 unwrap 方法时,如果 Option 是 Some(T),则返回 T;否则,代码会 panic。 例如:

rust
fn main() {
    let some_number = Some(5);
    println!("The number is: {}", some_number.unwrap());
}

输出结果为:The number is: 5

对于 Result<T, E> 类型,unwrap 方法用来从Ok(T)的 Result 中提取 T 值,或者在Err(E)的情况下,将错误unwrap到 panic 内部并终止程序。例如:

rust
fn main() {
    let result: Result<u32, &str> = Ok(42);
    println!("The number is: {}", result.unwrap());

    let error: Result<u32, &str> = Err("Something went wrong");
    println!("The number is: {}", error.unwrap()); // 这里会 panic
}

第一个 unwrap 方法会返回Ok(T)的数值,即:The number is: 42。而第二个 unwrap 方法在 error 为Err(E)的情况下会 panic,因为它试图 unwrap 一个错误类型的值。

虽然unwrap方法可以简化代码,但它也有可能会导致一些严重的问题,特别是在面对不可靠的输入或外部库的返回值时。因此,Rust 官方文档建议开发者要谨慎使用unwrap

作为代替使用unwrap方法,Rust 社区提倡使用matchif let 等更为安全的方式来处理 Option<T>Result<T, E> 类型的值,以确保代码的健壮性和可维护性。

参考

Shortcuts for Panic on Error: unwrap and expect

Released under the MIT License