跳转到内容

6.3 消灭空指针:Option<T> 与空位优化(Niche Optimization)

1965 年,图灵奖得主 Tony Hoare 在设计 ALGOL W 语言时引入了空引用(null reference)。四十多年后,他在公开演讲中将其称为自己犯下的「十亿美元的错误(The Billion-Dollar Mistake)」。

无论你写过 Java、C#、C/C++、Go 还是 Python,一定都对运行时的空指针异常(NullPointerException、nil 指针解引用或 Segmentation fault)记忆犹新。

为什么在主流语言里防不胜防的 null,到了 Rust 里却连 null 这个关键字都不复存在?学完前两节的和类型(enum)与模式匹配,我们现在可以直接从类型系统的第一性原理看穿这个难题。


为什么 null 是类型系统的漏洞?

Section titled “为什么 null 是类型系统的漏洞?”

设想你要写一个按用户名查询年龄或账号信息的函数 find_user(name)。在真实业务中,这个查询结果存在两种互斥的合法可能性:

  1. 查到了:返回一个有效的 User;
  2. 没查到:返回「无结果(缺失)」。

按照我们在 6.1 节建立的模型,既然存在「有值 或 缺失」两种互斥可能性,这在逻辑上明明是一个和类型(Sum Type)。

但在 Java、C++ 或 Go(指针)等语言中,语言设计者为了省事,没有用独立的和类型来表达这种「或关系」,而是把 null(空地址)偷偷塞进了每一个引用类型里:

User u = findUser("Ferris"); // 签名写着返回 User,实际返回的是 User 或 null
System.out.println(u.name); // 编译畅通无阻,运行时若 u 为 null 则直接崩溃

用第 3 章和第 6 章的视角审视这两行代码,就能看清 null 造成灾难的根本原因:

  1. 类型签名在说谎(把和类型伪装成了单一类型):函数签名明明写着返回 User,但在运行时它却可能是 null。这意味着在 Java 里,每一个写着 User 的类型,实际上都是隐式的 User | null 和类型。
  2. 编译器失去了区分「必定有值」与「可能缺失」的能力:正因为每一个 User 引用都可能为 null,编译器在看到 u.name 时,无法在编译期强制要求你先处理 null 的可能性(即无法执行 6.2 节的穷尽性检查),只能放任代码通过编译,等线上真正遇到空值时炸出 NullPointerException。

Rust 的破局:用 Option<T> 把「可能缺失」显式编码进类型

Section titled “Rust 的破局:用 Option<T> 把「可能缺失」显式编码进类型”

为了从根源上终结空指针,Rust 做出了两项首尾相扣的决定:

  1. 第一项决定:普通类型 T 保证 100% 有值。在 Rust 中,没有任何内置的 null。只要一个变量、字段或函数参数的类型写着 u32、String、User 或 &User,编译器就向你保证它里面一定装着有效的值。你随时可以直接读取字段或调用方法,永远不需要写防御性的 if (u != null)。
  2. 第二项决定:「可能缺失」必须显式套上 Option<T> 类型构造器。当某个值在业务逻辑上确实可能不存在时,Rust 不需要发明任何新语法,而是直接在标准库里用你刚刚学过的普通 enum 定义了一个泛型和类型——Option<T>:
enum Option<T> {
None, // 可能性 1:值缺失(类单元变体,不携带任何数据)
Some(T), // 可能性 2:值存在(类元组变体,内部包裹着一个 T 类型的值)
}
在 Rust Playground 中运行

因为 Option<T> 以及它的两个变体构造器 Some 和 None 在日常代码中出镜率极高,Rust 标准库的预导入模块(Prelude)已经将它们自动引入了每一个源文件的作用域。你在代码里可以直接写 Some(7) 和 None,无需写成冗长的 Option::Some(7)。

T 与 Option<T> 是两个截然不同的编译期类型实体

Section titled “T 与 Option<T> 是两个截然不同的编译期类型实体”

回顾第 3 章 3.2 节的生成公式:裸 Option 是一个接收类型参数 T 的类型构造器。当你把 u32 填进去得到 Option<u32> 时,它在编译器眼里是一个与 u32 毫无关系的独立编译期类型实体。

运行下面这段代码,看看当你拿到一个 Option<u32> 却试图直接把它当成 u32 做加法时会发生什么:

源文件:option.rs
//! `Option<u32>` is either `Some(age)` or `None`. There is no age without the variant.
//! Uncomment the addition to get error[E0369]: cannot add `{integer}` to `Option<u32>`.
fn find_age(name: &str) -> Option<u32> {
if name == "Ferris" { Some(7) } else { None }
}
fn main() {
let age = find_age("Ferris");
// let next = age + 1;
match age {
Some(a) => println!("next year: {}", a + 1),
None => println!("unknown"),
}
}
在 Rust Playground 中运行

运行输出:

运行输出
next year: 8

