yohhoyの日記

技術的メモをしていきたい日記

C++コルーチン送出例外のハンドリング戦略

C++20コルーチンからの例外送出ハンドリングに関するメモ。C++コルーチンライブラリの設計者向け。

C++コルーチン言語仕様では、コルーチン送出例外ハンドリングのカスタマイズポイントとしてpromise_type::unhandled_exception関数を規定する。プログラマが記述したコルーチン本体処理に対して、C++コンパイラは下記のようなコード展開を行う(一部は簡略化)。

/* コルーチンの展開後コード */ {
  promise_type _promise;
  try {
    co_await _promise.initial_suspend();
    /* コルーチン本体処理 */
  } catch (...) {
    _promise.unhandled_exception();  // ★
  }
  co_await _promise.final_suspend();
}

C++コールチンライブラリ設計者の選択肢としては、3種類の例外ハンドリング戦略が考えられる。

  • [A] コルーチンの呼出元(caller)/再開元(resumer)にそのまま例外伝搬させる。ジェネレータ(generator)などの同期型コルーチン向け。
  • [B] コルーチンの戻り値型オブジェクトに例外保持しておき、値の読み取りタイミングで例外再スローする。並行タスクや非同期I/Oなどの非同期コルーチン向け。
  • [C] 例外送出=プログラム異常終了(std::terminate)。

それぞれの例外ハンドリングにおけるpromise_type::unhandled_exception実装イメージは下記の通り。

// [A] 再スロー
void unhandled_exception()
{
  throw;  // 現在の例外を再スロー
  // コルーチン処理と呼出元は同一スレッドの前提。
  // コルーチンの呼出元(caller)/再開元(resumer)に
  // コルーチン内部から送出された例外が伝搬する。
}

// [B] 例外保持
void unhandled_exception()
{
  ep_ = std::current_exception();
  // コルーチン処理と値の取出操作は別スレッドの可能性あり。
  // メンバ変数 std::exception_ptr ep_; に格納しておき、
  // コルーチン計算結果の取出操作メンバ関数の実装にて
  // 値を返す代わりに std::rethrow_exception(ep_); を実行。
}

// [C] プログラム停止
void unhandled_exception()
{
  std::terminate();
}

選択肢[A]の動作は、C++20統合前のC++コルーチン拡張Coroutine TSに対するP0664R6 Issue#25にて明言されている。

25. Allow unhandled exception escape the user-defined body of the coroutine and give it well defined semantics

The proposed resolution is to eliminate the undefined behavior in the following manner:

  • Allow an exception to escape p.unhandled_exception() and, in that case, consider the coroutine to be at the final suspend point. Reminder: when a coroutine is at the final suspend point, the coroutine can only be destroyed and a call to member function done() of the coroutine handle associated with that coroutine returns true.
  • Eliminate possibility of an exception being thrown from evaluation of an expression co_await p.final_suspend() by stating that final_suspend member function of the coroutine promise and await_resume, await_ready, and await_suspend members of the object returned from final_suspend shall have non-throwing exception specification.

This resolution allows generator implementations to define unhandled_exception as follows:

void unhandled_exception() { throw; } 

With this implementation, if a user of the generator pulls the next value, and during computation of the next value an exception will occur in the user authored body it will be propagate back to the user and the coroutine will be put into a final suspend state and ready to be destroyed when generator destructors is run.

P0664R6 C++ Coroutine TS Issues

関連URL

コルーチン×ラムダ式キャプチャ=鼻から悪魔

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 immediately co_awaited then the task will be left with a dangling reference when the temporary lambda goes out of scope.

Use the folly::coro::co_invoke() helper when immediately invoking a lambda coroutine to keep the lambda alive as long as the Task.

https://github.com/facebook/folly/blob/main/folly/experimental/coro/README.md#lambdas

関連URL

*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

1 << 31 == ?

C++言語における符号付き整数型の左ビットシフト<<と符号ビットの関係について。

まとめ:

  • 2の補数表現が保証されたC++20現在、左ビットシフト<<は論理左シフト(logical left shift)が保証される。
  • おまけ:C++20現在、右ビットシフト>>は算術右シフト(arithmetic right shift)が保証される。
// 前提:int型==32bit幅
constexpr int x = 1 << 31;
// x == -2147483648 (== -2^{31})

C++11時点では符号反転を伴う左ビットシフト演算は未定義動作(undefined behavior)であったがnumeric_limits<T>:::min()の実装がコア定数式(core constant expression)でなくなるという問題が発覚し、C++14策定段階のCWG DR 1457にて処理系定義(implementation-defined)の振る舞いへと修正された。*1

C++20 7.6.7/p1-3より引用。

1 The shift operators << and >> group left-to-right.
 shift-expression:
   additive-expression
   shift-expression << additive-expression
   shift-expression >> additive-expression
The operands shall be of integral or unscoped enumeration type and integral promotions are performed. The type of the result is that of the promoted left operand. The behavior is undefined if the right operand is negative, or greater than or equal to the width of the promoted left operand.
2 The value of E1 << E2 is the unique value congruent to E1 × 2E2 modulo 2N, where N is the width of the type of the result. [Note: E1 is left-shifted E2 bit positions; vacated bits are zero-filled. --end note]
3. The value of E1 >> E2 is E1/2E2, rounded down. [Note: E1 is right-shifted E2 bit positions. Right-shift on signed integral types is an arithmetic right shift, which performs sign-extension. --end note]

