闭包中的不相交捕获

总结

  • || a.x + 1 现在只捕获 a.x 而不是 a
  • 这可能导致事物在不同时间被丢弃,或者影响闭包是否实现 SendClone 等特征。
    • 如果检测到可能的更改,cargo fix 将插入 let _ = &a 之类的语句,以强制闭包捕获整个变量。

详情

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

在 Rust 2018 及之前的版本中,闭包会捕获整个变量,即使闭包只使用一个字段。例如,|| a.x + 1 会捕获对 a 的引用,而不仅仅是 a.x。捕获整个 a 可以防止 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 版本的一部分,添加了一个迁移 linter,即 rust_2021_incompatible_closure_captures,以帮助将 Rust 2018 代码库自动迁移到 Rust 2021。

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

cargo fix --edition

以下是如何手动迁移代码以使用与 Rust 2021 兼容的闭包捕获的说明,以防自动迁移失败或您想更好地了解迁移的工作原理。

更改闭包捕获的变量会导致程序在以下两种情况下更改行为或停止编译

  • 更改丢弃顺序或析构函数何时运行(详情);
  • 更改闭包实现的特征(详情)。

每当检测到以下任何一种情况时,cargo fix 都会在您的闭包中插入一个“虚拟 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);
};
}

这是一种保守的分析:在许多情况下,可以安全地删除这些虚拟 let,并且您的程序可以正常工作。

通配符模式

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

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

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

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

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

微妙之处: 还有其他类似的表达式,例如我们插入的“虚拟 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 实现(也称为析构函数)具有副作用,在这种情况下更改丢弃顺序可能会改变程序的语义。在这种情况下,编译器会建议插入一个虚拟 let 来强制捕获整个变量。

特征实现

闭包会根据它们捕获的值自动实现以下特征

在 Rust 2021 中,由于捕获的值不同,这可能会影响闭包将实现的特征。迁移 linter 会测试每个闭包,以查看它之前是否以及现在是否仍然实现了给定的特征;如果它们发现某个特征以前已实现但现在不再实现,则会插入“虚拟 let”。

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

使用不相交捕获,只会捕获闭包中提到的特定字段,而该字段最初并非 Send/Sync,这违背了包装器的目的。

#![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
}