看第 9~10 行:find_age("Ferris") 返回的 age 类型是 Option<u32>。如果你取消第 10 行 // let next = age + 1; 的注释,试图不经检查直接把 age 当作整数加 1,编译器会当场报错 error[E0369]:

编译器输出(取消 option.rs 第 10 行注释后)
error[E0369]: cannot add `{integer}` to `Option<u32>`
--> src/bin/option.rs:10:20
|
10 | let next = age + 1;
| --- ^ - {integer}
| |
| Option<u32>

在 Java 里会留到运行时才崩溃的「未判空直接使用」,在 Rust 里连类型检查这一关都过不去——因为 Option<u32> 压根就不是 u32,它身上没有加号 +;如果是 Option<User>,它身上也没有 .name 字段。

要想拿到包裹在 Some(a) 里面的 u32,唯一的合法途径就是使用上一节学过的模式匹配(如第 11~14 行的 match age、或者 if let Some(a) = age、或者 let Some(a) = age else { return; })。而一旦你动用模式匹配,编译器的穷尽性检查就会立刻逼你交代清楚:「万一它是 None,程序该怎么办?」

至此,空值事故被彻底锁死在了编译期:

对比维度 传统语言中的引用 User Rust 中的 User(或 &User) Rust 中的 Option<User>
语义承诺 含混不清(可能有值,也可能是 null) 100% 保证有有效值 显式声明可能为空(Some 或 None)
能否直接访问 .name? 编译期允许(运行时可能炸出空指针异常) 允许(编译期与运行时均绝对安全) 禁止(未模式匹配解包前编译直接报错)
判空心智负担 靠程序员记忆防御,到处写 if (u != null) 零负担(无需任何判空) 仅在真正可空的边界处由编译器强制解包

性能揭秘:空位优化(Niche Optimization)——为什么 Option<&T> 依然只占 8 字节?

Section titled “性能揭秘:空位优化(Niche Optimization)——为什么 Option<&T> 依然只占 8 字节?”

理解了 Option<T> 的安全性之后,有 C/C++ 或高性能系统开发经验的读者马上会提出一个物理内存问题:

「在 C 语言里,指针本身占 8 字节,查不到数据时直接返回全 0 地址(NULL),1 个额外字节都不用多花。而在 Rust 里,Option<T> 是一个 enum。按照 6.1 节讲的 Tag + 共享载荷区 内存布局,&i32 占 8 字节,那 Option<&i32> 岂不是要加上 1 字节的判别式标签(Tag),再为了 8 字节对齐填充 7 字节,变成 16 字节?难道 Rust 为了编译期安全,要付出指针体积翻倍的代价吗?」

我们直接写一段代码,用 std::mem::size_of 测量一下 u32、&i32 和 String 在套上 Option 前后的真实字节大小:

源文件:option-ref-size.rs
//! Compare `size_of` before and after wrapping in `Option<T>`.
//! `u32` has no invalid bit pattern, so `Option<u32>` adds a tag and pads to 8 bytes.
//! `&i32` and `String` contain non-null pointers: the zero address (niche) represents `None` for free!
use std::mem::size_of;
fn main() {
println!(
"u32: {}B -> Option<u32>: {}B",
size_of::<u32>(),
size_of::<Option<u32>>()
);
println!(
"&i32: {}B -> Option<&i32>: {}B",
size_of::<&i32>(),
size_of::<Option<&i32>>()
);
println!(
"String: {}B -> Option<String>: {}B",
size_of::<String>(),
size_of::<Option<String>>()
);
}
在 Rust Playground 中运行

运行输出:

运行输出
u32: 4B -> Option<u32>: 8B
&i32: 8B -> Option<&i32>: 8B
String: 24B -> Option<String>: 24B

看最后两行实测结果:

  • u32 套上 Option 后,确实从 4B 变成了 8B(4 字节整数 + 1 字节 Tag + 3 字节对齐填充);
  • 但 &i32 套上 Option 后,分毫无增,依然是 8B;
  • 甚至连 24 字节的 String 套上 Option<String> 后,也依然是 24B,连 1 个字节的额外 Tag 都没有加。

为什么同样是枚举,Option<u32> 必须老老实实加 Tag,而 Option<&i32> 和 Option<String> 却能做到零额外空间开销?

把「编译期禁令」变成「运行时的免费座位」

Section titled “把「编译期禁令」变成「运行时的免费座位」”

秘密就藏在第 1 章 1.4 节我们为引用立下的第一条铁律里:在 Rust 中,合法的引用 &T 必须永远指向有效的内存地址,绝对不可能是全 0 地址(0x0000000000000000)。

