Rust 借用检查器的四个限制
自2016年以来,我一直在业余项目中使用 Rust,并从2021年开始专业地使用 Rust,所以我自认为对 Rust 相当了解。我已经熟悉 Rust 类型系统的所有常见限制以及如何绕过它们,因此很少需要“与借用检查器斗争”,尽管新 Rust 用户经常为此挣扎。然而,这种情况偶尔还是会发生。
在这篇文章中,我将介绍我在工作过程中遇到的借用检查器的四个令人惊讶的限制。
同时请注意,当我说某事无法完成时,我的意思是无法以利用 Rust 类型系统的方式完成,即通过静态类型检查。你可以通过使用不安全代码或运行时检查(例如,“只需在所有内容上添加 Arc<Mutex<_>>
”)来轻易绕过任何问题。然而,不得不诉诸于此仍然代表了类型系统的一个限制。永远不会出现你根本无法解决问题的情况,因为总是有这些逃生口(我甚至将在下面展示我使用的逃生口的例子),但以使 Rust 成为 Rust 的方式解决问题是不可能的。
1. 检查不考虑匹配和返回
在这种情况下,实际上有人来找我帮忙解决这个问题。然后我忘记了它,后来在自己的工作中遇到了完全相同的障碍,所以这似乎是一个特别常见的问题。
这个问题最常见的表现是,当你想在哈希表中查找一个值,并在它不存在时做不同的事情。为了举例,假设你想在哈希表中查找一个键,如果它不存在,就退回到第二个键。你可以像这样做:
解释
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 不鼓励做不必要的工作,比如多次冗余地查找哈希表。与其先检查映射中的值然后访问它(不必要的第二次查找),不如调用 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]: 不能同时多次借用 `*map` 为可变
--> src/main.rs:46:5
|
40 | fn double_lookup_mut(map: &mut HashMap<String, String>, mut k: String) -> Option<&mut String> {
| - 我们称这个引用的生命周期为 `'1`
41 | if let Some(v) = map.get_mut(&k) {
| --- 第一次可变借用发生在这里
42 | return Some(v);
| ------- 返回这个值需要 `*map` 被借用为 `'1`
...
46 | map.get_mut(&k)
| ^^^ 第二次可变借用发生在这里
第一次 get_mut
调用借用了 map
并返回了一个可能包含借用引用的 Option
。当它这样做时,我们立即返回值,因此在我们不返回的分支中,我们实际上不再使用借用。然而,借用检查器的流程分析能力有限,目前还无法理解这种事情。
因此,从借用检查器的角度来看,第一次 get_mut
调用导致 map
被错误地借用了整个函数的其余部分,使得无法再对其进行任何操作。
为了绕过这个限制,我们不得不进行不必要的检查和查找,像这样:
解释
fn double_lookup_mut2(map: &mut HashMap<String, String>, mut k: String) -> Option<&mut String> {
// 我们在这里查找 k:
if map.contains_key(&k) {
// 然后无缘无故地再次查找它。
return map.get_mut(&k);
}
k.push_str("-default");
map.get_mut(&k)
}
2. 异步的痛苦
假设你有一个向量,你想使用 封装 来防止用户担心实现的内部细节。因此,你只提供一个方法,它接受用户提供的回调,并在每个元素上调用它。
解释
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;
// 也可以在回调中捕获值
mv.for_all(|v| sum += v);
非常简单,对吧?现在假设你想允许 异步 代码。我们希望能够这样做:
mv.async_for_all(|v| async move {println!("{}", v)}).await;
……是的,祝你好运。我尝试了我能想到的一切,但据我所知,目前 Rust 中根本没有表达所需类型签名的方法。Rust 最近添加了 use<'a>
语法,更早之前,泛型关联类型,但即使这些似乎也没有帮助。问题是,函数返回的 future 类型必须依赖于参数的生命周期,而 Rust 不允许你对参数化类型进行泛型。
我可能在这方面错了,如果有的话,请随时加入讨论。如果有一种方法可以做到这一点,我非常想知道。
3. FnMut 不允许重新借用捕获
好吧,我们不能带一个异步回调,它接受一个引用。然而,在上述的玩具示例中,我们只是在处理简单的整数。让我们去掉泛型 <T>
,并且也通过值而不是引用传递一切:
解释
struct MyVec(Vec<u32>);
implMyVec {
pubfnfor_all(&self, mut f: implFnMut(u32)) {
forvinself.0.iter().copied() {
f(v);
}
}
pubasyncfnasync_for_all<Fut>(&self, mut f: implFnMut(u32) -> Fut)
where Fut: Future<Output = ()>,
{
forvinself.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]: 不能从 `r` 中移动出来,这是一个在 `FnMut` 闭包中捕获的变量
--> src/main.rs:137:26
|
136 | let r = &mut sum;
| - 捕获了外部变量
137 | mv.async_for_all(|v| async move {*r += v}).await;
| --- ^^^^^^ --
| | | |
| | | 变量由于在协程中的使用而被移动
| | | 移动发生是因为 `r` 的类型是 `&mut u32`,它没有实现 `Copy` 特性
| | `r` 在这里被移动
| 由这个 `FnMut` 闭包捕获
这里的问题是我们定义的 async_for_all
的签名不够通用。
我们的闭包类型是什么?为了理解问题,让我们尝试用显式类型来编写闭包。
首先,我们需要为我们返回的 future 创建一个类型。在大多数情况下,用安全的 Rust 编写自己的 future 是不可能的,但在像这样的简单案例中,没有借用,实际上 是 可能的:
解释
struct MyFut<'a>{
r: &'amutu32,
v: u32,
}
impl<'a> Future forMyFut<'a> {
typeOutput = ();
fnpoll(mutself: Pin<&mutSelf>, cx: &mut Context<'_>) -> Poll<Self::Output> {
*self.r += self.v;
Poll::Ready(())
}
}
现在我们只需要一个代表闭包本身的类型:
解释
struct SumCallback<'a> {
r: &'amutu32,
}
impl<'a> SumCallback<'a> {
fncall_mut<'s>(&'smutself, v: u32) -> MyFut<'s> {
MyFut{r: &
mutself.r, v}
}
}
注:'s
生命周期可以省略,但我在这里明确写出它以便于清晰。
这段代码确实可以编译。问题是我们定义的 call_mut
方法的签名实际上与实际的 FnMut
特征的签名不同。实际的 FnMut
特征强制输出类型与 self
生命周期无关。
FnMut
可能因此而设计,因为 a) Rust 在启动时没有泛型关联类型,b) 不清楚你会使用什么样的缩写语法。你可以尝试在语法中定义一个神奇的 'self
类型,允许你将类型写成 impl FnMut(u32) -> MyFut<'self>
,但这有点hack,如果允许 impl FnMut
嵌套,它也不会工作。无论如何,FnMut
今天就是这样工作的,所以我们再次陷入了困境。
顺便说一下,Rust 有三个函数特征,Fn
、FnMut
和 FnOnce
,分别对应方法接收器 &self
、&mut self
和 self
。然而,FnMut
是唯一一个缺乏自生命周期是一个问题的情况。在 Fn
的情况下,任何对捕获值的引用必须是共享引用,因此是 Copy
,所以返回对整个类型的引用没有问题。对于 FnOnce
,你根本不能借用捕获的值。
FnMut
独特的原因是&mut
引用是唯一一个重新借用相关的案例。在我们的call_mut
方法中,我们没有直接返回捕获的r
引用(它有生命周期'a
)。相反,我们返回了一个临时的子借用,该引用的生命周期为's
。如果r
是&u32
而不是&mut u32
,那么它将是Copy
,所以我们可以直接返回整个'a
引用,没有问题。
4. Send 检查器不受控流感知
这是我在工作中写的代码的简化版本:
解释
async fnupdate_value(foo: Arc<std::sync::Mutex<Foo>>, new_val: u32) {
letmut locked_foo = foo.lock().unwrap();
letold_val = locked_foo.val;
if new_val == old_val {
locked_foo.send_no_changes();
} else {
// 释放互斥锁,以免在 await 点持有它。
std::mem::drop(locked_foo);
// 现在做一些昂贵的工作
letchanges = get_changes(old_val, new_val).await;
// 然后发送结果
foo.lock().unwrap().send_changes(changes);
}
}
我们锁定了一个对象,如果字段没有变化,我们走快速路径,否则,我们释放锁,做一些处理,然后再次锁定它以发送更新。
顺便说一下,我确信有人会问如果 foo.val
在锁释放时发生变化怎么办。在这种情况下,这是唯一一个写入该字段的任务,所以那不可能发生(我们需要互斥锁的唯一原因是因为另一个任务 读取 该字段)。此外,由于我们没有在持有锁时做任何昂贵的工作,也不期望有任何真正的争用,我们只是使用常规的 std::sync::Mutex
而不是更典型的异步感知 tokio Mutex
,但这与这里讨论的问题无关。
那么问题是什么?没有问题,只要你只在根任务中运行。使用通常的多线程 Tokio 运行时,你可以在主线程上使用 block_on
运行一个任务,这个 future 不需要是 Send
。然而,你生成的任何 其他 任务都要求你的 future 是 Send
。
为了增加并行性并避免阻塞主线程,我想将这段代码从主线程移到单独的任务中。不幸的是,上述 future 不是 Send
,因此不能作为任务生成。
解释
note: future 不是 `Send`,因为这个值在 await 点被使用
--> src/main.rs:183:53
|
175 | let mut locked_foo = foo.lock().unwrap();
| -------------- 类型为 `MutexGuard<'_, Foo>`,它不是 `Send`
...
183 | let changes = get_changes(old_val, new_val).await;
| ^^^^^ await 发生在这里,`mut locked_foo` 可能稍后被使用
现在,这段代码 应该是 Send
。毕竟,它实际上从未 真正 在 await 点持有锁(这将有死锁的风险)。然而,编译器目前 在决定 future 是否为 Send
时不进行任何控流分析,因此它被错误地标记为阳性。
作为变通办法,我不得不将锁移到一个显式的作用域中,然后复制 if 条件,并将 else 分支移到作用域之外:
解释
async fnupdate_value(foo: Arc<std::sync::Mutex<Foo>>, new_val: u32) {
letold_val = {
letmut locked_foo = foo.lock().unwrap();
letold_val = locked_foo.val;
if new_val == old_val {
locked_foo.send_no_changes();
}
old_val
// 在这里释放锁,以便编译器理解这是 Send
};
if new_val != old_val {
letchanges = get_changes(old_val, new_val).await;
foo.lock().unwrap().send_changes(changes);
}
}
结论
Rust 的类型系统在典型情况下已经工作得很好,但偶尔还是会有惊喜。没有静态类型系统会允许 所有 有效程序,由于不可判定性问题,但编程语言可以足够好,以至于它很少成为实际问题。编程语言设计的挑战之一是弄清楚如何在你的复杂性和性能预算内(这包括不仅仅是编译器实现,还包括语言本身的复杂性,特别是类型系统)允许尽可能多的合理程序。
在我强调的问题中,#1 和 #4 特别是明显的修复事项,这将带来很大的价值,成本很小。#2 和 #3 更棘手,因为它们需要对类型语法进行更改,那里的复杂性成本很高。然而,异步 Rust 与经典的直线 Rust 相比工作得如此糟糕,这仍然是令人遗憾的。
ref
https://blog.polybdenum.com/2024/12/21/four-limitations-of-rust-s-borrow-checker.html