C++と色々

主にC++やプログラムに関する記事を投稿します。

インデックスアクセス可能なシーケンスの各要素にindex_sequenceを用いて戻り値voidな関数を適用する

素敵なタイトルが思いつきませんでした。

結論

以下のリンクに書いてある内容について触れています

c++ - Pretty-print std::tuple - Stack Overflow

c++ - Iterating on a tuple... again - Stack Overflow

c++ - What does this variadic template code do? - Stack Overflow

std::integer_sequence - cppreference.com

  • インデックスアクセス可能なシーケンスの各要素にindex_sequenceを用いて戻り値voidな関数を適用するにはunpack可能な文脈で関数を呼び出し、カンマ演算子を用いて式の型を変える

本編前の前提

std::index_sequence

C++14からstd::make_index_sequencestd::index_sequenceなどが追加されました。 std::index_sequencestd::size_t型のコンパイル時整数列を表します。std::make_index_sequenceは、例えばstd::make_index_sequence<3UL>{}と書くとstd::index_sequence<0UL, 1UL, 2UL>インスタンスを生成する便利関数です。

index accessable sequenceとvariadic template

C++11まではvariadic templateの各引数に何らかの処理を適用していくには再帰処理を使うしかありませんでした。*1
C++14からはstd::index_sequenceがありますので、コンパイル時に長さの決まっているインデックアクセス可能なシーケンス(std::tuplestd::arrayなど)は、可変長引数関数テンプレートを再帰せず、インデックスアクセスで各要素になんらかの処理を適用することができるようになりました。テンプレートの再帰深度などの問題を緩和できるため、C++14以降で、コンパイル時に長さが決まる添え字アクセス可能なシーケンスの各要素に何か処理を行いたい場合は、再帰関数ではなくstd::index_sequenceを用いた実装を推奨します。

map/transformの実装

例えば、std::arrayの各要素に変換を加えて、新しい配列を作りたいとします。ほかの言語で言うmap、C++標準で言うstd::transformのような関数の実装を考えます。使い方のイメージは以下のようになります:

std::array<int, 3> const arr = {{1, 2, 3}};
std::array<std::string, 3> const arr2 = transform(arr, [](int elem) { return std::to_string(elem) + std::to_string(elem); });
for (auto const& e : arr2) {
    std::cout << e << std::endl;
}

実行結果イメージ

11
22
33

では、実装を考えてみたいと思います。まず便利メソッドとしてmake_arrayというメソッドを作成します。簡易版なので説明は省略します:

template <typename ...Args, std::size_t N = sizeof...(Args)>
std::array<typename std::common_type<Args...>::type, N> make_array(Args&&... args)
{
    return {{std::forward<Args>(args)...}};
}

これを使うと先ほどのtransformの使用イメージ例のコードは以下のようになります:

auto const arr = make_array(1, 2, 3);
auto const arr2 = transform(arr, [](int elem) { return std::to_string(elem) + std::to_string(elem); }); // make_array関係なく元々autoにできる
for (auto const& e : arr2) {
    std::cout << e << std::endl;
}

transformの実装は以下のようになります:

template <typename T, std::size_t N, typename F, std::size_t ...Indices>
auto transform_impl(std::array<T, N> const& arr, F&& f, std::index_sequence<Indices...>)
{
    return make_array(std::forward<F>(f)(arr[Indices])...);
}

template <typename T, std::size_t N, typename F>
auto transform(std::array<T, N> const& arr, F&& f)
{
    return transform_impl(arr, std::forward<F>(f), std::make_index_sequence<N>{});
}

std::make_index_sequenceを使うことで、0からN-1までの連続した整数リストを得ることができます。この整数列のパラメータパックを展開する際に添え字として利用しています。そして、arr[0]から、arr[N-1]までの各要素にfを適用し、その戻り値の配列を作成しています。この際、fの戻り値型はstd::common_typeで求められる型リストに限られます。

本編

for_eachの実装

さて、ここからが本編です。先程の例のように戻り値の配列のようなunpackが許される場所での展開は問題なく行えるのですが、transformと同様の実装でstd::for_eachのような処理(戻り値がvoidで、副作用(例えば標準出力など)を期待した関数など)を適用したい場合、実装に工夫が要ります。なぜならば配列の各要素に関数を適用した結果はvoidであり、これはunpackができる場所(関数の引数や、配列の初期化リストなど)では許されない型だからです。

