为什么我不能从闭包返回一个对外部可变变量的可变引用?

14

当我尝试使用Rust闭包时,遇到了一个有趣的场景:

fn main() {
    let mut y = 10;

    let f = || &mut y;

    f();
}

这会导致一个错误:

error[E0495]: cannot infer an appropriate lifetime for borrow expression due to conflicting requirements
 --> src/main.rs:4:16
  |
4 |     let f = || &mut y;
  |                ^^^^^^
  |
note: first, the lifetime cannot outlive the lifetime  as defined on the body at 4:13...
 --> src/main.rs:4:13
  |
4 |     let f = || &mut y;
  |             ^^^^^^^^^
note: ...so that closure can access `y`
 --> src/main.rs:4:16
  |
4 |     let f = || &mut y;
  |                ^^^^^^
note: but, the lifetime must be valid for the call at 6:5...
 --> src/main.rs:6:5
  |
6 |     f();
  |     ^^^
note: ...so type `&mut i32` of expression is valid during the expression
 --> src/main.rs:6:5
  |
6 |     f();
  |     ^^^

尽管编译器正在逐行解释,但我仍然不明白它到底在抱怨什么。

它是在说可变引用不能超出封闭的闭包吗?

如果我删除调用f(),编译器就不会抱怨。


这是否与这个问题有关,因为我理解闭包基本上是一种没有明确定义类型的struct形式? - soupybionics
作为一种解决方法,您可以在外部函数中获取引用并从闭包中返回它(playground),但我不知道为什么您的原始代码会失败... - Jmb
3个回答

13

简短版本

闭包 f 存储了对 y 的可变引用。如果允许它返回该引用的副本,你将同时拥有两个可变引用指向 y(一个在闭包中,一个返回),这违反了 Rust 内存安全规则。

详细版本

可以将闭包视为

struct __Closure<'a> {
    y: &'a mut i32,
}

由于它包含一个可变引用,因此闭包被称为FnMut,其定义如下:

fn call_mut(&mut self, args: ()) -> &'a mut i32 { self.y }

由于我们只有对闭包本身的可变引用,因此无法移动字段y,也无法复制它,因为可变引用不是Copy类型。

我们可以通过强制将闭包调用为FnOnce而不是FnMut来欺骗编译器接受代码。这段代码运行良好:

fn main() {
    let x = String::new();
    let mut y: u32 = 10;
    let f = || {
        drop(x);
        &mut y
    };
    f();
}

由于我们在闭包作用域内使用了x,而且x不是Copy类型,编译器检测到该闭包只能为FnOnce。调用FnOnce闭包会将闭包本身按值传递,因此我们允许移动可变引用。

另一种明确强制闭包为FnOnce的方法是将其传递给具有特质约束的通用函数。这段代码也可以正常工作:

fn make_fn_once<'a, T, F: FnOnce() -> T>(f: F) -> F {
    f
}

fn main() {
    let mut y: u32 = 10;
    let f = make_fn_once(|| {
        &mut y
    });
    f();
}

移出借用上下文比生命周期冲突更容易理解。我希望这两个问题是相关的,并且生命周期冲突源于移出借用上下文问题,我认为这是核心问题。 - soupybionics
它们并不相关。Lifetime(生命周期)与内存区域的有效性有关(指针必须指向一个有效的内存槽),而移出借用上下文与内存别名有关(两个假设的“可写”指针指向同一内存槽:在 Rust 中,您不能拥有两个所有者)。我不知道哪种更容易理解,这两个都是理解的核心概念,并且两者都在这里发挥作用。 - attdona

10

这里有两个主要问题:

  1. 闭包不能返回它们的环境的引用。
  2. 对可变引用的可变引用只能使用外部引用的生命周期(与不可变引用不同)。

闭包返回对环境的引用

闭包不能返回具有与 self(闭包对象)相同生命周期的任何引用。为什么呢?因为每个闭包都可以被调用为 FnOnce,因为它是 FnMut 的超级 trait,而 FnMut 又是 Fn 的超级 trait。`FnOnce` 有这个方法:

fn call_once(self, args: Args) -> Self::Output;
请注意,self是按值传递的。因此,由于self被消耗掉了(现在存在于call_once函数中),我们不能返回对它的引用——这将等同于返回对局部函数变量的引用。
理论上,call_mut允许返回对self的引用(因为它接收&mut self)。但是,由于call_oncecall_mutcall都实现了相同的体,一般来说闭包不能返回对self的引用(也就是:对其捕获环境的引用)。
只是为了确保:闭包可以捕获引用并返回它们!并且它们可以通过引用进行捕获并返回该引用。这些东西是不同的。这仅与闭包类型中存储的内容有关。如果闭包类型中存储了引用,则可以返回该引用。但我们无法返回对闭包类型中存储的任何内容的引用。
嵌套的可变引用
考虑以下函数(请注意,参数类型意味着'inner: 'outer'outer'inner短):
fn foo<'outer, 'inner>(x: &'outer mut &'inner mut i32) -> &'inner mut i32 {
    *x
}

