闭包中的不相交捕获

摘要

  • || a.x + 1 现在仅捕获 a.x,而不是捕获整个 a
  • 这可能导致某些值在不同时间被丢弃,或影响闭包是否实现诸如 SendClone 的 trait。
    • 如果检测到可能的变更,cargo fix 会插入诸如 let _ = &a 的语句,以强制闭包捕获整个变量。

详情

闭包会自动捕获你在其主体中引用的任何内容。例如,|| a + 1 会自动从周围上下文中捕获对 a 的引用。

在 Rust 2018 及之前版本中,闭包会捕获整个变量,即使闭包只使用了其中一个字段。例如,|| a.x + 1 捕获的是对 a 的引用,而不仅仅是 a.x。完整捕获 a 会阻止对其其他字段的修改或移动,因此像这样的代码无法编译

let a = SomeStruct::new();
drop(a.x); // Move out of one field of the struct
println!("{}", a.y); // Ok: Still use another field of the struct
let c = || println!("{}", a.y); // Error: Tries to capture all of `a`
c();

从 Rust 2021 开始,闭包捕获更加精确。通常它们只会捕获使用的字段(在某些情况下,它们可能会捕获比实际使用的更多内容,详情请参阅 Rust 参考)。因此,上面的例子在 Rust 2021 中可以正常编译。

不相交捕获作为 RFC 2229 的一部分被提出,该 RFC 包含了关于动机的详细信息。

迁移

作为 2021 版本的一部分,增加了一个迁移 lint,rust_2021_incompatible_closure_captures,以帮助将 Rust 2018 代码库自动迁移到 Rust 2021。

为了将您的代码迁移到与 Rust 2021 版本兼容,请运行

cargo fix --edition

下面将探讨如果自动迁移失败或您想更好地理解迁移工作原理时,如何手动迁移代码以使用与 Rust 2021 兼容的闭包捕获。

更改闭包捕获的变量可能导致程序在两种情况下改变行为或无法编译

  • 丢弃顺序的改变,或析构器运行时间的改变(详情);
  • 闭包实现的 trait 的改变(详情)。

无论何时检测到以下任一情况,cargo fix 将在您的闭包中插入一个“dummy let”来强制它捕获整个变量

#![allow(unused)]
fn main() {
let x = (vec![22], vec![23]);
let c = move || {
    // "Dummy let" that forces `x` to be captured in its entirety
    let _ = &x;

    // Otherwise, only `x.0` would be captured here
    println!("{:?}", x.0);
};
}

这是一种保守分析:在许多情况下,这些 dummy lets 可以安全地移除,您的程序也能正常工作。

通配符模式

现在闭包只捕获需要读取的数据,这意味着以下闭包不会捕获 x

#![allow(unused)]
fn main() {
let x = 10;
let c = || {
    let _ = x; // no-op
};

let c = || match x {
    _ => println!("Hello World!")
};
}

这里的 let _ = x 语句是一个空操作(no-op),因为 _ 模式完全忽略右侧的值,而 x 是内存中某个位置(在本例中是一个变量)的引用。

这种变化本身(捕获更少的值)不会触发任何建议,但它可能与下面的“丢弃顺序”变化一起触发建议。

注意:还有其他类似的表达式,例如我们插入的“dummy let”let _ = &x,它们不是空操作。这是因为右侧的值(&x)不是内存中某个位置的引用,而是一个必须先被评估的表达式(其结果随后被丢弃)。

丢弃顺序

当闭包从变量 t 处获取值的所有权时,该值会在闭包被丢弃时被丢弃,而不是在变量 t 超出作用域时被丢弃

#![allow(unused)]
fn main() {
fn move_value<T>(_: T){}
{
    let t = (vec![0], vec![0]);

    {
        let c = || move_value(t); // t is moved here
    } // c is dropped, which drops the tuple `t` as well
} // t goes out of scope here
}

上面的代码在 Rust 2018 和 Rust 2021 中的运行方式相同。然而,在闭包只获取变量部分所有权的情况下,可能会有差异

#![allow(unused)]
fn main() {
fn move_value<T>(_: T){}
{
    let t = (vec![0], vec![0]);

    {
        let c = || {
            // In Rust 2018, captures all of `t`.
            // In Rust 2021, captures only `t.0`
            move_value(t.0);
        };

        // In Rust 2018, `c` (and `t`) are both dropped when we
        // exit this block.
        //
        // In Rust 2021, `c` and `t.0` are both dropped when we
        // exit this block.
    }

// In Rust 2018, the value from `t` has been moved and is
// not dropped.
//
// In Rust 2021, the value from `t.0` has been moved, but `t.1`
// remains, so it will be dropped here.
}
}

在大多数情况下,在不同时间丢弃值只会影响内存释放的时间,并不重要。然而,一些 Drop 实现(即析构函数)具有副作用,在这些情况下更改丢弃顺序可能会改变程序的语义。在这种情况下,编译器会建议插入一个 dummy let 来强制捕获整个变量。

Trait 实现

闭包会根据其捕获的值自动实现以下 trait

在 Rust 2021 中,由于捕获的值不同,这可能会影响闭包实现的 trait。迁移 lint 会测试每个闭包,检查它之前是否会实现某个 trait 以及现在是否仍然实现;如果发现某个 trait 之前实现了但现在不再实现,就会插入“dummy lets”。

例如,一种允许在线程之间传递裸指针的常见方法是将其包装在一个 struct 中,然后为该 wrapper 实现 Send/Sync auto trait。传递给 thread::spawn 的闭包使用 wrapper 中的特定字段,但无论如何都会捕获整个 wrapper。由于 wrapper 是 Send/Sync,代码被认为是安全的,因此可以成功编译。

使用不相交捕获,只捕获闭包中提到的特定字段,而该字段原本没有实现 Send/Sync,从而破坏了 wrapper 的目的。

#![allow(unused)]
fn main() {
use std::thread;

struct Ptr(*mut i32);
unsafe impl Send for Ptr {}


let mut x = 5;
let px = Ptr(&mut x as *mut i32);

let c = thread::spawn(move || {
    unsafe {
        *(px.0) += 10;
    }
}); // Closure captured px.0 which is not Send
}