网易首页 > 网易号 > 正文 申请入驻

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

0
分享至


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<&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 , 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 , 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 , 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 (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<'a>(早期称为 use<'a>)语法,并且更早之前还加入了泛型关联类型(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<'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.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 >, 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 >, 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 相比仍存在明显差距。

勿再“浮沙筑高台”

用扎实的 C++ 技术为你的职业发展奠定坚实基础

加入「C++ 大师系列精品课」

带你踏上一条通往技术巅峰的学习之旅!

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

相关推荐
热点推荐
证监会或难辞其咎!1月3日,昨晚爆出的三大消息冲击来袭!

证监会或难辞其咎!1月3日,昨晚爆出的三大消息冲击来袭!

风口招财猪
2025-01-03 10:09:14
全明星首轮投票结果:字母暂列票王+詹库排名下降 三大悬念待揭晓

全明星首轮投票结果:字母暂列票王+詹库排名下降 三大悬念待揭晓

罗说NBA
2025-01-03 05:30:04
上海轨交警方:“5元卖地铁座位”系男子自编自演,已行拘

上海轨交警方:“5元卖地铁座位”系男子自编自演,已行拘

澎湃新闻
2025-01-03 12:46:26
流感!5种病毒同时在北京肆虐!专家:1月上旬或迎最高峰

流感!5种病毒同时在北京肆虐!专家:1月上旬或迎最高峰

说点真嘞叭
2025-01-03 06:42:35
掏鸟窝判10年,继父性侵16岁继女,不断逼问:大不大?只判3年2月

掏鸟窝判10年,继父性侵16岁继女,不断逼问:大不大?只判3年2月

鋭娱之乐
2025-01-02 14:37:56
43岁范冰冰和男友人逛巴黎,亲密合影小巧依人,相濡以沫已20年

43岁范冰冰和男友人逛巴黎,亲密合影小巧依人,相濡以沫已20年

南城无双
2025-01-03 00:28:07
日内瓦公约之战俘待遇VS劳动法

日内瓦公约之战俘待遇VS劳动法

安安小小姐姐
2025-01-03 06:30:29
世界媒体报道叙利亚元旦!原来只是抛弃一个垃圾政府就能换新天

世界媒体报道叙利亚元旦!原来只是抛弃一个垃圾政府就能换新天

大风文字
2025-01-02 17:08:39
没爱了热火板凳全员起立看球助威 仅巴特勒一人坐着格格不入

没爱了热火板凳全员起立看球助威 仅巴特勒一人坐着格格不入

直播吧
2025-01-03 11:32:30
马斯克将重振MPV市场!首款MPV或定名为Model Z,2025年上市

马斯克将重振MPV市场!首款MPV或定名为Model Z,2025年上市

沙雕小琳琳
2025-01-03 08:49:38
普京宣布再次征兵28万人,这是要在2025年大决战了吗?

普京宣布再次征兵28万人,这是要在2025年大决战了吗?

凯撒谈兵
2025-01-03 05:00:36
要么恢复俄气过境,要么赔钱!斯洛伐克威胁将报复乌克兰

要么恢复俄气过境,要么赔钱!斯洛伐克威胁将报复乌克兰

财联社
2025-01-03 08:14:10
5天内必须离开中国,我国下达逐客令:从孟晚舟开始,绝不再惯你

5天内必须离开中国,我国下达逐客令:从孟晚舟开始,绝不再惯你

听风者说
2025-01-02 20:38:41
中方突遭晴天霹雳!朝鲜竟然动手了?我外长直接把话说透

中方突遭晴天霹雳!朝鲜竟然动手了?我外长直接把话说透

娱乐的宅急便
2025-01-02 09:34:46
雄鹿轰20-0仍爆冷惜败篮网 拉塞尔伤退字母表50分连丢绝平三分

雄鹿轰20-0仍爆冷惜败篮网 拉塞尔伤退字母表50分连丢绝平三分

醉卧浮生
2025-01-03 11:39:25
一夜难安睡的银川人,该逃还是该躲?

一夜难安睡的银川人,该逃还是该躲?

基本常识
2025-01-03 11:59:46
13连胜创队史纪录!雷霆16分逆转大胜三杀快船 亚历山大29+8

13连胜创队史纪录!雷霆16分逆转大胜三杀快船 亚历山大29+8

醉卧浮生
2025-01-03 11:16:45
欧盟宣布禁止强迫劳动产品,BYD面临压力

欧盟宣布禁止强迫劳动产品,BYD面临压力

涛哥锐评
2025-01-02 14:06:45
大瓜!国足前锋亲弟张玉宁开淫趴,睡几百人还拍视频晒记录留念?

大瓜!国足前锋亲弟张玉宁开淫趴,睡几百人还拍视频晒记录留念?

乌娱子酱
2025-01-02 23:54:41
菲打响首枪,南海形势大变!中方数千援手到位,美国航母连夜后撤

菲打响首枪,南海形势大变!中方数千援手到位,美国航母连夜后撤

猫眼观史
2025-01-02 19:38:52
2025-01-03 13:11:00
CSDN incentive-icons
CSDN
成就一亿技术人
25186文章数 241936关注度
往期回顾 全部

科技要闻

特斯拉年销量10多年来首降 今年指望新车型

头条要闻

福建最长寿老人出殡:膝下子孙100多人 100岁还能自理

头条要闻

福建最长寿老人出殡:膝下子孙100多人 100岁还能自理

体育要闻

全红婵当选2024年度最佳女子跳水运动员

娱乐要闻

曝张小斐开车出门,驾车1次违章6次

财经要闻

2025年,降准降息仍有空间

汽车要闻

10万元级无图智驾 悦也PLUS全路况实测

态度原创

房产
时尚
亲子
家居
数码

房产要闻

年度榜单出炉!海口竟有20+楼盘卖疯了!单盘最高爆卖34亿!

你适合穿裙子还是穿裤子,关键就看这几点!

亲子要闻

谁说男孩子不能跳芭蕾的?中韩萌娃安安佑佑这不跳的挺好的

家居要闻

素色现代 开启简洁生活

数码要闻

曝海信全新画质芯片有历史性提升!1月6日亮相CES 2025

无障碍浏览 进入关怀版