这段代码无法编译。乍一看,它似乎应该可以编译,因为我们只是剥去了一个引用层级。对于不可变引用,它确实可以工作!但是可变引用在这里是不同的,以保持完整性。

返回&'outer mut i32是可以的。但是使用更长的(内部的)生命周期获取直接引用是不可能的。

手动编写闭包

让我们尝试手动编写您尝试编写的闭包:

let mut y = 10;

struct Foo<'a>(&'a mut i32);
impl<'a> Foo<'a> {
    fn call<'s>(&'s mut self) -> &'??? mut i32 { self.0 }
}

let mut f = Foo(&mut y);
f.call();

返回的引用应该具有什么寿命?

  • 它不能是'a,因为我们基本上有一个&'s mut &'a mut i32。并且如上所述,在这种嵌套的可变引用情况下,我们无法提取更长的生命周期!
  • 但它也不能是's,因为那意味着闭合返回了一个与'self的生命周期相同的东西(“从self借用”)。并且如上所述,闭包不能做到这一点。

因此,编译器无法为我们生成闭包实现。


1
Fn 闭包可以返回对捕获变量的引用。根据您的论点,这应该是不可能的,但它确实可以正常工作。 - Sven Marnach
@SvenMarnach 在你的例子中,y 被引用捕获。因此,在闭包的 self 中存储了一个 &i32。所以闭包并不是返回对自身的引用,而是通过引用捕获环境并返回它。这就是我在闭包部分最后一段话中所说的意思。但我知道,这很复杂 :/ 尝试使用 move || { &y } 会导致错误。我可能会尝试改进我的解释。 - Lukas Kalbertodt
2
我认为实际原因是可变引用不是Copy,而不可变引用是,我们无法将可变引用移出借用的上下文。尽管如此,这并不是错误消息所说的。 - Sven Marnach
1
@SvenMarnach 正确,如果我们使用不可变引用,它就可以工作。问题只是由于我提到的两个事物的相互作用而产生。嵌套引用的事情(仅适用于可变引用)基本上强制闭包返回具有“self”生命周期的内容,但由于这是不可能的,因此会出现错误。 - Lukas Kalbertodt
啊,我明白了!谢谢你的例子! - Quoc-Hao Tran
显示剩余7条评论

9
考虑以下代码:
fn main() {
    let mut y: u32 = 10;

    let ry = &mut y;
    let f = || ry;

    f();
}

它之所以有效,是因为编译器能够推断出 ry 的生命周期:引用 ry 生存在 y 的相同作用域中。
现在,你代码的等效版本:
fn main() {
    let mut y: u32 = 10;

    let f = || {
        let ry = &mut y;
        ry
    };

    f();
}

现在编译器将生命周期分配给 ry,与主体关联的生命周期不再适用于闭包体。同时注意到不可变引用的情况是有效的:
fn main() {
    let mut y: u32 = 10;

    let f = || {
        let ry = &y;
        ry
    };

    f();
}

这是因为&T具有复制语义,而&mut T具有移动语义,请参见 &T/&mut T类型的复制/移动语义文档了解更多细节。

缺失的部分

编译器报告与生命周期相关的错误:

cannot infer an appropriate lifetime for borrow expression due to conflicting requirements

但正如Sven Marnach所指出的那样,还存在一个与错误相关的问题。
cannot move out of borrowed content

但是为什么编译器不会抛出这个错误呢?

简短的回答是,编译器首先执行类型检查,然后执行借用检查。

详细回答

闭包由两部分组成:

  • 闭包的状态:一个包含所有被闭包捕获的变量的结构体。

  • 闭包的逻辑:实现FnOnceFnMutFn特质的函数体。

在这种情况下,闭包的状态是可变引用y,而逻辑是闭包体{ &mut y },它只返回一个可变的引用。

当遇到引用时,Rust控制两个方面:

  1. 状态:如果引用指向有效的内存片段(即生命周期有效的只读部分);

  2. 逻辑:如果内存片段是别名,换句话说,它是否同时被多个引用指向;

注意禁止从借用内容中移动出以避免内存别名。

Rust编译器通过几个阶段执行其工作,这里是简化的工作流程:

.rs input -> AST -> HIR -> HIR postprocessing -> MIR -> HIR postprocessing -> LLVM IR -> binary

编译器报告了一个生命周期问题,因为它首先在“HIR后处理”阶段执行类型检查阶段(其中包括生命周期分析),然后如果成功,则在“MIR后处理”阶段执行借用检查。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接