Rust 是如何干掉空指针的

如果你一直看我的博客,你会发现我不仅仅会讲述 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 用。

你必须处理 SomeNone 两种情况。

rust 复制代码
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 代码块。

rust 复制代码
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 之类的方法。

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

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 里,StringString? 是两个不同的类型。

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 里的 StringString? 也是不同类型。

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

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

再比如 TypeScript。

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

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

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 | nullT | undefined

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

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

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

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

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

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

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

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

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

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

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

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

相关推荐
doiito19 小时前
【Agent Harness】Gliding Horse 本体论系统设计:给 AI Agent 装上“语义大脑”
ai·rust·架构设计·系统设计·ai agent
rocpp1 天前
Android 多语言切换实战:从 Context 到 Android 13 应用语言适配
android·kotlin
黄林晴1 天前
用了这么久 Koin Scope,原来一直都用错了?
android·kotlin
大卫小东(Sheldon)2 天前
Rust 推荐使用宏而非普通函数的场景
rust
doiito2 天前
【Agent Harness】为什么我把 JSON‑LD “编译成 DAG” 后,整个 Agent 平台立刻聪明了
ai·rust·架构设计·系统设计·ai agent
唐青枫2 天前
Kotlin Context Parameters 详解:别再把 Logger、事务和配置层层往下传
kotlin
jump_jump2 天前
为了重玩金庸群侠传,我研究了一下 Ruffle 怎么复活 Flash
游戏·rust·github
Coffeeee2 天前
如何使用Glide和Coil加载WebP动图
android·kotlin·glide
Kapaseker2 天前
5 分钟搞懂 Kotlin DSL
android·kotlin
alexhilton3 天前
使用Android Archive进行打包
android·kotlin·android jetpack