template <typename T, std::size_t N, typename F, std::size_t ...Indices>
auto for_each_impl(std::array<T, N> const& arr, F&& f, std::index_sequence<Indices...>)
{
    return make_array(std::forward<F>(f)(arr[Indices])...); // fの戻り値がvoidだとエラー
    // std::forward<F>(f)(arr[Indices])...; // こういう風にも書けない
}

template <typename T, std::size_t N, typename F>
auto for_each(std::array<T, N> const& arr, F&& f)
{
    return for_each_impl(arr, std::forward<F>(f), std::make_index_sequence<N>{});
}

工夫するポイントはパラメータパックが展開できる場所で、「戻り値のvoidを潰して代わりに何らかの値を返す式」の値のリストにする、です。何を言っているのか分からないと思いますので、実装を示します:

配列の初期化リストを使う方法

template <typename T, typename F, std::size_t N, std::size_t ...Indices>
void for_each_impl(std::array<T, N> const& arr, F&& f, std::index_sequence<Indices...>)
{
    int unused[] = {0, (static_cast<void>(std::forward<F>(f)(arr[Indices])), 0)...};
    static_cast<void>(unused);
}

template <typename T, std::size_t N, typename F>
void for_each(std::array<T, N> const& arr, F&& f)
{
    for_each_impl(arr, std::forward<F>(f), std::make_index_sequence<N>{});
}

for_eachtransformと同様に、impl関数に転送しています。impl関数の中ではパラメータパックを展開できる場所として配列の初期化リストを使用しています。impl関数中の(static_cast<void>(std::forward<F>(f)(arr[Indices])), 0)に注目します。C++ではカンマ演算子複数の式が並べられた場合、カンマ演算子の一番右にある式が全体の型/値になります。

int a = (nullptr, 3); // 右辺の式全体は型: int, 値: 3

という訳で(static_cast<void>(std::forward<F>(f)(arr[Indices])), 0)は式全体はint型、値0になります。ただし注意があります。operator,オーバーロード可能なので、組み込みのoperator,の場合は上記の説明通りですが、オーバーロードされている場合はこの通りになりません。fの適用をvoidでキャストしているのは式の値を使わないことを明示するためです。ここで(static_cast<void>(std::forward<F>(f)(arr[Indices])), 0)INVOKE_FUNCとします。INVOKE_FUNCは戻り値がintで値は0になります:

int unused[] = {0, INVOKE_FUNC...};

これで、int unused[]の初期化リストが正しいリストのように見えるかと思います。初期化リストの先頭になぜ0が1つあるかというと、仮にint unused[] = {INVOKE_FUNC}と書いた場合、可変長テンプレート引数が0の時、for_each_impl

int unused[] = {};

となってしまうからです。unusedの長さが0になってしまいます。C++では長さ0の配列は許されず、コンパイルエラーになってしまいます。そこで初期化リストの先頭に1つ要素を用意することでint ununsed[]の長さを最低1と保証しているわけです。 最後にunusedvoidにキャストしているのは、コンパイラの未使用の変数警告を抑制するためです。通常ならば使用していない変数がある場合警告を出すべきですが、今回はパラメータパックの展開の都合上登場した変数で、使わずとも意味があるため、この警告は適切ではありません。*2
そのため、コンパイラにこの変数は評価しない、ということを明示するためvoidにキャストしています。

このfor_eachは以下のように使用できます:

auto const arr = make_array(1, 2, 3);
for_each(arr, [](auto const& elem) { std::cout << elem << std::endl; });

関数呼び出しを使う方法

(3/12追記 @Linda_ppさんありがとうございます)

前項では配列の初期化リストを用いた方法を示しましたが、関数呼び出しを利用する方法もあります。for_eachの実装は前項と同じなので、for_each_implの実装を示します:

template <typename T, typename F, std::size_t N, std::size_t ...Indices>
void for_each_impl(std::array<T, N> const& arr, F&& f, std::index_sequence<Indices...>)
{
    [](auto&&...){}((static_cast<void>(std::forward<F>(f)(arr[Indices])), 0)...);
}

こちらの方法ではC++14から入ったジェネリックラムダ式を用いた可変長テンプレート引数をもったラムダ式を定義し、その場で呼び出しています。こちらの方法では未評価変数の登場と使用がなく、関数パラメータのカンマ区切りはoperator,の心配もなくなります((f(arr[Indices], 0)operator,オーバーロードの可能性は残っています。INVOKE_FUNC, INVOKE_FUNCの間のoperator,について触れています))

長くなってしまいましたが、以上です。

*1:標準の範囲で。std::index_sequence相当のものを自作したりしない限りは

*2:それに毎回警告が出るのは煩わしいですし、余計なノイズとなってほかの警告を見落としてしまうリスクもあります