Rust 是如何干掉空指针的

0 阅读12分钟

如果你一直看我的博客,你会发现我不仅仅会讲述 Android 相关的内容,我也会讲一些 Rust 相关的知识。

我不是 Rust 方面的专家,平时只有 20% 的时间编写 Rust 的代码,但我对 Rust 的喜爱溢于言表。

我的 Rust 经验主要是编写 UI 工具链,方便平时开发 AOSP 相关的内容。

对于一个写惯了 Java 和 Kotlin 的人来讲,我非常推崇 Rust 对空指针的处理方式。那么,就让我这个不那么精通 Rust 的人来看看,Rust 是如何解决空指针的。

空指针的泥潭

1.png

如果你写过 C、C++ 或 Java,应该都见过类似的错误:对象明明应该存在,运行时却突然抛出一个空指针异常。

代码看起来没什么问题,编译也通过了,真正的问题要等到程序跑起来才暴露。

更麻烦的是,空指针错误往往不是发生在赋值的位置,而是发生在很远之后的某次访问上。等你回头排查时,只能沿着调用链一点点追踪:这个值到底是从哪里变成 null 的?

虽然 C++ 和 Java 对于空指针都有明确的规避方式,但是作为开发者,对于空指针的担忧始终萦绕在心头。你能保证自己写的代码是完美的,但你不能规避第三方代码的空指针问题呀!

Rust 的神来之笔

2.png

在我 21 年接触 Rust 的时候,我发现 Rust 对 null 的处理非常有意思。

Rust 没有 null 这个值。

当然,没有 null 并不是简单地说“我没有空指针”,而是把“可能为空”这件事从一种隐式风险,变成了类型系统中明确表达的一部分。

换句话说,Rust 并没有让空值彻底消失,而是让它没法再偷偷混进普通代码里。

你必须直面空指针,而不是假装这里不会有空指针问题,这是 Rust 对于空指针的最终奥义!

在 Rust 里,普通引用默认就是非空的。比如 &T&mut T,它们表达的语义不是“这里可能有一个 T”,而是“这里一定有一个有效的 T”。

所以你不能随便构造一个空引用,也不能把 null 当成一个正常引用传来传去。

这个设计非常关键,因为它让大量代码天然摆脱了空指针检查。

fn print_name(name: &String) {
    println!("{}", name);
}

这段代码里,name 不需要判断是否为空。因为从类型上看,它就不可能是空的。函数签名已经把约束说清楚了:调用者必须传一个有效的 String 引用进来。编译器会帮助你维护这个约束,而不是把风险留到运行时。

那如果一个值确实可能不存在怎么办?Rust 使用 Option<T> 来表达。

enum Option<T> {
    Some(T),
    None,
}

这其实就是 Rust 处理空值的核心思路:不要用一个特殊的 null 值混在所有类型里面,而是用一个明确的类型告诉你,这里可能有值,也可能没有值。

比如查找一个用户时,用户可能存在,也可能不存在:

fn find_user(id: u64) -> Option<String> {
    if id == 1 {
        Some("Alice".to_string())
    } else {
        None
    }
}

调用者拿到 Option<String> 之后,不能直接把它当成 String 用。

你必须处理 SomeNone 两种情况。

match find_user(1) {
    Some(name) => println!("user: {}", name),
    None => println!("user not found"),
}

这就是 Rust 和很多传统语言最大的区别。

传统语言里,一个变量可能是 null,但类型本身往往看不出来。你看到的是 UserStringObject,但运行时它也可能是 null

所以在这些语言里,你不得不编写大量的代码去处理空指针问题 —— if (a != null)

Rust 则把这种不确定性写进类型里:String 就是有值,Option<String> 才是可能没值。

Rust 干掉空指针的方式,不是靠程序员自觉多写几个 if 判断,而是改变了问题的表达方式。

以前我们写代码时,经常在心里记着:“这个地方有可能为空,后面要小心。”Rust 不让你只在心里记着,它要求你在类型上写出来。

凡事都有例外

当然,Rust 也不是完全没有“空指针”这个概念。

比如在和 C 语言交互时,或者在写底层代码时,Rust 仍然有原始指针:*const T*mut T。原始指针是可以为空的,也可以指向无效地址。但是使用原始指针解引用需要进入 unsafe 代码块。

let p: *const i32 = std::ptr::null();

