maveの中身について

前回SIMDベクトルライブラリを作った話をしたが、そこでやっていることについてかいつまんで書いておく。

まず、以下のようなクラスがある。

namespace mave
{
template<typename T, std::size_t R, std::size_t C>
class matrix;

template<typename T, std::size_t N>
using vector = matrix<T, N, 1>;
}

これについて、ごく普通の演算子の他に、以下のような演算子オーバーロードを行った。

namespace mave
{
template<typename T, std::size_t N>
std::tuple<vector<T, N>, vector<T, N>>
operator+(std::tuple<const vector<T, N>&, const vector<T, N>&> lhs,
          std::tuple<const vector<T, N>&, const vector<T, N>&> rhs)

template<typename T, std::size_t N>
std::tuple<vector<T, N>, vector<T, N>>
operator+=(std::tuple<      vector<T, N>&,       vector<T, N>&> lhs,
           std::tuple<const vector<T, N>&, const vector<T, N>&> rhs)
}

そう、あまり見る機会はないが、実はoperator+=もクラス外で定義できる。通常この演算子はメンバへの書き込みを伴うのでクラス内でメンバとして定義する(ことが多い)。しかし今回は、std::tupleにはメンバを追加できないのでfreeな関数として実装している。まあこの場合はコンストラクタで与えられるものが全てなので、メンバ関数として実装する必要がそもそもないのだが。

念頭にあるのは以下のようなユーザーコードである。

const mave::vector<float, 3> v1(/*...*/), v2(/*...*/);
const mave::vector<float, 3> w1(/*...*/), w2(/*...*/);
auto [u1, u2] = std::tie(v1, v2) + std::tie(w1, w2);

まず、std::tieは参照を格納したstd::tupleを作って返す。これは本来、タプルのパターンマッチをライブラリレベルでサポートするための機能だ。普通は以下のように使う。

std::tuple<int, double, std::string> f();

int main()
{
    int i;
    double d;
    std::string s;
    std::tie(i, d, s) = f();
}

これは、多値を返す関数の戻り値を分解しつつ受け取るコードだ。何が起きるかというと、まずstd::tieによってstd::tuple<int&, double&, std::string&>が作られ、そこにstd::tuple<int, double, std::string>が代入される。参照への代入なのでもともとのオブジェクトへの書き込みが行われ、idsのそれぞれが書き換わるという寸法だ。その後std::tieによって作成されたオブジェクト(のprvalue)の寿命は人知れず切れる。

ついでにこれはstd::ignoreを使うことで一部の値を無視することができる。

int i;
double d;
std::tie(i, d, std::ignore) = f();

このstd::ignoreは処理系定義なクラスのインスタンスと定義されている。代入演算子template化してどんな型でも代入できるようにしておき、実際には代入演算子は何もしないようにすれば実装できるだろう。こんな感じで。

struct ignore_t
{
    template<typename T>
    ignore_t& operator=(const T&) noexcept {return *this;}
};

これらの機能は便利だったのだが、std::tieだと初期化にコストのかかるオブジェクトを受け取りたい時に不必要なコストがかかってしまうことがある(先にデフォルトコンストラクタが走り、その後std::tieで受け取るときムーブコンストラクタが走る)。また、そもそもデフォルトコンストラクタを持っていないとstd::tieは使いにくい。適当な引数を与えてあとで代入することで使えないわけではないが、明らかに意味不明だろう。というわけで、結局コア言語側で構造化束縛が導入されることになった。

閑話休題std::tieは参照のタプルを作るので、参照のタプルを受け取って使うことにすればよい、というのは使い方を決めると自動的に決まる。要素数が2個の場合はstd::pairを使えるようにしようかと一瞬思ったが、微妙に複雑になってしまう(結局tupleもサポートしないといけないので転送するだけの関数が増えてしまう)のでやめた。

さて、これで大丈夫だろう、と思ったのだが、少し困ったことが起きた。先ほどの状態でこれは通ったのだが、

const mave::vector<float, 3> v1(/*...*/), v2(/*...*/);
const mave::vector<float, 3> w1(/*...*/), w2(/*...*/);
auto [u1, u2] = std::tie(v1, v2) + std::tie(w1, w2);

