ltmpcの開発中に出会ったTMP注意点10選

C++11テンプレートメタプログラミングによるコンパイル時Cコンパイラ「ltmpc」を開発しています。

ltmpcのはなし - よーる

C++11TMPによるコンパイル時コンパイラltmpcを支えるテンプレートメタプログラミングテクニック - Qiita

テンプレートメタプログラミング(TMP)では、デバッグが非常に困難です。書き間違えの場合は注意深くソースコードを見つめることで除去可能ですが、C++の仕様を知っていないと取り除くことが不可能なものも多く存在します。今回は、ltmpcの開発中に出会った、つまらないミスからコンパイラのバグまで、TMPを行う上での注意点をまとめたいと思います。

なお、エラーの原因等は、C++の規格書もコンパイラソースコードも読まずに推測して書いているので、かなり間違いがあると思います。鵜呑みにしないでください。 もしこれらの真の原因を知っている方がいらっしゃいましたら、指摘してくださると大変助かります。

0. sizeof...を繰り返し使わない

これは厳密にはltmpcの開発より前の出来事ですがどうせなのでまとめます。

  • 重大度:大(コンパイラがメモリを使い果たす)→小(最近のコンパイラだと大丈夫)
  • 解決困難度:中(設計の見直しが必要)→小(最近のコンパイラだと大丈夫)

問題

index_tupleの実装(ltmpc/index_tuple.hpp at master · lpha-z/ltmpc · GitHub)をしている時のことです。 make_index_tupleを実装するとき、index_t... Indicesindex_t Stepを使って以下のように書きます。

Indices..., (Indices+Step)...

しかし、Stepsizeof...(Indices)と同じ値なので、テンプレート引数からStepを除去し、以下のように書き換えました。

Indices..., (Indices+sizeof...(Indices))...

