読者です 読者をやめる 読者になる 読者になる

コンテナ内の一括計算

C++

を、たまにコードを見たりしている後輩が自分のためのコードの中でよくやっている。要するにこういうことだ。

std::vector<double> v1;
std::vector<double> v2;

// do something...

assert(v1.size() == v2.size());
std::vector<double> v3(v1.size());
for(std::size_t i=0; i<v1.size(); ++i)
    v3.at(i) = v1.at(i) + v2.at(i);

善良なC++erとしてまず教えたのはstd::valarrayの存在である。valarrayは中々に便利だと思うのだが、何故か知名度がない。実際私も使っていない。これで全て解決だと思ったが、このvectorが故あって2重、3重になることがあるらしい。

std::vector<std::vector<double>> v1;
std::vector<std::vector<double>> v2;

// do something...

assert(v1.size() == v2.size());
std::vector<std::vector<double>> v3(v1.size());
for(std::size_t i=0; i<v1.size(); ++i)
{
    assert(v1.at(i).size() == v2.at(i).size());
    v3.at(i) = std::vector<double>(v1.at(i).size());
    for(std::size_t j=0; j<v1.at(i).size(); ++j)
    {
        v3.at(i) = v1.at(i) + v2.at(i);
    }
}

データファイルなどを読み取った時点でvectorのサイズがどの次元においても決定するようなものだった場合、個人的には全要素数の総和個の要素を持つvectorなりvalarrayを用い、必要ならそれを薄くラップしてvalueT operator()(std::size_t i, std::size_t j)などを定義するのが最善であろうと思う。そしてoperator+ではその本体のコンテナを普通に足せば良い。

だがこのクラスは掛け算をすると行列のように振る舞うこともあるらしく、そこまで来るならEigenBoost.uBlasを使うのが良いとは言ったが、そこそこ書き進めているため既にstd::vector<T>用のoperatorを定義しており、今から2重の場合に拡張するとのことだった。しかも、このvectorvalue_typedoubleにもintにもなり得るし、その上それらを演算することもあると言うではないか!

こういう演算子を自分で用意するのは生産性や速度、バグ取り的な観点からは論外であるが、練習・教育目的なら許される。そういう状況からすれば、どうせ拡張するなら一撃で終わらせられるようにしたい。templateを使えるなら、次元数が合っている場合N次元のvectorの演算は容易だ。毎回引数よりも一つ次元の低いvectorに対するインスタンスが形成され、順次適用されて話が済むだろう。

template<typename T>
std::vector<T> operator+(std::vector<T> const& lhs, std::vector<T> const& rhs)
{
    if(lhs.size() != rhs.size())
        throw std::out_of_range("operator+: different size");
    std::vector<T> retval(lhs.size());
    auto iter1 = lhs.cbegin();
    auto iter2 = rhs.cbegin();
    auto iter3 = retval.begin();
    while(iter1 != lhs.cend() && iter2 != rhs.cend() && iter3 != retval.end())
    {
        *iter3 = (*iter1) + (*iter2);
        ++iter1; ++iter2; ++iter3;
    }
    return retval;
}

さて、これだと問題がある。vectorの型引数が等しくないと機能しないのだ。intdoublevectorを足し算することはすでに要件に入っている。となると引数を増やすべきだろう。今回はallocatorのことまでは考えないことにして、T1T2を用意する。

template<typename T1, typename T2>
std::vector<T1> operator+(std::vector<T1> const& lhs, std::vector<T2> const& rhs)
{
    // impl.
}

ところで戻り値の型はこれでいいのだろうか。例えばintdoubleを足してdoubleになって欲しい時、引数の順序に気をつけるというのは正気の沙汰ではない。ではtemplate型引数を増やして、ユーザーが戻り値型を明示的に定義し、各要素の演算はstatic_castするべきだろうか。それが一番柔軟性が高いが、少々使い勝手が犠牲になるし、ユーザーがcast不能な型を持ってきた場合の対応が微妙だ。intdoubleを足してintになって欲しい、というケースは(今回のコードでは)ないと思っていいということもあって、要素同士のoperator+の結果が返るのが妥当な選択だろうということになった。

となると少し面倒だ。例えば、何らかの理由(ジェネリックプログラミングをやっているなど)でadd関数を作るなどというときは、以下のように型を定義すると思う、というか以前実際した。

template<typename T1, typename T2>
constexpr inline auto add(T1&& lhs, T2&& rhs)
    -> decltype(std::forward<T1>(lhs) + std::forward<T2>(rhs))
{
    return std::forward<T1>(lhs) + std::forward<T2>(rhs);
}

