如果你一直看我的博客,你会发现我不仅仅会讲述 Android 相关的内容,我也会讲一些 Rust 相关的知识。
我不是 Rust 方面的专家,平时只有 20% 的时间编写 Rust 的代码,但我对 Rust 的喜爱溢于言表。
我的 Rust 经验主要是编写 UI 工具链,方便平时开发 AOSP 相关的内容。
对于一个写惯了 Java 和 Kotlin 的人来讲,我非常推崇 Rust 对空指针的处理方式。那么,就让我这个不那么精通 Rust 的人来看看,Rust 是如何解决空指针的。
空指针的泥潭

如果你写过 C、C++ 或 Java,应该都见过类似的错误:对象明明应该存在,运行时却突然抛出一个空指针异常。
代码看起来没什么问题,编译也通过了,真正的问题要等到程序跑起来才暴露。
更麻烦的是,空指针错误往往不是发生在赋值的位置,而是发生在很远之后的某次访问上。等你回头排查时,只能沿着调用链一点点追踪:这个值到底是从哪里变成 null 的?
虽然 C++ 和 Java 对于空指针都有明确的规避方式,但是作为开发者,对于空指针的担忧始终萦绕在心头。你能保证自己写的代码是完美的,但你不能规避第三方代码的空指针问题呀!
Rust 的神来之笔