こうしてしまうと

  • `sizeof...(Indices)の計算にはΘ(N)の時間かかる
  • しかも、Indices...のΘ(N)回ある展開の毎回でそれが計算される

というわけで、Θ(N2)の時間がかかってしまいます(たぶん)。しかし、実際にはメモリを使い果たしてコンパイルに失敗する結果になります。なぜなのでしょう?

実行例

解決策

  • Nをテンプレート引数に持つ
  • デフォルトテンプレート引数を使って、一回しか計算が走らないようにする
  • 最近のコンパイラコンパイルする

1. クラス定義の中では、まだ不完全型

  • 重大度:大(clangでコンパイルできない)
  • 解決困難度:大(設計の大幅な見直しが必要)

問題

フリー関数をメンバ関数にすれば、定数倍高速化するのではないかと考えていた時のことです(以下にはあまり関係がないです)。

「パラメータパックへのランダムアクセス」は推論を伴うアップキャストを使っています。これをクラス定義の中で行おうとすると、まだ不完全型なので変換コンストラクタがviableではないというエラーをclangは出します。これを回避するため、不完全型でも使えるはずのポインタを介して行おうとするのですが、推論が絡むとエラーになるという問題があります。clangの基準がよくわからないです。

実行例

#include <type_traits>

struct Base {};

int f( Base );

struct Derived : Base {
    using type = decltype( f( std::declval<Derived>() ) );
};

int main() {
    Derived d;
}
template<class>
struct Base {};

int f( Base<int>* );

struct Derived : Base<int> {
    using type = decltype( f( static_cast<Derived*>(nullptr) ) );
};

int main() {
    Derived d;
}
  • これならgccもclangも文句を言わない
template<class>
struct Base {};

template<class T>
int f( Base<T>* );

struct Derived : Base<int> {
    using type = decltype( f( static_cast<Derived*>(nullptr) ) );
};

int main() {
    Derived d;
}

解決策

参考文献:C++11でメンバ関数を持つ列挙体のようなものを作る - natrium's reminder

2. 前方宣言が必要ないのはいつ?

  • 重大度:大(gccコンパイルできてしまう)→小(gccHEAD8.0.0で直った)
  • 解決困難度:大(大幅な設計の見直しが必要)

問題

関数形式のTMPを行っていると、再帰をするために必要な前方宣言ができない、とかADLが行われる形式なら問題ない、とかそういうことが分かっていなかった時のことです。

素直にADLが行われる形式で書けばよい話ですが、gccだとなぜか、名前修飾されていても同じstruct内の後ろの関数を参照できてしまうというバグがありました。 gccHEAD8.0.0で試してみたらこの問題は修正されているようなので、今後はこのバグに悩まされることはないでしょう。

実行例

struct Begin {};
struct Body {};
struct End {};

template<class...>
struct Tuple {};

template<class... Ts>
Tuple<Ts...> make_tuple( Ts... );

template<class T>
static auto f( T, End )
        -> int;

template<class... Ts>
static auto f( Begin, Ts... args ) 
        -> decltype( f( make_tuple( args... ) ) );

// tuple expand
template<class... Ts>
static auto f( Tuple<Ts...> )
        -> decltype( f( Ts{}... ) );

int main() {
        decltype( f( Begin{}, Body{}, End{} ) ) x;
}

上記のコードは上から二つ目のfで、それより後ろに存在する、三つ目のfを参照する必要があります。名前修飾されていないので、引数であるTupleに関連する名前空間(この場合、global空間を含む)からADLで検索されるため、後ろにあっても問題なく参照できます。

struct Begin {};
struct Body {};
struct End {};

template<class...>
struct Tuple {};

template<class... Ts>
Tuple<Ts...> make_tuple( Ts... );

struct X {
        template<class T>
        static auto f( T, End )
                -> int;

        template<class... Ts>
        static auto f( Begin, Ts... args ) 
                -> decltype( X::f( make_tuple( args... ) ) );

        // tuple expand
        template<class... Ts>
        static auto f( Tuple<Ts...> )
                -> decltype( X::f( Ts{}... ) );
};

int main() {
        decltype( X::f( Begin{}, Body{}, End{} ) ) x;
}

上のコードは、名前修飾されていますから、ADLが発動する余地はありません。しかし、gcc7.2.0まででコンパイルすると、構造体で囲った場合に限ってなぜか後ろの関数を参照してもコンパイルが通ってしまうのです(名前空間で囲んだ場合は問題が発生しません)。

解決策

  • ADLが行われるような関数にする(引数に来るものと同じ名前空間に入れる)
  • 構造体を使う形式にする
    • 暗黙変換が必要なら、暗黙変換を行う関数と、それを受け取る構造体に分離する→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
      • ただし、分離して書くと非常に読みづらいため使わない方がいいと思います
  • 擬似的な前方宣言を行う→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
    • デフォルトテンプレート引数を使って、無理やり依存名にして後方参照できる関数との相互再帰を行う方法です。おすすめです

3. 完全に覆い隠す関数

  • 重大度:大(gccコンパイルできてしまう)
  • 解決困難度:小(部分的な修正で解決可能)

問題

パーサーを書いていて、同じコードが増えてきたので抽象化しようとまとめていた時のことです。

基底クラスと同じ名前の関数を作る場合、usingで派生クラスにも持ってこないとオーバーロードではなく、オーバーライドのようになります。まず、これに注意する必要があります。

また、SFINAEを使った振り分けを行う場合、同じ形の関数を二個書くことがありますが、常に片方がSFINAEの文脈でのエラーになるなら、問題なく動作するはずです。

しかし、以下の条件を満たす場合、gccではコンパイルできるのにclangではコンパイルができません。どちらが正しいのかはよくわかりませんが、両方SFINAE的エラーが発生しなかった時に「あいまい」とは言われない(派生クラスの方が優先される)ことを考えると、clangが正しそうな気もします。

  • 派生クラスで、基底クラスと完全に同じ形の関数を定義する
  • 基底クラスの関数は、usingで派生クラスに持ってくる
  • 派生クラスの方の関数は、SFINAEの文脈でエラーになる
  • つまり、持ってきた基底クラスの方の関数が選ばれてほしい

実行例

#include <type_traits>

struct Base {
    template<class... Args>
    static char f( Args... );
};

struct Derived : Base {
    using Base::f;

    template<class... Args>
    static auto f( Args... args ) -> decltype( Unknown( args... ) );
};

int main() {
    using Type = decltype( Derived::f( 1 ) );
    static_assert( std::is_same<Type, char>::value, "" );
    Type a;
}

解決策

  • 完全に同じ形の関数を書かなければよいので、たとえば以下のように変更すればよいです。
// Before
template<class... Args>
auto func( Args... ) -> ...;

// After
template<class T, class... Args>
auto func( T, Args... ) -> ...;

引数が0個の場合を許容する場合は、手間がかかりますが引数0の関数を別に作成すればよいです。 他の形の場合でも、いろいろやり方はあると思います。暗黙変換が必要ない場合は、Tで受け取ってtypename std::enable_if<std::is_same<T, int>::value, std::nullptr_t>::type = nullptrなどとSFINAEで制限すると実質的に同じ引数を持つ関数を定義できます。暗黙変換が必要な場合は、一般的な解法はないように思われますが、暗黙変換を担う関数と、オーバーロード振り分けを行う関数の二段に分離するなどの方法が考えられます。

4. 可変長テンプレートに引数を渡し忘れる

  • 重大度:小(ただのミス)

問題

可変長テンプレートに引数を渡し忘れると、それ自体は文法違反ではないので、SFINAEの文脈でそれをやってしまうとエラーにならずに発見が困難になりました。

UnaryTokenRule<lr_unary_increment, '+','+'>

と書くべきところを、

UnaryTokenRule<lr_unary_increment>

と書いてしまっていたのでした。

5. elipsisの優先順位

  • 重大度:小(ただのミス)
  • 解決困難度:小(容易に解決可能)

問題

関数の型チェックのコードを書いていた時のことです。

次のコードは、オーバーロード解決があいまいになります。なんで優先順位が同じなのでしょうか……?

int f();
char f(...);

int main() {
    decltype( f() ) a;
}

解決策

  • C++11以降が使えるなら、可変長テンプレートを使うのが"現代流"です。優先順位も意図通りになります。

6. SFINAEではない

  • 重大度:小(ただのミス)
  • 解決困難度:小(容易に解決可能)

問題

型宣言をパースするコードを書いている時のことです。C言語の型宣言は複雑で、構文木の上の方に出現するのは、使った時に得られる型です。重要なのはその変数の使い方(これはポインタなのか配列なのか関数なのか?)なのですが、その情報は構文木の最下層に出現します。そのため、再帰的に情報を収集する必要がありました。

再帰的に情報を集めるのを以下のように行い、SFINAEの文脈で使おうと思うのは間違いです。

template<class T>
struct Traits<Wrapper<T>> { using type = typename Traits<T>::type; };

// SFINAEを行う
template<class T, typename Traits<T>::type = nullptr>
auto f( T ) -> ...;

実行例

解決策

7. 非型パラメータの型の推論

問題

これもパーサーを書いていて、同じコードが増えてきたので抽象化しようとまとめていた時のことです。

テンプレートテンプレートパラメターの中に非型テンプレート引数が含まれるとき、その型を取得したいという状況がありました。というか言葉で伝えるのには無理があるのですが、以下のようなことがしたいということです。

template<class T, template<T>class U, T N>
void f( U<N> ) { std::cout << N << std::endl; }

残念ながらTを推論することができません。しかし、最近のコンパイラ-std=c++1z以降を指定するとコンパイルできるのです。そんな新機能の追加ありましたっけ……?もしかすると、非型テンプレートとしてauto型が使えるようになったことと関係があるのでしょうか……?

実行例

解決策

  • gcc7.1.0以降か、clang4.0.0以降で、-std=c++1z以降を指定する

8. オーバーロード解決時に、不要なテンプレートがインスタンス化される

  • 重大度:大(未規定の挙動)
  • 解決困難度:大(設計の大幅な見直しが必要)

問題

コンパイラに意図しない入力が入ってきたとき、「マッチするオーバーロード関数がありません」で終わらせてもいいのですが、static_assertでメッセージを出力しつつ落とすほうが親切ですし、デバッグも楽です。そのため、以下のようにマッチする関数がない場合はなるべく落とすようにしました。

template<std::nullptr_t N = nullptr>
struct Error { static_assert( N != nullptr, "error!" ); };

auto f( int ) -> int;
auto f( ... ) -> Error<>;

ここで、Errorクラスをわざわざテンプレートにしているのは、テンプレートのインスタンス化を遅らせないと、常にstatic_assertがかかってしまうためです。static_assertの条件式を依存式にする必要があります。

それはいいのですが、なんとこの手法は未既定の挙動を含んでいるというのです!オーバーロード解決時、そのオーバーロードを解決するのに必要ないテンプレートのインスタンス化は、してもしなくてもいいことになっています。そのため、上記のコードはたとえ上のオーバーロード関数が呼ばれる状況であっても、Error<>インスタンス化する可能性があります。

幸いgccやclangはそのような動作をしません。gccやclangの挙動を観察してみたところ、「上記のような、関数の返り型等、不完全型でよい文脈ではテンプレートのインスタンス化をしない」、「完全型が要求される文脈(関数の実引数等)で使われたらテンプレートのインスタンス化を行う」という法則に従っていると思われます。

実行例

template<bool B = false>
struct Error { static_assert( B, "error!" ); };

template<class T>
char wrap( T );

template<class T>
auto raise( T ) -> Error<>;

int f( int );

template<class T>
auto f( T ) -> decltype( wrap( raise( 0 ) ) );

int main() {
    decltype( f(0) ) obj;
}

解決策

  • オーバーロード解決の時だけ問題になるので、オーバーロードの振り分けを使うのではなく、構造体の部分特殊化で振り分けを行うと解決します
  • 未規定の挙動ではありますが、gccやclangの評価戦略の範囲においては、返り型のdecltype内でネストした関数呼び出しをしない場合問題になりません

Thanks!

この原因を究明されたのは、nus氏でした。ありがとうございました!!

twitter.com

9. 可変長引数テンプレートのマッチングバグ

  • 重大度:大(clangでコンパイルできない)
  • 解決困難度:中(設計の見直しが必要)

問題

この問題は8. の直後に発生しました。というか、8. が解決した後に発生したから原因を突き止められたものの、同時に発生していたら解決は不可能だったのではないでしょうか……。

さて、先ほどの8. の問題は、すべての関数テンプレートを実体化してみるという点に問題がありました。これは、優先順位とか以前に、完全に合わない形の関数テンプレートまでも実体化しているのです。そのせいで、以下のようなコードもエラーになります。

int f( int );
template<class T, class U>
auto f( T, U ) -> decltype( wrap( raise( 0 ) ) );

f( 0 ); // static_assertに引っかかる

さて、テンプレート引数のTUは推論できるはずがありませんから、これに触ることでSFINAE的エラーを誘発できるはずです。

int f( int );
template<class T, class U>
auto f( T, U ) -> decltype( wrap( raise( T{} ) ) );

f( 0 ); // 問題なく動作する

ここまでは、常識的な挙動です。しかし、関数の引数ではなくテンプレートの引数でこれを行うと、clangはわけのわからないことを言い出します。

template<class T, bool B = false>
struct Error { static_assert( B, "error!" ); };

template<class... Ts>
struct Tuple {};

template<class T>
char wrap( T );

template<class T>
auto raise() -> Error<T>;

template<class T, class U>
int f( Tuple<T, U> );

template<class T>
auto f( Tuple<T> ) -> decltype( wrap( raise<T>() ) );

int main() {
    decltype( f( Tuple<float, double>{} ) ) obj;
}

これを実行すると、clangでのみstatic_assertに引っかかりエラーになります。どうやら、Error<float, nullptr>インスタンス化しているようです。Tuple<float, double>Tuple<T>にマッチさせてTを推論することはできないはずです。勝手に同じ位置の物を持ってきてしまっているのでしょうか。

実行例

解決策

  • 基本的に8. の問題と同時に発生するので、同様の対処法が有効です

さいごに

少しでもバグに直面したテンプレートメタプログラマのお役にたてれば幸いです。 ただ、バグへの対処方法はまとめましたが、根本的な原因はわかっていません。 バグの原因がわかる方がいらっしゃいましたら、ぜひ教えてください……。