C++11テンプレートメタプログラミングによるコンパイル時Cコンパイラ「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... Indices
とindex_t Step
を使って以下のように書きます。
Indices..., (Indices+Step)...
しかし、Step
はsizeof...(Indices)
と同じ値なので、テンプレート引数からStep
を除去し、以下のように書き換えました。
Indices..., (Indices+sizeof...(Indices))...
こうしてしまうと
- `sizeof...(Indices)の計算にはΘ(N)の時間かかる
- しかも、
Indices...
のΘ(N)回ある展開の毎回でそれが計算される
というわけで、Θ(N2)の時間がかかってしまいます(たぶん)。しかし、実際にはメモリを使い果たしてコンパイルに失敗する結果になります。なぜなのでしょう?
実行例
- gcc5.2.0まで、メモリ不足になる→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
- clang3.9.1まで、エラーになる→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
解決策
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; }
- gccはこれに文句を言わないが、clang ではエラーになる→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
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; }
- 推論を伴う場合、clangはエラーになってしまう→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
解決策
- 実装クラスを分離して確定させてから、それを継承するクラスを外部に公開する→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
参考文献:C++11でメンバ関数を持つ列挙体のようなものを作る - natrium's reminder
2. 前方宣言が必要ないのはいつ?
問題
関数形式の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まででコンパイルすると、構造体で囲った場合に限ってなぜか後ろの関数を参照してもコンパイルが通ってしまうのです(名前空間で囲んだ場合は問題が発生しません)。
- gccHEAD8.0.0ではバグが修正されました→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
解決策
- ADLが行われるような関数にする(引数に来るものと同じ名前空間に入れる)
- 構造体を使う形式にする
- 暗黙変換が必要なら、暗黙変換を行う関数と、それを受け取る構造体に分離する→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
- ただし、分離して書くと非常に読みづらいため使わない方がいいと思います
- 暗黙変換が必要なら、暗黙変換を行う関数と、それを受け取る構造体に分離する→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
- 擬似的な前方宣言を行う→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
- デフォルトテンプレート引数を使って、無理やり依存名にして後方参照できる関数との相互再帰を行う方法です。おすすめです
3. 完全に覆い隠す関数
問題
パーサーを書いていて、同じコードが増えてきたので抽象化しようとまとめていた時のことです。
基底クラスと同じ名前の関数を作る場合、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; }
- clangでエラーになる→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
解決策
- 完全に同じ形の関数を書かなければよいので、たとえば以下のように変更すればよいです。
// 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 ) -> ...;
実行例
- SFINAEではなく、ふつうのエラーになる→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
解決策
- こういうのは、継承を使って書くのが正しいです→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
7. 非型パラメータの型の推論
問題
これもパーサーを書いていて、同じコードが増えてきたので抽象化しようとまとめていた時のことです。
テンプレートテンプレートパラメターの中に非型テンプレート引数が含まれるとき、その型を取得したいという状況がありました。というか言葉で伝えるのには無理があるのですが、以下のようなことがしたいということです。
template<class T, template<T>class U, T N> void f( U<N> ) { std::cout << N << std::endl; }
残念ながらT
を推論することができません。しかし、最近のコンパイラで-std=c++1z
以降を指定するとコンパイルできるのです。そんな新機能の追加ありましたっけ……?もしかすると、非型テンプレートとしてauto
型が使えるようになったことと関係があるのでしょうか……?
実行例
- 古いコンパイラではエラーになる→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
解決策
- 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; }
static_assert
が発生しないことを意図しているが、引っかかってしまう→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ- 上記の例で、
wrap
で囲むのを外すと、今度はstatic_assert
に引っかからなくなる
解決策
- オーバーロード解決の時だけ問題になるので、オーバーロードの振り分けを使うのではなく、構造体の部分特殊化で振り分けを行うと解決します
- 未規定の挙動ではありますが、gccやclangの評価戦略の範囲においては、返り型の
decltype
内でネストした関数呼び出しをしない場合問題になりません
Thanks!
この原因を究明されたのは、nus氏でした。ありがとうございました!!
twitter.comn4659の17.7.1に以下の例と共に
— nus (@nus_miz) 2017年12月21日
"If the function selected by overload resolution can be determined without instantiating a class template definition, it is unspecified whether that instantiation actually takes place."
とありましたhttps://t.co/5wvUDMBVY4
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に引っかかる
さて、テンプレート引数のT
やU
は推論できるはずがありませんから、これに触ることで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
を推論することはできないはずです。勝手に同じ位置の物を持ってきてしまっているのでしょうか。
実行例
- clangで
static_assert
に引っかかってしまう→[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
解決策
- 基本的に8. の問題と同時に発生するので、同様の対処法が有効です
さいごに
少しでもバグに直面したテンプレートメタプログラマのお役にたてれば幸いです。 ただ、バグへの対処方法はまとめましたが、根本的な原因はわかっていません。 バグの原因がわかる方がいらっしゃいましたら、ぜひ教えてください……。