クロージャはフィールドごとにキャプチャする
概要
|| a.x + 1
がa
でなくa.x
だけをキャプチャするようになりました。- これにより、ドロップのタイミングが変わったり、クロージャが
Send
やClone
を実装するかどうかが変わったりします。cargo fix
は、このような違いが起こりうると検出した場合、let _ = &a
のような文を挿入して、クロージャが変数全体をキャプチャするように強制します。
詳細
クロージャは、本体の中で使用しているすべてのものを自動的にキャプチャします。
例えば、|| a + 1
と書くと、周囲のコンテキスト中の a
への参照が自動的にキャプチャされます。
Rust 2018 以前では、クロージャに使われているのが1つのフィールドだけであっても、クロージャは変数全体をキャプチャします。
例えば、 || a.x + 1
は a.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」をクロージャの中に挿入して、強制的に全ての変数がキャプチャされるようにします:
この解析は保守的です。ほとんどの場合、ダミーの let は問題なく消すことができ、消してもプログラムはきちんと動きます。
ワイルドカードパターン
クロージャは本当に読む必要のあるデータだけをキャプチャするようになったので、次のコードは x
をキャプチャしません:
この let _ = x
は何もしません。
なぜなら、_
パターンは右辺を無視し、さらに、x
はメモリ上のある場所(この場合は変数)への参照だからです。
この変更(いくつかの値がキャプチャされなくなること)そのものによってコード変更の提案がなされることはありませんが、後で説明する「ドロップ順序」の変更と組み合わせると、提案がなされる場合もあります。
ちなみに: 似たような式の中には、同じく自動挿入される "ダミーの let" であっても、let _ = &x
のように「何もしない」わけではない文もあります。なぜかというと、右辺(&x
)はメモリ上のある場所を指し示すのではなく、値が評価されるべき式となるからです(その評価結果は捨てられますが)。
ドロップの順序
クロージャが変数 t
の値の所有権を取るとき、その値がドロップされるのは t
がスコープ外に出たときではなく、そのクロージャがドロップされたときになります:
上記のコードの挙動は Rust 2018 と Rust 2021 で同じです。ところが、クロージャが変数の一部の所有権を取るとき、違いが発生します:
ほとんどの場合、ドロップのタイミングが変わってもメモリが解放されるタイミングが変わるだけで、さほど問題にはなりません。
しかし、Drop
の実装に副作用のある(いわゆるデストラクタである)場合、ドロップの順序が変わるとプログラムの意味が変わってしまうかもしれません。
その場合は、コンパイラはダミーの let
を挿入して変数全体がキャプチャされるように提案します。
トレイト実装
何がキャプチャされているかによって、クロージャには自動的に以下のトレイトが実装されます:
Clone
: キャプチャされた値がすべてClone
を実装していた場合。Send
,Sync
,UnwindSafe
などの自動トレイト: キャプチャされた値がすべてそのトレイトを実装していた場合。
Rust 2021 では、キャプチャされる値が変わることによって、クロージャが実装するトレイトも変わることがあります。 先ほどの移行リントは、それぞれのクロージャについて、これまで実装されていた自動トレイトが何であるか、そして移行後もそれらが残るかどうかを調べます。 もし今まで実装されていたトレイトが実装されなくなる場合、「ダミーの let」が挿入されます。
例えば、スレッド間で生ポインタを受け渡しする一般的な方法に、ポインタを構造体でラップし、そのラッパー構造体に自動トレイト Send
/Sync
を実装するというものがあります。
thread::spawn
に渡されるクロージャが使うのは、ラッパー構造体のうち特定の変数だけですが、キャプチャされるのはラッパー構造体全体です。
ラッパー構造体は Send
/Sync
なので、コードは安全であるとみなされ、コンパイルは成功します。
フィールドごとのキャプチャが導入されると、キャプチャ内で使用されているフィールドだけがキャプチャされますが、フィールドの中身はもともと Send
/Sync
でなかったのですから、せっかくラッパーを作っても元の木阿弥です。