関連URL

*1:C++17以前は、符号付き整数型が2の補数表現とは保証されておらず、その内部表現は処理系定義となっていた。

Preconditions: false is true.

C++2b(C++23)向けに提案されているコード不到達表明std::unreachable関数の前提条件。
2022-02-09追記:2022年2月会合にて(PDF)P0627R6採択され、C++2b標準<utility>ヘッダにstd::unreachable関数が追加される。

2022-07-25追記:次世代C言語標準でもC2x(C23)向け提案(PDF)N2826が採択済み。<stddef.h>標準ヘッダの関数マクロ(function-like macro)unreachable()として導入される。

false is true」は常に成り立たない(恒偽命題)ため、unreachable関数呼び出しは必ず未定義動作(undefined behavior)を引き起こす。つまり…どういうことだってばよ?

C++コンパイラは同関数呼び出しコード箇所には到達しないと仮定して最適化を行ってもよい、という規定となっている。提案文書(PDF) P0627R6 7. Proposed Wording より該当箇所を引用(下線部は強調)。

[[noreturn]] void unreachable();

1 Preconditions: false is true. [ Note: This precondition cannot be satisfied, thus the behavior of calling unreachable is undefined. -- end note]
2 [Example:

int f(int x) {
  switch (x) {
  case 0:
  case 1:
    return x;
  default:
    std::unreachable();
  }
}

int a = f(1);  // OK; a has value 1
int b = f(3);  // undefined behavior

-- end example]

なお初期の提案文書では素直に自然言語で記述されていた。(PDF) P0627R3 7. Proposed Wordingより該当箇所を引用。

2 Effects: The behavior of calling unreachable is undefined.

関連URL

2進数フォーマット出力 in 標準C

プログラミング言語Cの次期C2x(C23)標準ライブラリprintf関数ファミリでは、変換指定子bによる2進数フォーマット出力がサポートされる。同時にscanf関数ファミリやstrtoT関数では0bプレフィクス付き文字列入力がサポートされる。*1

// C2x
#include <stdio.h>

printf("%08b", 42u);
// "00101010"

C++言語

C++20で追加されたstd::formatはバイナリ書式化指定子bをサポート済み。

// C++20
#include <format>
#include <iostream>

std::cout << std::format("{:08b}", 42u);
// "00101010"

C++17以前はstd::bitsetで手軽に済ませるか、書式化処理を自作する必要あり。

#include <bitset>
std::cout << std::bitset<8>(42u);

関連URL

*1:提案文書N2630によれば、C2xでの2進数リテラルのサポート(→id:yohhoy:20210228)に合わせた機能追加とのこと。もっと早くからサポートしてくれ。

自己再帰するラムダ式 @ C++23

次期C++2b(C++23)言語仕様に追加される Deducing this により、自己再帰するラムダ式を自然に記述できるようになる。

ラムダ式の第1引数型this autoで宣言されるselfは explicit object parameter と呼ばれ、ここではラムダ式自身のクロージャ型(closure type)へ推論される。パラメータ名は任意だが、PythonやRustなど他言語の流儀にならった方が無難。

// C++2b(P0847R7より引用)
auto fact = [](this auto self, int n) -> int {
  return (n <= 1) ? 1 : n * self(n-1);
};
std::cout << fact(5);  // "120"

C++14/17/20

C++14/17/20ではジェネリックラムダ式を利用してラムダ式の自己再帰を実装する*1。呼び出し時にはクロージャオブジェクト自身を追加で指定(下記コードでは第1引数)する必要がある。

auto fact = [](auto self, int n) -> int {
  return (n <= 1) ? 1 : n * self(self, n-1);
};
std::cout << fact(fact, 5);  // "120"

不動点コンビネータfix*2を利用すると、ラムダ式への追加の引数指定が不要になる。そこまでして実装したいかはともかく。

template<typename F>
struct fix {
  F f;
  template<typename... Args>
  decltype(auto) operator()(Args&&... args) const&
    { return f(std::ref(*this), std::forward<Args>(args)...); }
};
// テンプレート推論ガイド(deduction guide)
template<typename F> fix(F) -> fix<F>;  // C++20以降は明示不要

auto fact = fix{[](auto self, int n) -> int {
  return (n <= 1) ? 1 : n * self(n-1);
}};
std::cout << fact(5);  // "120"

// C++14では型推論のためヘルパ関数 make_fix を経由する
// template<typename F> fix<F> make_fix(F f) { return {f}; }
// auto fact = make_fix([](auto self, int n) -> int { ... });

C++11

ラムダ式が導入されたC++11時点ではstd::function利用しか実現手段がない。動的メモリ確保(→id:yohhoy:20201111)や型消去(type erasure)による実行時オーバーヘッドが生じるため、実用性のほどは微妙か。素直に関数定義したほうがマシ。

#include <functional>

std::function<int(int)> fact = [&](int n) -> int {
  return (n <= 1) ? 1 : n * fact(n-1);
};
std::cout << fact(5);  // "120"

関連URL

OpenMP 最長コンストラクト

OpenMPの最長コンストラク*1 Target Teams Distribute Parallel Worksharing-Loop SIMD Construct。

// OpenMP 4.0 for C/C++
#pragma omp target teams distribute parallel for simd
  /*for-loops*/

関連URL

*1:オプショナルな指示節(clause)を除いたプラグマ指定キーワード個数で計上。