unsafe {
    // 解引用之前必须自己保证它是有效的
    // println!("{}", *p);
}

这说明 Rust 的目标不是假装世界上没有危险,而是把危险圈在边界里。

普通业务代码使用引用和 Option<T>,编译器帮你保证基本安全;真正需要和底层世界打交道时,可以使用原始指针,但这部分代码会被 unsafe 明确标出来。代码审查时,大家自然也知道这些地方需要额外小心。

性能

Rust 的 Option<T> 还有一个很容易被忽略的细节:它并不一定会带来额外的运行时开销。

很多人一开始看到 SomeNone,会以为这肯定比 null 指针更重。实际上,Rust 编译器会做所谓的空指针优化。

对于引用、Box<T>、函数指针这类本身不能为 null 的类型,Option<&T>Option<Box<T>> 往往可以和普通指针占用一样大的空间。因为编译器可以用原本非法的 null 位模式来表示 None

这就是 Rust 的高明之处了:在语义上让“可能为空”变得明确,在性能上又尽量不让你为这种明确性付出额外代价。

强迫症

当然,这套设计也带来一个很现实的体验:你不能偷懒。

拿到一个 Option<T>,就必须处理没有值的情况。你可以用 match,也可以用 if let,还可以用 unwrap_ormapand_then 之类的方法。

对于简单场景,这些工具非常顺手。

let name = find_user(2).unwrap_or("Guest".to_string());
println!("{}", name);

但如果你直接使用 unwrap(),其实就有点回到老路上了。

let name = find_user(2).unwrap();

unwrap() 的意思是:我确信这里一定有值,如果没有,就让程序 panic。

它不是不能用,比如测试代码、原型代码、明确不可能失败的地方都可以用。但在正式业务代码里频繁使用 unwrap(),就等于把 Rust 类型系统帮你拦下来的风险又手动放回去了。

Rust 不能阻止你写出不负责任的代码,但它会让这种不负责任变得非常显眼,你的领导一旦在业务代码中审查到这种代码,一定会批评你几句。不说领导了,你让 AI 给你查一下,也都会提醒你这个地方的处理过于粗糙。

Kotlin 呢

再来看 Kotlin。

Kotlin 处理空值的思路和 Rust 有相似之处:它同样把“是否可空”放进了类型系统。

只不过 Kotlin 是运行在 JVM 上的语言,它需要和 Java 互操作,所以它的设计不像 Rust 那样彻底。

我现在想想,如果当时 Kotlin 对于 Java 中的可空互操作,自定义一个 Option 去处理,会不会比现在更好呢?

在 Kotlin 里,StringString? 是两个不同的类型。

val name: String = "Alice"
val nickname: String? = null

String 表示非空字符串,String? 表示这个字符串可能为空。

如果你直接访问一个可空类型的方法,编译器会报错。

val nickname: String? = null

// 编译不通过
// println(nickname.length)

你必须使用安全调用:

println(nickname?.length)

这里的 ?. 表示:如果 nickname 不为空,就访问它的 length;如果为空,整个表达式结果就是 null

Kotlin 还提供了 Elvis 操作符 ?:,用来给空值提供默认结果。

val length = nickname?.length ?: 0

也可以在判断之后,让编译器自动做智能类型转换。

if (nickname != null) { // 你必须确保 nickname 是 val
    println(nickname.length)
}

在这个 if 分支里,Kotlin 知道 nickname 已经不是 null 了,所以允许你像普通 String 一样访问它。

从日常开发体验看,Kotlin 的空安全非常舒服。它不像 Rust 那样把所有权、生命周期、借用检查一起压过来,学习成本低很多;但它也比 Java 更严格,能提前发现大量空指针问题。

尤其在 Android 开发里,Kotlin 的空安全确实减少了很多低级崩溃。

不过 Kotlin 也保留了一个“后门”:!!

val length = nickname!!.length

!! 的意思是:我认为它一定不为空,如果它为空,就直接抛出 NullPointerException

这个操作符很直白,也很危险。它基本相当于开发者告诉编译器:“这里你别管,我自己负责。”如果判断错了,程序就会在运行时崩溃。

Kotlin 还有一个绕不开的问题:Java 互操作。

Java 代码里的类型默认并不携带严格的可空信息,所以 Kotlin 引入了平台类型,比如 String! 这种概念。

