Rust 借用检查器的四个限制!

Rust 借用检查器的四个限制!
2024年12月30日 17:05 CSDN

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 相比仍存在明显差距。

财经自媒体联盟更多自媒体作者

新浪首页 语音播报 相关新闻 返回顶部