站在编译器的角度核对两者的物理表示:

  1. 为什么 u32 省不掉 Tag? 因为 4 字节(32 个 bit)共有 (2^32) 种可能的 bit 组合,而从 0 到 4,294,967,295 的每一种 bit 组合都是一个合法的 u32 整数。如果在 4 字节内部随便挑一个数字代表 None,就会和那个合法的整数冲突。所以编译器别无选择,只能在 4 字节外面额外加 1 个字节来存 Tag。
  2. 为什么 &i32 和 String 能省掉 Tag? 因为 &i32 虽然占 8 字节((2^64) 种可能的 bit 组合),但由于 Rust 规定「引用绝不为空」,全 0(0x0000...)这个 bit 模式对于 &i32 来说是一个永远不可能出现的非法值。 在编译器眼里,这个永远闲置的全 0 bit 模式就是一个现成的「空位(Niche)」。当编译器合成 Option<&i32> 的内存布局时,它根本不需要在外面另加 Tag 字节,而是直接规定:
    • 如果这 8 个字节里存的是非零地址 → 代表 Some(&i32);
    • 如果这 8 个字节里存的是全 0(占用了那个空位) → 代表 None。

同样的道理,String 和 Vec<T> 的 24 字节控制头 (ptr, len, cap) 里,第一个字段 ptr 也是一个非空堆指针(内部类型为 NonNull<u8>)。编译器顺着结构体字段往里找,发现 String 的前 8 字节里同样躺着全 0 这个空位,于是直接让 Option<String> 的 None 坐进了这个空位里,使 Option<String> 保持了原封不动的 24 字节。

这项由编译器自动完成的底层布局机制,就叫空位优化(Niche Optimization,又称空指针优化)。

回味一下这个设计闭环:

  • 在运行时的物理内存和机器指令上,Rust 的 Option<&T> 与 C 语言可能为 NULL 的裸指针 T* 完全一样——都是 8 个字节,判断是不是 None 就是一条判断寄存器是否为 0 的机器指令,没有任何堆分配,也没有多花 1 个字节;
  • 但在编译期的类型系统里,Rust 通过把全 0 关进 Option<&T> 这个独立的类型实体,彻底消灭了未经检查直接解引用空指针的可能。

这就是 Rust 零成本抽象(Zero-Cost Abstraction)最经典的体现:更严格的编译期类型约束(禁止 &T 为空),不仅没有在运行时增加开销,反而为编译器腾出了优化空间,换来了运行时的零额外成本。


  1. 终结 null 的类型设计:传统语言将 null 隐式混入所有引用类型,导致编译器无法强制判空;Rust 令普通类型 T 保证 100% 有值,并将「可能缺失」显式分离为独立的标准库和类型 Option<T>(Some(T) 或 None),未经解包无法直接当成 T 使用(E0369)。
  2. 空位优化(Niche Optimization):由于合法的引用 &T(以及 String、Vec<T>、Box<T> 内部的指针)永远不为 0,编译器自动利用全 0 这个非法 bit 空位(Niche)表示 None,使 Option<&T> 与 &T 同占 8 字节,做到编译期强制判空、运行时零额外内存开销。

Option<T> 解决了「有值 或 缺失(无原因)」的问题。但如果在打开文件、解析字符串或发送网络请求时操作失败了,调用方不仅需要知道「没拿到结果」,还必须知道「到底是因为什么原因失败的」,并且要把错误干净地向上层传递。这就是下一节 Result<T, E> 与 ? 短路传播要解决的问题。

  1. 编译期类型核对:为什么在 Java 里写 String s = findName(id); int n = s.length(); 能通过编译(却可能在运行时崩溃),而在 Rust 里写 let s: Option<String> = find_name(id); let n = s.len(); 在编译期就会直接报错?
  2. 物理内存大小推算:在 64 位平台上,请判断以下三组类型的字节大小是否相等,并说明原因:
    • i64(8 字节)与 Option<i64>
    • &str(16 字节字符串切片胖指针)与 Option<&str>
    • Option<&i32>(8 字节)与 Option<Option<&i32>>
点击核对参考答案
  1. 为什么 Rust 在编译期直接拦截: 在 Java 中,findName 返回的虽然可能为空,但类型依然是 String,编译器允许对 String 调用 .length();而在 Rust 中,Option<String> 与 String 是两个截然不同的编译期类型实体,.len() 方法挂载在 String 上而非 Option<String> 上,未经模式匹配将内部 String 解构出来之前,编译器直接拒绝调用。
  2. 物理内存大小推算:
    • i64(8B)与 Option<i64>(16B)不相等:i64 的 (2^64) 种 bit 组合全部是合法整数,没有任何空位(Niche),因此 Option<i64> 必须额外加 1 字节 Tag,并为了满足 i64 的 8 字节对齐填充到 16 字节。
    • &str(16B)与 Option<&str>(16B)相等:&str 是由 (ptr, len) 组成的 16 字节胖指针,其中前 8 字节的 ptr 是非空引用,拥有全 0 空位,编译器直接用 ptr == 0 表示 None,零额外字节开销。
    • Option<&i32>(8B)与 Option<Option<&i32>>(16B)不相等:内层的 Option<&i32> 已经把 &i32 唯一的全 0 空位用掉了(用来表示内层的 None),8 字节内再无剩余空位可供外层的 None 乘坐,因此外层 Option 必须额外加 Tag 并对齐到 16 字节。