1.Rust 借用检查器在确保类型安全方面发挥了重要作用,但仍存在一些局限性。
2.其中一个限制是借用检查器无法结合 match 和返回值进行判断,导致在复杂场景中出现问题。
3.另一方面,异步代码中的 Future 类型需要依赖于参数的生命周期,而 Rust 不允许对参数化类型进行泛型化。
4.由于此,Rust 编译器在判断 Future 是否是 Send 时并未进行控制流分析,可能导致错误地将其标记为不安全。
5.为了提升 Rust 生态开发体验,改进这些限制具有重要意义。
以上内容由腾讯混元大模型生成,仅供参考
Rust 以其严格的类型系统和内存安全著称,为开发者提供了强大的工具来避免运行时错误。然而,即便是经验丰富的 Rust 开发者,也难免在面对复杂场景时遇到一些棘手的类型系统限制。本文作者结合多年的实际开发经验,深入探讨 Rust 安全性保证的核心工具 Rust 借用检查器的局限性,并结合实例,分析这些问题在实际开发中的影响,还探讨了改进这些限制对于提升 Rust 生态开发体验的重要意义。
以下为译文:
我从 2016 年开始用 Rust 来开发个人项目,而后自 2021 年起将这门语言正式应用在工作中,所以我算是对 Rust 相当熟悉了。我已经对 Rust 类型系统的常见限制及其解决方法了如指掌,因此很少会像新手那样频繁地“与借用检查器作斗争”。不过,偶尔还是会碰到一些问题。
在这篇文章中,我将分享四个在工作中遇到的借用检查器的意外限制。
需要说明的是,当我说“某事无法实现”时,我指的是无法通过 Rust 的类型系统来实现,也就是无法通过静态类型检查实现。或许你可以使用不安全代码(unsafe)或者运行时检查(比如“直接给所有东西加上 Arc<Mutex<_>>”)来绕过这些问题。然而,如果不得不采用这些方法,依然反映出类型系统的局限性。并不是说问题根本无法解决——因为总会有这些“逃生通道”(我还会在下文中展示一个我使用逃生通道的例子)——但确实无法用一种充分体现 Rust 精髓的方式来解决问题。
借用检查器无法结合 match 和返回值进行判断
这个问题非常常见,我甚至先是帮别人解决了类似的问题,后来自己也在工作中也遇到了。这说明这种问题尤其普遍。
这种问题通常出现的场景是——你想要在 HashMap 中查找一个值,并在找不到时执行其他操作的场景中。为了举例说明,假设你需要先查找一个键,如果找不到,再使用备用键进行查找。你可以轻松地用如下代码实现:
fn double_lookup(map: &HashMap<String, String>, mut k: String) -> Option<&String> {
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<String, String>, mut k: String) -> Option<&mut String> {
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<String, String>, mut k: String) -> Option<&mut String> {
| - 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<String, String>, mut k: String) -> Option<&mut String> {
// 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<T>(Vec<T>);
impl<T> MyVec<T> {
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<'a>(早期称为 use<'a>)语法,并且更早之前还加入了泛型关联类型(Generic Associated Types, GAT),但即便如此,这些工具也无法解决问题。
问题的关键在于,函数返回的 Future 类型需要依赖于参数的生命周期,而 Rust 不允许对参数化类型进行泛型化。
当然,我可能理解得不完全对。如果有人知道如何实现这个功能,请随时指出。如果有解决方案,我非常乐意学习。
FnMut 不允许对捕获变量进行重借用
既然无法使用接受引用的异步回调,我们可以简化示例,移除泛型 <T>,并通过值而不是引用传递所有数据:
struct MyVec(Vec<u32>);
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<Fut>(&self, mut f: impl FnMut(u32) -> Fut)
where Fut: Future<Output = ()>,
{
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<'a>{
r: &'a mut u32,
v: u32,
}
impl<'a> Future for MyFut<'a> {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
*self.r += self.v;
Poll::Ready(())
}
}
接下来,我们需要定义回调函数的类型:
struct SumCallback<'a> {
r: &'a mut u32,
}
impl<'a> SumCallback<'a> {
fn call_mut<'s>(&'s mut self, v: u32) -> MyFut<'s> {
MyFut{r: &mut self.r, v}
}
}
注意:'s 的生命周期可以省略,但这里为了清晰明确,我直接写了出来。
上述代码可以正常编译。然而,call_mut 方法的签名与 FnMut 特质的签名并不一致。FnMut 特质要求返回值的类型与 self 的生命周期无关,而这与我们自定义的方法有所冲突。
根源问题
FnMut 之所以被设计成这样,可能是因为:
1. Rust 在最初发布时并不支持泛型关联类型(GATs)。
2. 即使支持,如何设计简洁的语法也是个问题。例如,可以尝试定义一个特殊的 'self 生命周期,这样可以将类型写成 impl FnMut(u32) -> MyFut<'self>,但这种写法在嵌套时就会变得复杂且难以理解。
当前,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<std::sync::Mutex<Foo>>, 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<'_, Foo>` 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<std::sync::Mutex<Foo>>, 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 相比仍存在明显差距。