你在代码里不会直接写出 String!,但从 Java 返回的对象可能在 Kotlin 看起来既可以当非空用,也可以当可空用。这是 Kotlin 空安全体系里最容易漏风的地方。

比如 Java 里有这样一个方法:

public String getName() {
    return null;
}

Kotlin 调用它时,如果没有注解告诉编译器这个返回值可能为空,开发者就很容易把它当成非空值使用,最后还是可能遇到运行时空指针。

所以 Kotlin 的策略可以概括为:在 Kotlin 自己的世界里尽量做到空安全,但在 JVM 生态和 Java 历史包袱面前,仍然需要开发者保持警惕。它比 Java 向前走了一大步,但没有像 Rust 那样把边界切得那么硬。

走马观花

3.png

除了 Rust 和 Kotlin,其他现代语言也在用不同方式处理空值问题。比如 Swift,它的设计和 Kotlin 很像,也使用 Optional 表达可能不存在的值。

var name: String? = nil

if let realName = name {
    print(realName)
} else {
    print("no name")
}

Swift 里的 StringString? 也是不同类型。

访问 Optional 时,需要解包。你可以用 if let,也可以用 guard let,还可以用可选链调用。

Swift 也有强制解包 !,一旦值为空就会崩溃。这个设计和 Kotlin 的 !! 非常接近:语言鼓励你安全处理,但也允许你在明确知道风险的时候强行解包。

再比如 TypeScript。

JavaScript 里长期存在 nullundefined 两种“空”的表达,这也是很多 bug 的来源。

TypeScript 没法改变 JavaScript 的运行时模型,但它可以在类型系统里增加约束。开启 strictNullChecks 之后,nullundefined 不会再随便赋值给其他类型。

let name: string = "Alice"

// 开启 strictNullChecks 后,这样会报错
// name = null

如果一个值可能为空,就要写成联合类型:

let nickname: string | null = null

if (nickname !== null) {
  console.log(nickname.length)
}

TypeScript 的做法和 Kotlin、Rust 都有点像:让“可能为空”出现在类型签名里,而不是偷偷藏在运行时。

区别在于 TypeScript 最终还是编译成 JavaScript,类型检查主要发生在编译阶段,运行时并不会真的多出一套类型系统。因此它能帮助你减少很多错误,但前提是项目开启严格模式,并且团队不要大量使用 any 去绕开类型检查。

亿点想法

如果把这些语言放在一起看,会发现现代语言对于空指针的处理有一个共同趋势:不再把 null 当成所有引用类型的默认可能值,而是把“值可能不存在”单独表达出来。

Rust 用 Option<T>,Kotlin 用 T?,Swift 用 Optional,TypeScript 用 T | nullT | undefined

语法不同,理念很接近。它们都在推动开发者做一件事:不要假装一个值永远存在,而是在它可能不存在的时候,明确写出处理逻辑。

这背后其实是编程语言设计思想的变化。

过去很多语言更信任程序员,语言本身提供强大的自由度,至于你会不会踩坑,那是你自己的事。

现代语言则更倾向于把常见错误变成编译期问题。能在编译期发现的,就不要留到运行时;能在类型系统里表达清楚的,就不要只靠注释和约定。

那么也就是说,随着技术的发展,语言越来越不信任程序员了? —— 很有意思的一个想法。

空指针就是一个典型例子。它不是复杂的算法问题,也不是高深的架构问题,但它足够常见,足够隐蔽,也足够致命。

一个本该存在的对象突然为空,轻则页面崩溃,重则服务异常。如果语言能在编译阶段把这类问题提前暴露出来,整体代码质量自然会提升。

当然,类型系统不是银弹,空问题本身并不能完全规避!

Rust 里可以滥用 unwrap(),Kotlin 里可以到处写 !!,Swift 里也可以强制解包,TypeScript 里可以用 any 把类型检查绕开。

语言只能给你铺一条更安全的道路,把危险的操作标得更醒目,最终你怎么走,还是取决于你的习惯和工程纪律。

这就是现代语言对稳定性和健壮性的共同追求:让代码的风险更可见,让错误更早暴露,让边界条件更难被忽略。

空指针曾经被称为“十亿美元的错误”,而 Rust、Kotlin、Swift、TypeScript 这些语言的努力,本质上都是在告诉我们:不要再把这种错误交给运行时处理了,能在写代码的时候解决,就别等到线上崩溃时再解决。