Rust 借用检查器的软肋:深入揭秘四个出人意料的限制

文摘   2024-12-26 22:55   江苏  

Rust 借用检查器的四个限制

自2016年以来,我一直在业余项目中使用 Rust,并从2021年开始专业地使用 Rust,所以我自认为对 Rust 相当了解。我已经熟悉 Rust 类型系统的所有常见限制以及如何绕过它们,因此很少需要“与借用检查器斗争”,尽管新 Rust 用户经常为此挣扎。然而,这种情况偶尔还是会发生。

在这篇文章中,我将介绍我在工作过程中遇到的借用检查器的四个令人惊讶的限制。

同时请注意,当我说某事无法完成时,我的意思是无法以利用 Rust 类型系统的方式完成,即通过静态类型检查。你可以通过使用不安全代码或运行时检查(例如,“只需在所有内容上添加 Arc<Mutex<_>>”)来轻易绕过任何问题。然而,不得不诉诸于此仍然代表了类型系统的一个限制。永远不会出现你根本无法解决问题的情况,因为总是有这些逃生口(我甚至将在下面展示我使用的逃生口的例子),但以使 Rust 成为 Rust 的方式解决问题是不可能的。

1. 检查不考虑匹配和返回

在这种情况下,实际上有人来找我帮忙解决这个问题。然后我忘记了它,后来在自己的工作中遇到了完全相同的障碍,所以这似乎是一个特别常见的问题。

这个问题最常见的表现是,当你想在哈希表中查找一个值,并在它不存在时做不同的事情。为了举例,假设你想在哈希表中查找一个键,如果它不存在,就退回到第二个键。你可以像这样做:

解释

fn double_lookup(map: &HashMap<StringString>, 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<StringString>, 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<StringString>, 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(&selfmut 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(&selfmut f: implFnMut(u32)) {
        forvinself.0.iter().copied() {
            f(v);
        }
    }

    pubasyncfnasync_for_all<Fut>(&selfmut 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 有三个函数特征,FnFnMut 和 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

编程悟道
自制软件研发、软件商店,全栈,ARTS 、架构,模型,原生系统,后端(Node、React)以及跨平台技术(Flutter、RN).vue.js react.js next.js express koa hapi uniapp Astro
 最新文章