在我 21 年接触 Rust 的时候,我发现 Rust 对 null 的处理非常有意思。
Rust 没有 null 这个值。
当然,没有 null 并不是简单地说"我没有空指针",而是把"可能为空"这件事从一种隐式风险,变成了类型系统中明确表达的一部分。
换句话说,Rust 并没有让空值彻底消失,而是让它没法再偷偷混进普通代码里。
你必须直面空指针,而不是假装这里不会有空指针问题,这是 Rust 对于空指针的最终奥义!
在 Rust 里,普通引用默认就是非空的。比如 &T 和 &mut T,它们表达的语义不是"这里可能有一个 T",而是"这里一定有一个有效的 T"。
所以你不能随便构造一个空引用,也不能把 null 当成一个正常引用传来传去。
这个设计非常关键,因为它让大量代码天然摆脱了空指针检查。
rust
fn print_name(name: &String) {
println!("{}", name);
}
这段代码里,name 不需要判断是否为空。因为从类型上看,它就不可能是空的。函数签名已经把约束说清楚了:调用者必须传一个有效的 String 引用进来。编译器会帮助你维护这个约束,而不是把风险留到运行时。
那如果一个值确实可能不存在怎么办?Rust 使用 Option<T> 来表达。
rust
enum Option<T> {
Some(T),
None,
}
这其实就是 Rust 处理空值的核心思路:不要用一个特殊的 null 值混在所有类型里面,而是用一个明确的类型告诉你,这里可能有值,也可能没有值。
比如查找一个用户时,用户可能存在,也可能不存在:
rust
fn find_user(id: u64) -> Option<String> {
if id == 1 {
Some("Alice".to_string())
} else {
None
}
}
调用者拿到 Option<String> 之后,不能直接把它当成 String 用。
你必须处理 Some 和 None 两种情况。
rust
match find_user(1) {
Some(name) => println!("user: {}", name),
None => println!("user not found"),
}
这就是 Rust 和很多传统语言最大的区别。
传统语言里,一个变量可能是 null,但类型本身往往看不出来。你看到的是 User、String、Object,但运行时它也可能是 null。
所以在这些语言里,你不得不编写大量的代码去处理空指针问题 ------ if (a != null)。
Rust 则把这种不确定性写进类型里:String 就是有值,Option<String> 才是可能没值。
Rust 干掉空指针的方式,不是靠程序员自觉多写几个 if 判断,而是改变了问题的表达方式。
以前我们写代码时,经常在心里记着:"这个地方有可能为空,后面要小心。"Rust 不让你只在心里记着,它要求你在类型上写出来。
凡事都有例外
当然,Rust 也不是完全没有"空指针"这个概念。
比如在和 C 语言交互时,或者在写底层代码时,Rust 仍然有原始指针:*const T 和 *mut T。原始指针是可以为空的,也可以指向无效地址。但是使用原始指针解引用需要进入 unsafe 代码块。
rust
let p: *const i32 = std::ptr::null();
unsafe {
// 解引用之前必须自己保证它是有效的
// println!("{}", *p);
}
这说明 Rust 的目标不是假装世界上没有危险,而是把危险圈在边界里。
普通业务代码使用引用和 Option<T>,编译器帮你保证基本安全;真正需要和底层世界打交道时,可以使用原始指针,但这部分代码会被 unsafe 明确标出来。代码审查时,大家自然也知道这些地方需要额外小心。
性能
Rust 的 Option<T> 还有一个很容易被忽略的细节:它并不一定会带来额外的运行时开销。
很多人一开始看到 Some 和 None,会以为这肯定比 null 指针更重。实际上,Rust 编译器会做所谓的空指针优化。
对于引用、Box<T>、函数指针这类本身不能为 null 的类型,Option<&T> 或 Option<Box<T>> 往往可以和普通指针占用一样大的空间。因为编译器可以用原本非法的 null 位模式来表示 None。
这就是 Rust 的高明之处了:在语义上让"可能为空"变得明确,在性能上又尽量不让你为这种明确性付出额外代价。
强迫症
当然,这套设计也带来一个很现实的体验:你不能偷懒。
拿到一个 Option<T>,就必须处理没有值的情况。你可以用 match,也可以用 if let,还可以用 unwrap_or、map、and_then 之类的方法。
对于简单场景,这些工具非常顺手。
rust
let name = find_user(2).unwrap_or("Guest".to_string());
println!("{}", name);
但如果你直接使用 unwrap(),其实就有点回到老路上了。
rust
let name = find_user(2).unwrap();
unwrap() 的意思是:我确信这里一定有值,如果没有,就让程序 panic。
它不是不能用,比如测试代码、原型代码、明确不可能失败的地方都可以用。但在正式业务代码里频繁使用 unwrap(),就等于把 Rust 类型系统帮你拦下来的风险又手动放回去了。
Rust 不能阻止你写出不负责任的代码,但它会让这种不负责任变得非常显眼,你的领导一旦在业务代码中审查到这种代码,一定会批评你几句。不说领导了,你让 AI 给你查一下,也都会提醒你这个地方的处理过于粗糙。
Kotlin 呢
再来看 Kotlin。
Kotlin 处理空值的思路和 Rust 有相似之处:它同样把"是否可空"放进了类型系统。
只不过 Kotlin 是运行在 JVM 上的语言,它需要和 Java 互操作,所以它的设计不像 Rust 那样彻底。
我现在想想,如果当时 Kotlin 对于 Java 中的可空互操作,自定义一个 Option 去处理,会不会比现在更好呢?
在 Kotlin 里,String 和 String? 是两个不同的类型。
kotlin
val name: String = "Alice"
val nickname: String? = null
String 表示非空字符串,String? 表示这个字符串可能为空。
如果你直接访问一个可空类型的方法,编译器会报错。
kotlin
val nickname: String? = null
// 编译不通过
// println(nickname.length)
你必须使用安全调用:
kotlin
println(nickname?.length)
这里的 ?. 表示:如果 nickname 不为空,就访问它的 length;如果为空,整个表达式结果就是 null。
Kotlin 还提供了 Elvis 操作符 ?:,用来给空值提供默认结果。
kotlin
val length = nickname?.length ?: 0
也可以在判断之后,让编译器自动做智能类型转换。
kotlin
if (nickname != null) { // 你必须确保 nickname 是 val
println(nickname.length)
}
在这个 if 分支里,Kotlin 知道 nickname 已经不是 null 了,所以允许你像普通 String 一样访问它。
从日常开发体验看,Kotlin 的空安全非常舒服。它不像 Rust 那样把所有权、生命周期、借用检查一起压过来,学习成本低很多;但它也比 Java 更严格,能提前发现大量空指针问题。
尤其在 Android 开发里,Kotlin 的空安全确实减少了很多低级崩溃。
不过 Kotlin 也保留了一个"后门":!!。
kotlin
val length = nickname!!.length
!! 的意思是:我认为它一定不为空,如果它为空,就直接抛出 NullPointerException。
这个操作符很直白,也很危险。它基本相当于开发者告诉编译器:"这里你别管,我自己负责。"如果判断错了,程序就会在运行时崩溃。
Kotlin 还有一个绕不开的问题:Java 互操作。
Java 代码里的类型默认并不携带严格的可空信息,所以 Kotlin 引入了平台类型,比如 String! 这种概念。
你在代码里不会直接写出 String!,但从 Java 返回的对象可能在 Kotlin 看起来既可以当非空用,也可以当可空用。这是 Kotlin 空安全体系里最容易漏风的地方。
比如 Java 里有这样一个方法:
java
public String getName() {
return null;
}
Kotlin 调用它时,如果没有注解告诉编译器这个返回值可能为空,开发者就很容易把它当成非空值使用,最后还是可能遇到运行时空指针。
所以 Kotlin 的策略可以概括为:在 Kotlin 自己的世界里尽量做到空安全,但在 JVM 生态和 Java 历史包袱面前,仍然需要开发者保持警惕。它比 Java 向前走了一大步,但没有像 Rust 那样把边界切得那么硬。
走马观花