以下は通らなかった。

mave::vector<float, 3> v1(/*...*/), v2(/*...*/);
mave::vector<float, 3> w1(/*...*/), w2(/*...*/);
auto [u1, u2] = std::tie(v1, v2) + std::tie(w1, w2);

例えば、以下のようなtemplate関数があったとする。

template<typename T1, typename T2>
void f(std::tuple<T1 const&, T2 const&> tied)
{
    std::cout << std::get<0>(tied) << ", "
              << std::get<1>(tied) << std::endl;
}

これにstd::tuple<int&, double&>などを渡すと、const T1intがマッチしないのでこれは候補にならない。なので、以下は通らない。

int i = 42;
double pi = 3.14;
f(std::tie(i, pi)); // no matching function!

std::tie(T1, T2)std::tuple<T1&, T2&>を返すからだ。だが、もちろんT&const T&に変換できるので、templateをやめると通る。

void f(std::tuple<int const&, double const&> tied)
{
    std::cout << std::get<0>(tied) << ", "
              << std::get<1>(tied) << std::endl;
}

int main()
{
    int i = 42;
    double pi = 3.14;
    f(std::tie(i, pi)); // conversion from int& to int const&
    return 0;
}

これが通ってtemplateにすると通らないのはハマりどころな気がする。実際、書いてエラーを見るまでtemplateでも通ると思っていた。一度、呼び出す関数の解決順序を見直した方が良さそうだ。

さて、これをどうするかだ。多分templateをやめてmave::vector<float, 3><double,3>matrix<float, 3, 3>……とそれぞれに演算子オーバーロードを用意すれば良いのだが、その場合用意していないサイズのmatrixは、作れるが演算だけできないというよくわからないことになる。全てのサイズ、型についてオーバーロードを用意することはできない。そういう問題を回避する為にtemplateがあるのだ。では非templatetemplate演算子を共存させれば、と考えるわけだが、当然template版と非template版の両方がマッチするので、conflictしてオーバーロード解決が失敗する。

解決策として、こうした。

#define MAVE_GENERATE_FORWARDING_BINARY_FUNCTIONS_MATRIX_MATRIX_MATRIX(FUNC_NAME, L_MODIFICATION, R_MODIFICATION)\
  //...
MAVE_GENERATE_FORWARDING_BINARY_FUNCTIONS_MATRIX_MATRIX_MATRIX(operator+, MAVE_EMPTY_ARGUMENT, MAVE_EMPTY_ARGUMENT)
MAVE_GENERATE_FORWARDING_BINARY_FUNCTIONS_MATRIX_MATRIX_MATRIX(operator+, const&             , MAVE_EMPTY_ARGUMENT)
MAVE_GENERATE_FORWARDING_BINARY_FUNCTIONS_MATRIX_MATRIX_MATRIX(operator+, &                  , MAVE_EMPTY_ARGUMENT)
MAVE_GENERATE_FORWARDING_BINARY_FUNCTIONS_MATRIX_MATRIX_MATRIX(operator+, MAVE_EMPTY_ARGUMENT, &)
MAVE_GENERATE_FORWARDING_BINARY_FUNCTIONS_MATRIX_MATRIX_MATRIX(operator+, const&             , &)
MAVE_GENERATE_FORWARDING_BINARY_FUNCTIONS_MATRIX_MATRIX_MATRIX(operator+, &                  , &)
MAVE_GENERATE_FORWARDING_BINARY_FUNCTIONS_MATRIX_MATRIX_MATRIX(operator+, MAVE_EMPTY_ARGUMENT, const&)
MAVE_GENERATE_FORWARDING_BINARY_FUNCTIONS_MATRIX_MATRIX_MATRIX(operator+, &                  , const&)

グワーッ!

渡されうる引数はconst&&(値渡し)の3通りがありえて、2引数取る場合はその組み合わせで増えるので大変なことになる。 これ、何かいい解決法がないだろうか。考えているのだが、浮かばなくて困っている。