Rust 以其严格的类型系统和内存安全著称,为开发者提供了强大的工具来避免运行时错误。然而,即便是经验丰富的 Rust 开发者,也难免在面对复杂场景时遇到一些棘手的类型系统限制。本文作者结合多年的实际开发经验,深入探讨 Rust 安全性保证的核心工具 Rust 借用检查器的局限性,并结合实例,分析这些问题在实际开发中的影响,还探讨了改进这些限制对于提升 Rust 生态开发体验的重要意义。
原文链接:https://blog.polybdenum.com/2024/12/21/four-limitations-of-rust-s-borrow-checker.html
作者 | polybdenum 责编 | 苏宓
出品 | CSDN(ID:CSDNnews)
以下为译文:
我从 2016 年开始用 Rust 来开发个人项目,而后自 2021 年起将这门语言正式应用在工作中,所以我算是对 Rust 相当熟悉了。我已经对 Rust 类型系统的常见限制及其解决方法了如指掌,因此很少会像新手那样频繁地“与借用检查器作斗争”。不过,偶尔还是会碰到一些问题。
在这篇文章中,我将分享四个在工作中遇到的借用检查器的意外限制。
需要说明的是,当我说“某事无法实现”时,我指的是无法通过 Rust 的类型系统来实现,也就是无法通过静态类型检查实现。或许你可以使用不安全代码(unsafe)或者运行时检查(比如“直接给所有东西加上 Arc
>”)来绕过这些问题。然而,如果不得不采用这些方法,依然反映出类型系统的局限性。并不是说问题根本无法解决——因为总会有这些“逃生通道”(我还会在下文中展示一个我使用逃生通道的例子)——但确实无法用一种充分体现 Rust 精髓的方式来解决问题。
借用检查器无法结合 match 和返回值进行判断
这个问题非常常见,我甚至先是帮别人解决了类似的问题,后来自己也在工作中也遇到了。这说明这种问题尤其普遍。
这种问题通常出现的场景是——你想要在 HashMap 中查找一个值,并在找不到时执行其他操作的场景中。为了举例说明,假设你需要先查找一个键,如果找不到,再使用备用键进行查找。你可以轻松地用如下代码实现:
fn double_lookup(map: &HashMap
, mut k: String) -> Option {
if let Some(v) = map.get(&k) {
return Some(v);
}
k.push_str("-default");
map.get(&k)
}
通常情况下,你可能更倾向于返回 &str 而不是 &String,不过这里为了简单清晰,使用了 String。
Rust 一贯建议避免不必要的操作,比如在 HashMap 中重复查找键值。与其先检查值是否存在再查找(这样会多一次无意义的查询),更好的方法是直接调用 get(),它会返回一个 Option,允许你一次完成所有操作。
然而,这种优化并非总是可行。有时借用检查器的限制会成为障碍。具体来说,假如我们想实现与上述逻辑相同的功能,但需要返回一个可变(&mut)引用而不是共享(&)引用:
fn double_lookup_mut(map: &mut HashMap
, mut k: String) -> Option {
if let Some(v) = map.get_mut(&k) {
return Some(v);
}
k.push_str("-default");
map.get_mut(&k)
}
运行这段代码时,编译器会报错:
error[E0499]: cannot borrow `*map` as mutable more than once at a time
--> src/main.rs:46:5
|
40 | fn double_lookup_mut(map: &mut HashMap
, mut k: String) -> Option {
| - let's call the lifetime of this reference `'1`
41 | if let Some(v) = map.get_mut(&k) {
| --- first mutable borrow occurs here
42 | return Some(v);
| ------- returning this value requires that `*map` is borrowed for `'1`
...
46 | map.get_mut(&k)
| ^^^ second mutable borrow occurs here
第一次调用 get_mut 时,map 被借用并返回一个可能包含引用的 Option。如果返回了值,借用会立即结束;而在不返回的分支中,实际上并没有再使用借用。然而,借用检查器的流分析能力有限,无法判断这种情况。
因此,在借用检查器看来,第一次调用 get_mut 会导致 map 在整个函数的剩余部分都被错误地视为已借用,使得无法对其进行任何其他操作。
为了解决这个限制,我们不得不使用一种多余的“检查再查找”的方法,如下所示:
fn double_lookup_mut2(map: &mut HashMap
, mut k: String) -> Option {
// We look up k here:
if map.contains_key(&k) {
// and then look it up again here for no reason.
return map.get_mut(&k);
}
k.push_str("-default");
map.get_mut(&k)
}
异步代码的痛苦
假设你有一个 vec(动态数组),且希望通过封装来隐藏内部实现细节,使用户无需关心具体实现。你需要提供了一个方法,该方法接收用户提供的回调函数,并对每个元素调用它:
struct MyVec
(Vec
);
impl
MyVec
{
pub fn for_all(&self, mut f: impl FnMut(&T)) {
for v in self.0.iter() {
f(v);
}
}
}
这样可以像下面这样使用:
let mv = MyVec(vec![1,2,3]);
mv.for_all(|v| println!("{}", v));
let mut sum = 0;
// Can also capture values in the callback
mv.for_all(|v| sum += v);
看起来很简单,对吧?
现在假设你想支持异步代码。理想情况下,你希望能够这样使用:
mv.async_for_all(|v| async move { println!("{}", v) }).await;
……嗯,祝你好运。我尝试了各种方法,花了不少时间,但据我所知,目前在 Rust 中根本无法表达所需的类型签名。
虽然 Rust 最近引入了 for(早期称为 use)语法,并且更早之前还加入了泛型关联类型(Generic Associated Types, GAT),但即便如此,这些工具也无法解决问题。
问题的关键在于,函数返回的 Future 类型需要依赖于参数的生命周期,而 Rust 不允许对参数化类型进行泛型化。
当然,我可能理解得不完全对。如果有人知道如何实现这个功能,请随时指出。如果有解决方案,我非常乐意学习。
FnMut 不允许对捕获变量进行重借用
既然无法使用接受引用的异步回调,我们可以简化示例,移除泛型
,并通过值而不是引用传递所有数据:
struct MyVec(Vec
);
impl MyVec {
pub fn for_all(&self, mut f: impl FnMut(u32)) {
for v in self.0.iter().copied() {
f(v);
}
}
pub async fn async_for_all
(&self, mut f: impl FnMut(u32) -> Fut)
where Fut: Future
,
{
for v in self.0.iter().copied() {
f(v).await;
}
}
}
这种写法确实可以正常工作,例如以下代码能够顺利编译:
mv.async_for_all(|v| async move { println!("{}", v); }).await;
然而,当回调函数捕获外部变量时,问题就出现了:
let mut sum = 0;
let r = &mut sum;
mv.async_for_all(|v| async move { *r += v }).await;
编译器报错:
error[E0507]: cannot move out of `r`, a captured variable in an `FnMut` closure
--> src/main.rs:137:26
|
136 | let r = &mut sum;
| - captured outer variable
137 | mv.async_for_all(|v| async move {*r += v}).await;
| --- ^^^^^^^^^^ --
| | | |
| | | variable moved due to use in coroutine
| | | move occurs because `r` has type `&mut u32`, which does not implement the `Copy` trait
| | `r` is moved here
| captured by this `FnMut` closure
问题在于 async_for_all 的签名不够通用。
问题分析
回调函数的类型是什么?为了理解问题,我们试着手动定义这个回调函数,并明确它的类型。
首先,我们需要定义返回的 Future 类型。在大多数情况下,用安全的 Rust 编写自己的 Future 是很困难的,但像这种没有引用的简单场景下是可行的:
struct MyFut{
r: &'a mut u32,
v: u32,
}
impl Future for MyFut {
type Output = ();
fn poll(mut self: Pin, cx: &mut Context) -> Poll
{
*self.r += self.v;
Poll::Ready(())
}
}
接下来,我们需要定义回调函数的类型:
struct SumCallback {
r: &'a mut u32,
}
impl SumCallback {
fn call_mut(&'s mut self, v: u32) -> MyFut {
MyFut{r: &mut self.r, v}
}
}
注意:'s 的生命周期可以省略,但这里为了清晰明确,我直接写了出来。
上述代码可以正常编译。然而,call_mut 方法的签名与 FnMut 特质的签名并不一致。FnMut 特质要求返回值的类型与 self 的生命周期无关,而这与我们自定义的方法有所冲突。
根源问题
FnMut 之所以被设计成这样,可能是因为:
1.Rust 在最初发布时并不支持泛型关联类型(GATs)。
2.即使支持,如何设计简洁的语法也是个问题。例如,可以尝试定义一个特殊的 'self 生命周期,这样可以将类型写成 impl FnMut(u32) -> MyFut,但这种写法在嵌套时就会变得复杂且难以理解。
当前,FnMut 的行为并不支持上述写法,因此我们受到了限制。
另外,Rust 中有三种函数特质:Fn、FnMut 和 FnOnce,它们分别对应接收者为 &self、&mut self 和 self 的方法。
但只有 FnMut 存在 self 生命周期的问题:
对于 Fn,捕获的值必须是共享引用,且是 Copy 的,因此返回整个类型的引用不会有问题。
对于 FnOnce,捕获的值不能被借用,因此不存在生命周期相关的问题。
FnMut 的特殊性在于,&mut 引用是唯一需要涉及重借用的情况。在 call_mut 方法中,我们返回的是捕获变量 r 的一个临时子借用(生命周期为 's),而不是直接返回 r 本身(生命周期为 'a)。如果 r 是 &u32 而非 &mut u32,它是 Copy 的,那么直接返回整个 'a 生命周期的引用也不会有问题。
Send 检查器无法感知控制流
以下是一个简化的代码版本,这段代码曾在工作中被实际使用:
async fn update_value(foo: Arc
>, new_val: u32) {
let mut locked_foo = foo.lock().unwrap();
let old_val = locked_foo.val;
if new_val == old_val {
locked_foo.send_no_changes();
} else {
// Release the mutex so we don't hold it across an await point.
std::mem::drop(locked_foo);
// Now do some expensive work
let changes = get_changes(old_val, new_val).await;
// And send the result
foo.lock().unwrap().send_changes(changes);
}
}
在这段代码中,锁定了一个对象。如果字段未发生变化,则走快速路径;否则会释放锁,执行一些处理后重新加锁并发送更新。
关于锁的释放
有人可能会问:在锁定被释放期间,如果 foo.val 的值发生了变化会怎样?在这种情况下,只有当前任务会写入该字段,因此不可能发生变化(需要锁的原因是还有其他任务会读取该字段)。
此外,由于我们不会在持有锁的情况下执行耗时操作,也不期望出现实际的争用,因此使用的是标准的 std::sync::Mutex,而不是更常见的异步 tokio::Mutex。但这些并不是这里问题的重点。
那么问题是什么?只要这段代码仅在根任务中运行,就没有问题。在多线程的 Tokio 运行时中,可以通过 block_on 在主线程上运行一个任务,此时这个 Future 不需要是 Send 的。然而,任何其他通过 spawn 启动的任务都需要其 Future 是 Send 的。
为了提高并行性并避免阻塞主线程,我想将这段代码移到一个独立任务中运行。然而,这段代码中的 Future 不是 Send,因此无法作为任务启动:
note: future is not `Send` as this value is used across an await
--> src/main.rs:183:53
|
175 | let mut locked_foo = foo.lock().unwrap();
| -------------- has type `MutexGuard` which is not `Send`
...
183 | let changes = get_changes(old_val, new_val).await;
| ^^^^^ await occurs here, with `mut locked_foo` maybe used later
实际上,这段代码应该是 Send 的。毕竟它从未真正跨越 await 点持有锁(那样会有死锁的风险)。然而,当前编译器在决定 Future 是否是 Send 时并未进行控制流分析,因此错误地将其标记为不安全。
解决方法
作为一种变通方法,我将锁放入显式作用域中,然后重复 if 条件并将 else 分支移到作用域外:
async fn update_value(foo: Arc
>, new_val: u32) {
let old_val = {
let mut locked_foo = foo.lock().unwrap();
let old_val = locked_foo.val;
if new_val == old_val {
locked_foo.send_no_changes();
}
old_val
// Drop the lock here, so the compiler understands this is Send
};
if new_val != old_val {
let changes = get_changes(old_val, new_val).await;
foo.lock().unwrap().send_changes(changes);
}
}
结论
Rust 的类型系统在大多数情况下表现良好,但偶尔仍会出现令人意外的情况。由于不可判定性问题,任何静态类型系统都不可能允许所有合法程序运行,但设计良好的编程语言能做到让这种问题极少成为实际障碍。
编程语言设计的一项挑战是,在复杂性和性能预算内(包括编译器实现、语言复杂性,尤其是类型系统的复杂性)尽可能支持合理的程序。
在本文提到的问题中,#1 和 #4 尤其值得修复,因为它们带来的价值很高,且实现成本低。而 2 和 3 则更棘手,因为它们涉及到类型语法的变更,复杂性代价较高。不过,很遗憾当前异步 Rust 的表现与经典线性 Rust 相比仍存在明显差距。
4000520066 欢迎批评指正
All Rights Reserved 新浪公司 版权所有