クロージャはフィールドごとにキャプチャする

概要

  • || a.x + 1a でなく a.x だけをキャプチャするようになりました。
  • これにより、ドロップのタイミングが変わったり、クロージャが SendClone を実装するかどうかが変わったりします。
    • cargo fix は、このような違いが起こりうると検出した場合、 let _ = &a のような文を挿入して、クロージャが変数全体をキャプチャするように強制します。

詳細

クロージャは、本体の中で使用しているすべてのものを自動的にキャプチャします。 例えば、|| a + 1 と書くと、周囲のコンテキスト中の a への参照が自動的にキャプチャされます。

Rust 2018 以前では、クロージャに使われているのが1つのフィールドだけであっても、クロージャは変数全体をキャプチャします。 例えば、 || a.x + 1a.x への参照だけでなく、a への参照をキャプチャします。 a 全体がキャプチャされると、a の他のフィールドの値を書き換えたりムーブしたりできなくなります。従って以下のようなコードはコンパイルに失敗します:

let a = SomeStruct::new(); drop(a.x); // Move out of one field of the struct // 構造体のフィールドの1つをムーブする println!("{}", a.y); // Ok: Still use another field of the struct // OK: 構造体の他のフィールドは、まだ使える let c = || println!("{}", a.y); // Error: Tries to capture all of `a` // エラー: `a` 全体をキャプチャしようとする c();

Rust 2021 からは、クロージャのキャプチャはより精密になります。 特に、使用されるフィールドだけがキャプチャされるようになります (場合によっては、使用する変数以外にもキャプチャすることもあり得ます。詳細に関しては Rust リファレンスを参照してください)。 したがって、上記のコードは Rust 2021 では問題ありません。

フィールドごとのキャプチャは RFC 2229 の一部として提案されました。この RFC にはより詳しい動機が記載されています。

移行

Rust 2018 のコードベースから Rust 2021 への自動移行の支援のため、2021 エディションには、移行用のリントrust_2021_incompatible_closure_captures が追加されています。

rustfix でコードを Rust 2021 エディションに適合させるためには、次のように実行します。

cargo fix --edition

以下では、クロージャによるキャプチャが出現するコードについて、自動移行が失敗した場合に手動で Rust 2021 に適合するように移行するにはどうすればいいかを考察します。 移行がどのようになされるか知りたい人も以下をお読みください。

クロージャによってキャプチャされる変数が変わると、プログラムの挙動が変わったりコンパイルできなくなったりすることがありますが、その原因は以下の2つです:

  • ドロップの順序や、デストラクタが走るタイミングが変わる場合(詳細
  • クロージャが実装するトレイトが変わる場合(詳細

以下のような状況を検知すると、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 // `x` 全体が強制的にキャプチャされるための「ダミーの let」 let _ = &x; // Otherwise, only `x.0` would be captured here // それがないと、`x.0` だけがここでキャプチャされる 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 // c がドロップされ、そのときにタプル `t` もまたドロップされる } // t goes out of scope here // t はここでスコープを抜ける }

上記のコードの挙動は 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` // Rust 2018 では、`t` 全体がキャプチャされる。 // Rust 2018 では、`t.0` だけがキャプチャされる move_value(t.0); }; // In Rust 2018, `c` (and `t`) are both dropped when we // exit this block. // Rust 2018 では、 `c` (と `t`) の両方が // このブロックを抜けるときにドロップされる。 // // In Rust 2021, `c` and `t.0` are both dropped when we // exit this block. // Rust 2021 では、 `c` と `t.0` の両方が // このブロックを抜けるときにドロップされる。 } // In Rust 2018, the value from `t` has been moved and is // not dropped. // Rust 2018 では、`t` はすでにムーブされており、ここではドロップされない // // In Rust 2021, the value from `t.0` has been moved, but `t.1` // remains, so it will be dropped here. // Rust 2021 では、`t.0` はムーブされているが、 // `t.1` は残っており、ここでドロップされる } }

ほとんどの場合、ドロップのタイミングが変わってもメモリが解放されるタイミングが変わるだけで、さほど問題にはなりません。 しかし、Drop の実装に副作用のある(いわゆるデストラクタである)場合、ドロップの順序が変わるとプログラムの意味が変わってしまうかもしれません。 その場合は、コンパイラはダミーの let を挿入して変数全体がキャプチャされるように提案します。

トレイト実装

何がキャプチャされているかによって、クロージャには自動的に以下のトレイトが実装されます:

Rust 2021 では、キャプチャされる値が変わることによって、クロージャが実装するトレイトも変わることがあります。 先ほどの移行リントは、それぞれのクロージャについて、これまで実装されていた自動トレイトが何であるか、そして移行後もそれらが残るかどうかを調べます。 もし今まで実装されていたトレイトが実装されなくなる場合、「ダミーの 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 // クロージャは px.0 をキャプチャしたが、これは Send ではない }