除了 Rust 和 Kotlin,其他现代语言也在用不同方式处理空值问题。比如 Swift,它的设计和 Kotlin 很像,也使用 Optional 表达可能不存在的值。
swift
var name: String? = nil
if let realName = name {
print(realName)
} else {
print("no name")
}
Swift 里的 String 和 String? 也是不同类型。
访问 Optional 时,需要解包。你可以用 if let,也可以用 guard let,还可以用可选链调用。
Swift 也有强制解包 !,一旦值为空就会崩溃。这个设计和 Kotlin 的 !! 非常接近:语言鼓励你安全处理,但也允许你在明确知道风险的时候强行解包。
再比如 TypeScript。
JavaScript 里长期存在 null 和 undefined 两种"空"的表达,这也是很多 bug 的来源。
TypeScript 没法改变 JavaScript 的运行时模型,但它可以在类型系统里增加约束。开启 strictNullChecks 之后,null 和 undefined 不会再随便赋值给其他类型。
ts
let name: string = "Alice"
// 开启 strictNullChecks 后,这样会报错
// name = null
如果一个值可能为空,就要写成联合类型:
ts
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 | null 或 T | undefined。
语法不同,理念很接近。它们都在推动开发者做一件事:不要假装一个值永远存在,而是在它可能不存在的时候,明确写出处理逻辑。
这背后其实是编程语言设计思想的变化。
过去很多语言更信任程序员,语言本身提供强大的自由度,至于你会不会踩坑,那是你自己的事。
现代语言则更倾向于把常见错误变成编译期问题。能在编译期发现的,就不要留到运行时;能在类型系统里表达清楚的,就不要只靠注释和约定。
那么也就是说,随着技术的发展,语言越来越不信任程序员了? ------ 很有意思的一个想法。
空指针就是一个典型例子。它不是复杂的算法问题,也不是高深的架构问题,但它足够常见,足够隐蔽,也足够致命。
一个本该存在的对象突然为空,轻则页面崩溃,重则服务异常。如果语言能在编译阶段把这类问题提前暴露出来,整体代码质量自然会提升。
当然,类型系统不是银弹,空问题本身并不能完全规避!
Rust 里可以滥用 unwrap(),Kotlin 里可以到处写 !!,Swift 里也可以强制解包,TypeScript 里可以用 any 把类型检查绕开。
语言只能给你铺一条更安全的道路,把危险的操作标得更醒目,最终你怎么走,还是取决于你的习惯和工程纪律。
这就是现代语言对稳定性和健壮性的共同追求:让代码的风险更可见,让错误更早暴露,让边界条件更难被忽略。
空指针曾经被称为"十亿美元的错误",而 Rust、Kotlin、Swift、TypeScript 这些语言的努力,本质上都是在告诉我们:不要再把这种错误交给运行时处理了,能在写代码的时候解决,就别等到线上崩溃时再解决。