C++20 コルーチンとキャプチャありラムダ式の組合せは、キャプチャ変数の生存期間(lifetime)切れによる未定義動作(undefined behavior)を引き起こすリスクが高く、原則として併用すべきでない。
要約:ラムダ式でキャプチャした変数はコルーチン中断時にコルーチンフレーム*1に退避されない。キャプチャ変数はコルーチンフレームに退避されない。大事なことなので二回言いました。
C++20現在は標準コルーチンライブラリを提供しないため*2、ここでは次期C++2b(C++23)向け提案(PDF)P2168R3std::generator<T>
を用いたコード例示を行う。
2022-08-01追記:C++2b向けに後継提案文書(PDF)P2502R2が採択され、<generator>ヘッダとstd::generator
導入が決定している。id:yohhoy:20220801 参照。
#include <iostream> #include <generator> // P2168R3 // NG: キャプチャありコルーチンラムダ式 void f(int n) { // (1) co_yield式により クロージャ型::operator() はコルーチンとなる。 // またラムダ式内部からは「関数fのローカル変数n」へは直接アクセスできず、 // 「クロージャオブジェクトのメンバ変数として保持されたn」に自動変換される。 auto gen = [n]() -> std::generator<int> { // (3) std::generatorのpromise_type定義よりコルーチン本体実行前の // 初期サスペンドポイント(initial_suspend)にて実行を中断する。 // (6) 「メンバ変数として保持されたn」は生存期間が終了しているため、 // 下記for文において変数名nへのアクセスが未定義動作を引き起こす! for (int i = 0; i < n; i++) { co_yield i; } }(); // (2) IILE(Immediately-Invoked Lambda Expression)イディオムを用いて、 // ローカル変数nをキャプチャしたクロージャオブジェクトを即時評価する。 // (4) この時点でラムダ式によるクロージャオブジェクトは破棄済みとなり、 // 同メンバ変数として保持されたnの生存期間もまた終了している。 // (5) 範囲for文によりgen.begin()が呼び出されてコルーチン再開する。 for (int i: gen) { std::cout << i; } } // OK: キャプチャ無しコルーチンラムダ式 void g(int n) { // (1) 変数キャプチャでなはく引数ありラムダ式として定義する。 // ラムダ式内部では「コルーチンフレームで保持されるn」へアクセスする。 auto gen = [](int n) -> std::generator<int> { // (3) 初期サスペンドポイント(initial_suspend)にて中断される。 // 引数nはコピーされてコルーチンフレーム上に格納される。 // (6) コルーチンフレームで保持されるnへアクセスする。 for (int i = 0; i < n; i++) { co_yield i; } }(n); // (2) IILEイディオムにより変数nを実引数として渡す。 // (4) この時点でラムダ式によるクロージャオブジェクトは破棄済み。 // (5) 範囲for文によりgen.begin()が呼び出されてコルーチン再開する。 for (int i: gen) { std::cout << i; } }
ラムダ式キャプチャに関するこの振る舞いは、キャプチャ動作はラムダ式によるクロージャオブジェクト生成時に1回だけである一方、コルーチンラムダ式(クロージャ型のoperator()
)は複数回呼び出されうる*3という差異に起因する。例えば Move-only 型を前者でムーブキャプチャした場合、後者で同オブジェクトをコルーチンフレームへとコピー退避することができない。*4
メモ:次期C++2b(C++23)で導入されるP0847R7 "Deducing this" 機能(→id:yohhoy:20211025)を使っても、本問題は未解決もしくは仕様規定が曖昧に思える。*5
2022-10-05追記:根本的な解決策ではないがReddit関連スレッドにて、[n](this auto self){ ... }
ラムダ式中でn
の代わりにself.n
と記述する方法が紹介されている。
例えば facebook/folly C++ライブラリでは、キャプチャありコルーチンラムダ式を安全に呼び出すためのfolly::coro::co_invoke
ヘルパ関数を実験的に提供する。*6
Lambdas
You can implement a lambda coroutine however you need to explicitly specify a return type - the compiler is not yet able to deduce the return type of a coroutine from the body.IMPORTANT: You need to be very careful about the lifetimes of temporary lambda objects. Invoking a lambda coroutine returns a
folly::coro::Task
that captures a reference to the lambda and so if the returned Task is not immediatelyco_await
ed then the task will be left with a dangling reference when the temporary lambda goes out of scope.Use the
https://github.com/facebook/folly/blob/main/folly/experimental/coro/README.md#lambdasfolly::coro::co_invoke()
helper when immediately invoking a lambda coroutine to keep the lambda alive as long as theTask
.
関連URL
- C++ Core Guidelines, CP.51: Do not use capturing lambdas that are coroutines
- c++ - Lambda lifetime explanation for C++20 coroutines - Stack Overflow
- C++2a Coroutines and dangling references – Arthur O'Dwyer – Stuff mostly about C++
- How do I get the effect of C#'s async void in a C++ coroutine? Part 1: Why does the obvious solution crash? - The Old New Thing
- A capturing lambda can be a coroutine, but you have to save your captures while you still can - The Old New Thing
- cpprefjp: コルーチン
*1:C++言語仕様上はコルーチンフレーム(coroutine frame)ではなく coroutine state が正式名称。本文中ではより一般的な “コルーチンフレーム” を用いた。
*2:C++20時点では言語仕様へのコルーチン導入と、コルーチンライブラリ作成者向けの低レイヤ・ライブラリのみが提供される。
*3:本文中コードはIILEイディオムによりコルーチンは1回しか呼び出されないが、C++構文上で1回のみ呼び出し/複数回呼び出しの可能性を判別できない。例えばRust言語では型システムにより両者を判別可能。
*4:https://www.reddit.com/r/cpp/comments/qmes0e/a_capturing_lambda_can_be_a_coroutine_but_you/hj9b352/
*5:https://twitter.com/yohhoy/status/1458091760777900037
*6:https://github.com/facebook/folly/blob/main/folly/experimental/coro/Invoke.h