最初、何故かoperator+std::vector<T1>は受け取るがT1は受け取らないので、これと同じ方法では演算結果の型を推論できないと思ってしまった。そして考えたのは、上記のようなadd関数の戻り値型を使えばいいということだった。add関数はこちらが明示的にtemplate型引数を与えてやればその戻り値型も確定する。つまり、

template<typename T1, typename T2>
inline std::vector<typename result_type_of<decltype(add<T1, T2>)>::type>
operator+(const std::vector<T1>& lhs, const std::vector<T2>& rhs)
{
    // impl.
}

のようなことができれば良い。そのためにはresult_type_ofが必要で、それは

template<typename F>
struct result_type_of
{
    typedef typename std::function<F>::result_type type;
};

でできる。と、ここまで勢いで書いて思ったのだが、確か標準にstd::result_ofか何かそんなものがあったはずだ。再発明よりは標準ライブラリの方がよかろう、と判断して使ってみる。ところがこいつに関数を渡そうとしてもうまく動かない。

template<typename T1, typename T2>
inline std::vector<typename std::result_of<decltype(add<T1, T2>(T1, T2))>::type> //error!
operator+(const std::vector<T1>& lhs, const std::vector<T2>& rhs)

template<typename T1, typename T2>
inline std::vector<typename std::result_of<add<T1, T2>>::type> //error!
operator+(const std::vector<T1>& lhs, const std::vector<T2>& rhs)

このあたりはまだ少し良くわかっていない。実装はtype_traitsヘッダにあるもののそう簡単にわかるものではなかった。add<T1, T2>(args...)なる式の型を取り出すのに手こずっているということはわかるのだが……。

試しつつstd::result_ofに関して例えばこの辺の記事を読んだ。

もちろん、addを関数オブジェクトにすることによってこの問題は回避できる。素の関数のままにしておきたいなら、std::functionとかでラップすると可能になると思われるのだが(最初にやっていたものに近い)。

と、いうことで一件落着なのだが、よく考えると

template<typename T1, typename T2>
auto operator+(std::vector<T1> const& lhs, std::vector<T2> const& rhs)
    -> std::vector<decltype(lhs.front() + rhs.front())>
{
    // impl.
}

で良いのでは、という考えが去来した。さてこの場合、lhsが空とかそういうことは問題になるのだろうか。この式の評価(?)はコンパイル時の型の解決のタイミングで行われそうで、std::vector::front()の戻り値型がわかっている以上、その処理はそこで完結する。実際に、というと変だが、実行時にそこに書かれた式が評価されることはないので、実行時にlhsが空だったらとかそういう俗世のことは絡んで来なさそうに思える。この式が実行時に評価されることはない……はずだ、と直感的には思うものの、確信が持てない。

実際、 戻り値の型を後置する関数宣言構文 - cpprefjp C++日本語リファレンス にあるコードには、『decltype((*(T*)0)+(*(U*)0))』なるものが出てくるが、これはnullptrC-style castにしか見えない。今風に書けばdecltype(*static_cast<T*>(nullptr) + *static_cast<U*>(nullptr))になるだろう。こんなものを見るとnullptrデリファレンスしている時点でぶっ倒れそうになる。これが合法なのかはまだC++歴1年半・プログラミング歴2年半の若輩である私には分からないので、ちゃんと規格書かC++の人外に尋ねるべきな気もするが、とりあえずコンパイルは通った。偶然動いただけの未定義動作や処理系定義の可能性があるので、何の保証にもならないが。また規格書を読むべき項目のリストが増えた。積み上がり方に対して消化速度が追いつく気配がない。

となれば、decltype(lhs.front() + rhs.front())だろうが何だろうが通りそうな気がするし、それ以前にdecltype(*static_cast<T1*>(nullptr) + *static_cast<T2*>(nullptr))で良いのではという気がしてきた。何とはなしにそのままresult_ofの説明などを読んでいると、見つけた、標準にstd::declvalなるものがあるではないか。説明を読む限り、「実際には評価されない式においてある型の値を取り出すために用いる」らしいので、まさにこのような場合のために用意されたもののように見える。 declval - cpprefjp C++日本語リファレンス

これを使うと、

template<typename T1, typename T2>
std::vector<decltype(std::declval<T1>() + std::declval<T2>())>
operator+(std::vector<T1> const& lhs, std::vector<T2> const& rhs)
{
    // impl.
}

なんとまあスッキリするものか。declvalによってその式が何のためにあるのかがわかりやすくなっている。

ところでこれは前方宣言なしでは2重std::vectorの時にコンパイルエラーを起こしたりしたが、単に前方宣言をつけてやると動いた。

というわけで任意の次元の加算可能な要素を持つvectorに対する加算がかなりスッキリ書けるようになった。これは全部std::transformでいい気がする。