文章摘要
Rust语言以其内存安全性和高效性著称,其核心创新是借用检查器,用于强制执行所有权规则,确保内存安全且无运行时开销。然而,作者认为借用检查器在Rust中的角色被过度夸大,且其导致严重的编程体验问题,尤其是对引用的处理变得繁琐,影响了代码的易用性。
文章总结
标题:Rust 中我最不喜欢的就是借用检查器
在 2010 年代的一众编程语言中,Rust 可能是最受赞誉的。Rust 的主要卖点在于它成功地将速度和底层控制与高水平的错误抵抗能力(通常称为安全性)结合在一起。Rust 的主要创新——也是它成名的原因——是它的借用检查器:编译器的一部分,负责强制执行 Rust 的所有权规则,从而使 Rust 能够实现垃圾回收语言所享有的所有内存安全性,但无需运行时开销。
Rust 的~~传教士~~支持者们将内存安全性作为 Rust 的核心卖点,以至于借用检查器已成为 Rust 身份的标志性特征。我认为将 Rust 的安全性与借用检查器的保证混为一谈是有些误导的。在这篇文章中,我想提出两个论点:
- 借用检查器给 Rust 带来了严重的人机工程学问题。
- 借用检查器在 Rust 安全性中的作用被夸大了。
借用检查器的根本问题
简而言之,Rust 借用检查器的问题在于它让引用变得非常麻烦。
从抽象层面来看,这种麻烦的原因在于借用检查器需要在编译时知道所有引用的生命周期,而这显然是一个不切实际的要求。生命周期通常是一个固有的运行时属性。
从算法层面来看,借用检查器强制执行一种特定的模型或所有权规则,但这个模型过于严格,导致它拒绝了太多行为良好的程序,从而降低了 Rust 的人机工程学表现。
从实现层面来看,借用检查器的当前实例并不完善,经常拒绝那些符合所有权模型的程序,尽管该模型本身就已经过于严格。
借用检查器的挫败感就像失恋一样——你无法轻易展示它,只有亲身经历才能理解人们所说的痛苦。真正的借用检查器痛苦并不是在你那 20 行的小示例代码无法编译时感受到的,而是在你现有的项目需要对所有权结构进行微小修改时,借用检查器却拒绝编译你的代码。然后,一旦你开始拉扯代码中的那一小根松散的线,你会发现你不得不拆解掉一半的代码才能让借用检查器满意。
尽管如此,小例子仍然可以展示借用检查器拒绝完全正常代码的倾向。
借用检查器失败的例子
最明显的例子是借用检查器没有做到它应该做的事情,因为它拒绝了那些甚至没有违反 Rust 所有权规则精神的代码。
例如,考虑我从一篇博客中得到的这段代码:
```rust struct Point { x: f64, y: f64, }
impl Point { fn x_mut(&mut self) -> &mut f64 { &mut self.x }
fn y_mut(&mut self) -> &mut f64 {
&mut self.y
}
}
fn main() { let mut point = Point { x: 1.0, y: 2.0 }; let xref = point.xmut(); let yref = point.ymut(); *xref *= 2.0; *yref *= 2.0; } ```
这段代码无法编译,因为两个可变引用 x_ref 和 y_ref 需要同时存在,这违反了 Rust 的原则,即对某些数据的可变引用在任何时候都必须是唯一的。
当然,这是一个误报,因为这些引用指向同一结构体的不同字段,因此并不引用相同的数据。借用检查器拒绝这段代码是因为其底层规则——“突变需要独占性”——被不精确地实现,导致引用字段和引用结构体之间的细微差别被忽略。
另一个类似但略有不同的例子出现在以下代码中:
```rust
struct Collection {
counter: u32,
items: Vec
impl Collection { fn increment_counter(&mut self) { self.counter += 1; }
pub fn count_items(&mut self) {
for _ in &self.items {
self.increment_counter();
}
}
} ```
对于人类读者来说,很明显 increment_counter 不会改变 self.items,因此不会干扰对向量的循环。因此,借用检查器的规则——“突变需要独占性”——并没有被违反,代码应该可以正常编译。
不幸的是,借用检查器无法跨函数进行推理,因此错误地拒绝了该函数。
借用检查器不仅在跨函数检查借用时过于保守;它甚至无法很好地推理函数内的控制流。以下例子是它无法正确推理分支的著名例证:
rust
fn get_default<'r, K: Hash + Eq + Copy, V: Default>(
map: &'r mut HashMap<K, V>,
key: K,
) -> &'r mut V {
match map.get_mut(&key) {
Some(value) => value,
None => {
map.insert(key, V::default());
map.get_mut(&key).unwrap()
}
}
}
由于两个可变引用,这段代码被拒绝,尽管程序逻辑保证第二个引用仅在 None 分支中创建,而第一个引用此时已经不再存在。
借用检查器还有更多类似的不必要限制。
一个“足够智能的借用检查器”
此时,Rust 的辩护者可能会指出,正因为上述例子是实现的限制,它们并不是根本性的,未来随着实现的改进可能会被解除。这种希望有一定的道理:Rust 在 2022 年采用了所谓的非词法生命周期,这确实提高了借用检查器的准确性。同样,一种新的借用检查器形式,称为Polonius,正在开发中,它将进一步提高准确性。
我仍然持怀疑态度。Polonius 已经开发了七年,似乎还远未完成。更根本的是,借用检查器永远不会“完成”,因为它的工作是推理你的代码,而程序无法在足够深的层次上做到这一点。这与神话般的“足够智能的编译器”有明显的相似之处——就像编译器不断改进,但似乎从未真正理解你的代码,也很少能在算法层面上重写它,借用检查器可能总是会拒绝看似明显正确的代码。
规则本身就不符合人机工程学
在上面,我展示了抽象所有权模型实现中的局限性。然而,有时模型本身对你的程序来说就是错误的。
考虑以下代码:
```rust struct Id(u32);
fn main() { let id = Id(5); let mut v = vec![id]; println!("{}", id.0); } ```
这明显违反了所有权规则:在 v 构造后,id 被移动到 v 中,这阻止了函数 main 在最后一行使用 id。从这个意义上说,借用检查器——无论任何实现——都应该拒绝该程序。它确实违反了所有权规则。
但在这个案例中,这些规则的意义何在?这里,所有权规则并没有防止释放后使用、双重释放、数据竞争或任何其他错误。对于人类来说,这段代码显然是没问题的,没有任何实际的所有权问题。但借用检查器作为一个程序,很难与之协商,也很难说服它不要过于迂腐地执行一套僵硬的规则,尽管在这个案例中,这些规则毫无意义。
在上述案例中,这种迂腐并不重要,因为解决方法很简单。但我的经验是,经常遇到这些根本性问题,即所有权模型不符合我的程序需求。例如:
- 对临时值的引用,例如在闭包中创建的值,是被禁止的,尽管对人类来说,解决方案显然是将值的生命周期延长到闭包外使用。
- 混合所有权的结构体:你不能有一个结构体,其中一个字段包含
Vec<Thing>,而另一个字段在Vec<Vec<&Thing>>中存储相同事物的组。 - 系统发育树是一个巨大的痛苦,因为每个节点的双向引用与 Rust 的数据只有一个所有者的概念根本冲突。
很难夸大这些问题在垃圾回收语言中根本不存在,而是纯粹由 Rust 的借用检查器自找的。你想在 Python 中构建一个双向引用的树吗?只需做显而易见的事情,它就能完美运行。在 Rust 中可能做到,但非常复杂[1]。
Rust 的一个常见辩护是,借用检查器给你带来的痛苦并不是多余的痛苦,而是提前的痛苦。这种说法是:你有一个复杂所有权结构的程序,Rust 只是迫使你明确这一点。通过这样做,它保证了内存安全——你难道不宁愿处理编译器错误而不是生产崩溃吗?
但这并不是我的经验。我的经验是,借用检查器的问题大多只是无稽之谈——没有实际依据的虚构问题。每当我遇到 Python 中的一个错误,而这个错误在 Rust 中会被借用检查器阻止时,我可能会遇到二十个借用检查器问题。
另一个常见的说法是,这种借用检查器的挫败感仅仅是初学者的挣扎。一旦你内化了 Rust 的所有权模型,你就会自动构建代码以符合借用检查器,使所有问题消失。不幸的是,在断断续续使用 Rust 几年后,这还没有成为我的经验,而且似乎我并不孤单。
为什么你不直接...
我上面的示例代码可能无法说服有经验的 Rust 开发者。他们可能会争辩说,这些片段并没有显示出任何真正的人机工程学问题,因为使这些片段编译的解决方案完全微不足道。在最后一个例子中,我可以直接为 Id 派生 Clone + Copy。
我知道。这些绊脚石的共同点是,你可以跳过它们。你确实可以做一些额外的工作来解决任何借用检查器问题。
问题是,一开始就没有问题需要这些额外的工作。Rust 将你的完全功能正常的代码挡在生命周期谜题之后,并迫使你重构,直到你解决了它。Rust 坚持认为你的程序结构是一个纸牌屋——触碰一小部分,大部分就必须拆掉重建。随着我 Rust 经验的增加,我越来越怀疑这通常是完全错误的。对于大多数我遇到的借用检查器问题,问题不在于我的程序结构,而在于 Rust 的很大程度上任意的限制。
当然,在上面的片段中,借用检查器问题的解决方案是微不足道的,但在更大规模的实际代码中,它们可能是一个真正的挑战。反常的是,因为 Rust 的生命周期谜题具有挑战性,它们某种程度上很有趣。我相信这在一定程度上解释了为什么这么多人似乎并不介意它们。与其思考如何编写代码来解决我受雇解决的科学问题,我不得不思考如何编写代码来取悦借用检查器。后者通常更易于处理,范围有限,需求更明确,更像“谜题”。从这个意义上说,Rust 实现了逃避主义:在编写 Rust 时,你可以解决很多“问题”——不是真正的问题,而是有趣的问题。
通常遵守借用检查器的方法是重构你的代码。这已经是令人不快的额外工作,但有时甚至这还不够。让我们看看人们通常推荐我解决借用检查器问题的一些其他方法:
使用更少的引用并复制数据。或者:“直接克隆”。
这通常是很好的建议。通常,额外的分配是可以接受的,由此产生的性能下降不是问题。但奇怪的是,在一个以性能为导向的语言中,分配被鼓励,不是因为程序逻辑需要它,而是因为借用检查器需要它。
当然,有时克隆并不能解决问题,因为你确实需要改变共享对象。
Rc / Arc / RefCell / Box 滥用
Rust 的 Arc 类型禁用了它包装的对象的某些 Rust 所有权规则,而是对该特定对象进行引用计数。这意味着有时,可以通过在你的程序中到处添加 Arc 来安抚借用检查器。或者,正如我也喜欢称之为“管理世界上最糟糕的垃圾收集器,但没有便利性或性能”。
公平地说,添加一些选择性的 Arc 或 RefCell 并不是性能问题。当这些被广泛用于绕过借用检查器时,例如在大型图中的每个节点上,它就会成为问题。
使用索引而不是引用
当我第一次尝试在 Rust 中实现双向图时,有人建议我使用这种模式。双向图的边可以通过使用整数 ID 引用顶点来表示,而不是实际引用。由于你不以这种方式使用引用,你不会遇到借用检查器的问题。
第一次有人给我这个建议时,我不得不重新审视。Rust 社区的全部承诺是编译器强制执行的正确性,他们构建借用检查器的前提是人类不能信任手动处理引用。当同一个借用检查器使引用无法工作时,他们的解决方案是……建议我手动管理它们,零安全性和零语言支持?!?这种讽刺是不可思议的。要求人们手动管理引用是如此不安全和不人性化,如果不是因为大多数情况下是悲伤的,这个建议会很有趣。
Rust 的安全性只有部分归功于借用检查器
在网络讨论中,Rust 的安全性有时等同于其内存安全性,这归功于借用检查器。Rust 的抗错误声誉是应得的,但我认为这是由于 Rust 的广泛良好品味和坚实设计。就像性能一样,正确性死于千刀万剐,而 Rust 以始终如一地关注正确性而著称:
- 它广泛使用枚举加上详尽的模式匹配,包括错误状态,使得很难忽略潜在的错误和边缘情况。
- 它大量使用自定义类型在类型系统中编码信息,编译器可以在其中静态防止错误。
- 强制使用关键字参数来构造结构体,使得很难混淆字段。
- 对函数 API 中的边缘情况的一致关注和文档。
- 良好的工具,例如 cargo-semver-checks,以及良好的内置 linter。
更无形但同样重要的是 Rust 对正确性的强烈文化亲和力。例如,去 YouTube 点击一些 Rust 会议频道。你会看到,很大一部分演讲都以某种方式涉及正确性。这是我在 Julia 或 Python 会议演讲中没有看到的。
这一点体现在它的标准库中充满了难以误用的 API。例如,比较 Python 的 int.from_bytes 和 Julia 的类似 reinterpret(::Int, ::AbstractArray) 与 Rust 的 i64::from_le_bytes。所有这些函数的结果都由字节序决定,但只有 Rust 函数使字节序明确——在其他两种语言中,用户需要记住这个潜在的正确性问题。
我确信,具有上述特性但使用垃圾收集器而不是借用检查器的语言将具有 Rust 的大部分正确性。OCaml 和 Haskell 就是这样,它们也有很强的安全性和正确性声誉。
好吧,借用检查器并不是全坏
正如你可能已经注意到的,我不喜欢借用检查器,但即使我也不得不勉强承认它有一些用例。
没有借用检查器,你要么手动管理内存,这既烦人又容易出错,要么使用垃圾回收(GC)[2]。GC 也有其缺点:
- 标记和清除会导致延迟峰值,如果你的程序必须具有毫秒级的响应能力,这可能是不可接受的。
- GC 是间歇性发生的,这意味着垃圾在每次收集之前会积累,因此你的程序总体上内存效率较低。
仅这两个缺点就足以使 GC 对某些应用来说不可行。由于 Rust 旨在用于低级应用,例如裸机软件操作系统内核,GC 并不是一个可行的选择。这些限制是完全有效的,但大多数软件,当然不是大多数科学软件,并不受这些限制。如果你像我一样,不是在构建操作系统或编程微控制器,那么 GC 的这些缺点并不适用。
在将借用检查器与 GC 进行比较时,还有性能问题,这并不简单。GC 以性能差而闻名,因为大多数 GC 语言如 Java 和 Python 都不以性能为导向。当非 GC 语言与以性能为导向的 GC 语言如 Julia 和 Go 进行比较时,差距缩小了。而且即使是 Julia 和 Go 也明显比非 GC 语言如 Rust 或 C 更高层次,为程序员提供更少的控制,使得比较有些混淆。
我不完全清楚 GC 是否比确定性销毁慢。当然,有些情况下 GC 会导致减速。一种情况是当程序的对象图很大,并且每次收集时都需要遍历它。在这种情况下,我听到一个程序员嘲笑 GC,称其为“堆扫描器”。或者,当程序的分配模式意味着它在垃圾回收之间会破坏 CPU 缓存,而确定性销毁会在热缓存中重用内存。我见过展示这种行为的 Julia 代码,其中手动调用 malloc / free 比 Julia 的 GC 快六倍。
另一方面,二叉树基准测试[3]的简单实现在 Julia 中比在 Rust 中快几倍,因为 Julia 的 GC 在批量运行时具有更高的吞吐量,而 Rust 的确定性销毁是逐个对象调用的。
好吧,借用检查器确实防止了一些错误
垃圾回收器将防止悬空指针、释放后使用和双重释放错误。缓冲区溢出通过边界检查来防止。这些是 Rust 防止的主要内存安全问题,它不需要借用检查器来做到这一点。
但有一些错误是借用检查器特别适合防止的:多线程代码中的数据竞争由 Rust 优雅且静态地防止。我从未编写过大型并发 Rust 程序,我承认借用检查器可能在这个用例中非常神奇,很容易弥补其笨拙。然而,异步 Rust 并没有很好的声誉。
在单线程代码中,静态防止外部持有的引用的突变听起来会防止很多错误,但我在 Julia 和 Python 中的经验是,我很少遇到这些错误。你的情况可能不同。
借用检查器还有一些意想不到的小好处值得一提:保证编译器数据是不可变的可以解锁优化,同样,保证可变数据不被别名化可以启用其他优化。借用检查器的机制可以用于一些看似无关的技巧,例如无锁突变锁,以及只占用 1 字节内存的锁。
结论
在我的日常工作中,我在 Julia、Python 和 Rust 之间切换。我始终体验到“草总是另一边更绿”:当我从 Julia 切换到 Rust 时,我怀念 Julia 的优势。当我不在 Rust 中编程时,我怀念 Rust 提供的许多好东西:我怀念 Rust 简洁的枚举及其详尽的匹配。我怀念特质
评论总结
评论主要围绕Rust的借用检查器(Borrow Checker)展开,观点分为支持和批评两派。
支持借用检查器的观点: 1. 借用检查器确保了代码的正确性和内存安全。评论者认为,借用检查器不仅防止了内存安全问题,还使得遵循Rust所有权模式的代码更有可能正确无误。 - "code that follows Rust’s tree-style ownership pattern and doesn’t excessively circumvent the borrow checker is more likely to be correct." (评论5) - "Memory is always owned by someone, its validity is always determined by someone, and having that validity enforced by the language is absolutely priceless." (评论21)
借用检查器是Rust成功的关键。评论者指出,借用检查器是Rust区别于其他语言的重要特性,帮助Rust获得了广泛的关注和采用。
- "the borrowchecker is the only reason Rust actually caught on." (评论6)
- "The borrow checker is certainly Rust’s claim to fame. And a critical reason why the language got popular and grew." (评论10)
借用检查器在并发编程中尤为重要。评论者认为,借用检查器在并发编程中提供了额外的安全保障,避免了潜在的并发问题。
- "A huge part of the spirit of rust is fearless concurrency. The simple seeming false positive examples become non-trivial in concurrent code." (评论22)
批评借用检查器的观点: 1. 借用检查器增加了代码的复杂性。评论者认为,借用检查器在某些情况下过于严格,导致代码编写和修改变得繁琐。 - "When you need to change a program, re-doing the ownership plumbing can be quite time-consuming. Losing a few days on that is a routine Rust experience." (评论19) - "The thrust of the piece is 'there should not be so many rules, let me do whatever I want to do if the code would make sense to me'." (评论28)
借用检查器在处理某些场景时表现不佳。评论者指出,借用检查器在处理自引用结构和复杂的所有权关系时存在局限性。
- "The other big problem is back references. Rust still lacks a good solution in that area." (评论19)
- "Rust is absolute garbage at arenas. No bumpalo doesn’t count." (评论10)
借用检查器并非适用于所有场景。评论者认为,借用检查器在某些领域(如游戏开发)可能过于严格,限制了开发的灵活性。
- "The other end of the spectrum is something like gamedev: you write code that pretty explicitly has an end-date, and the actual shape of the program can change drastically during development." (评论15)
其他观点: 1. 借用检查器的替代方案。评论者提到,如果不喜欢借用检查器,可以考虑使用其他语言,如Gleam或Moonbit。 - "If a friend told me they liked Rust but didn’t like the borrow checker, I’d probably point them to Gleam and Moonbit." (评论16)
- 借用检查器的未来改进。评论者认为,借用检查器仍有改进空间,未来可能会有更强大的版本出现。
- "Maybe some other language will come in with a more powerful and omniscient borrow checker, and leave Rust in the dust." (评论21)
总结来看,借用检查器在确保代码安全和正确性方面发挥了重要作用,但也因其严格性和复杂性受到了一些批评。未来,借用检查器的改进和替代方案可能会进一步推动Rust的发展。