C++/テンプレートメタプログラミング
テンプレートメタプログラミングの概要
[編集]テンプレートメタプログラミングとは
[編集]テンプレートメタプログラミングとは、C++のテンプレートの機能を使って、コンパイル時にプログラムを実行するテクニックのことです。通常のプログラムはコンパイル時に機械語に変換されるだけですが、テンプレートメタプログラミングではコンパイル時に様々な計算や処理を行うことができます。
例えば、次のようなコードを考えてみましょう。
template<int N> struct Factorial { static const int value = N * Factorial<N-1>::value; }; template<> struct Factorial<0> { static const int value = 1; };
Factorial
という構造体テンプレートでは、テンプレート引数N
の階乗の値をvalue
としてコンパイル時に計算しています。Factorial<5>::value
なら、5*4*3*2*1=120となります。つまり、コンパイル時にこの計算が行われるのです。
このように、テンプレートメタプログラミングではコンパイル時にプログラムを「実行する」ことができ、そのコンパイル時の計算結果を使ってプログラムを生成します。コンパイル時の計算なので実行時のオーバーヘッドは発生しません。
コンパイル時プログラミングの利点
[編集]テンプレートメタプログラミングによるコンパイル時プログラミングには、次のような利点があります。
- 型安全性の向上
- コンパイル時にプログラムのエラーを検出できるため、型の安全性が高まります。
- 最適化の可能性
- コンパイル時に計算することで、実行時のオーバーヘッドを削減できます。
- ゼロコストのアブストラクション
- テンプレートの機能を使えば、アブストラクションを実現しながらも実行時コストを最小限に抑えられます。
- ジェネリックプログラミング
- テンプレートを活用することで、コードの再利用性が高まります。
つまり、テンプレートメタプログラミングによって、パフォーマンスと抽象化の両立を実現できるのです。
C++におけるテンプレートメタプログラミングの役割
[編集]C++ではテンプレートメタプログラミングが、様々な用途で活用されています。
- 標準ライブラリの内部で使われている
- 型traits(後述)の実装
- 静的アサート
- 小規模DSL(ドメイン特化言語)の実装
- 数値計算のテンプレート化
- ユーティリティコードの実装
など、C++における重要な概念やテクニックがテンプレートメタプログラミングに支えられています。高度な機能ですが、C++を深く理解する上で欠かせない概念なのです。
つまり、テンプレートメタプログラミングは単なるテクニックではなく、C++の重要な機能そのものだと言えるでしょう。次に基礎的な文法やテクニックについて見ていきましょう。
テンプレートメタプログラミングの基礎
[編集]この章では、テンプレートメタプログラミングを理解する上で必要な基礎的な文法やテクニックを説明します。
テンプレート型パラメータ
[編集]テンプレートメタプログラミングの基礎として、テンプレートの型パラメータについて確認しましょう。
template<typename T> struct Example { // ... };
ここでT
はテンプレート型パラメータで、任意の型を受け取れます。特殊化して具象型を割り当てることもできます。
template<> struct Example<int> { // int型の特殊化 };
型パラメータは、それ自体が値を持つわけではありません。しかし、テンプレートメタプログラミングではこの型パラメータ自体を操作の対象とすることができます。
例えば、次のようにサイズを保持するArray
クラスを実装できます。
template<typename T, int N> class Array { T data[N]; public: // ... };
ここでN
はコンパイル時に評価される定数式となり、data
配列のサイズを決定します。Array<int, 10>
ならint
型で要素数10の配列になります。
このように、型パラメータを使ってコンパイル時にサイズや値を決定できるのがテンプレートメタプログラミングの基礎となります。
値パラメータ
[編集]テンプレートには、型パラメータ以外にも値パラメータを指定できます。
template<typename T, int Value> struct IntValue { static const int value = Value; };
IntValue<int, 42>::value
なら42になります。値パラメータはコンパイル時の定数式として使えるので、テンプレートメタプログラミングの基礎となります。
値パラメータは整数型に限らず、列挙型や組み込みの型でも指定できます。
template<char...> struct CharValue {}; CharValue<'a', 'b', 'c'> cv;
ここでは、'a'
、'b'
、'c'
という文字リテラルをパックexpansionで展開して渡しています。このようにして、コンパイル時に様々な値を表現できます。
テンプレートメタ関数
[編集]テンプレートメタプログラミングでは、関数のように呼び出せるテンプレートを定義できます。これをテンプレートメタ関数と呼びます。
template<typename T, typename U> struct Sum { using type = /* Tとの組み合わせによるUの型 */ };
このSum
テンプレートは、2つの型を受け取り、その組み合わせによる型をtype
に指定するものとします。関数のように以下のように使えます。
using IntType = Sum<int, double>::type; // doubleになる
このように、テンプレートメタ関数を使ってコンパイル時に型計算を行えます。型計算の具体的な方法は、メタプログラミングのテクニックとして後述します。
テンプレートメタ関数は値の計算にも使えます。
template<int N, int M> struct Add { static const int value = N + M; };
このAdd
テンプレートは、2つの整数値を受け取り、その和をvalue
に格納します。使い方は以下のようになります。
int x = Add<20, 22>::value; // x = 42
テンプレートメタ関数では、このように値の計算を関数のように記述できます。メタプログラミングでは、このようなテンプレートメタ関数を組み合わせて複雑な計算を行っていきます。
テンプレートメタ関数には再帰的な定義も可能です。
template<int N> struct Factorial { static const int value = N * Factorial<N-1>::value; }; template<> struct Factorial<0> { static const int value = 1; };
ここではFactorial
テンプレートが再帰的に定義されており、Factorial<5>::value
なら5*4*3*2*1=120となります。
このように、テンプレートメタ関数はコンパイル時の型計算や値計算を関数のように記述できる強力な仕組みです。メタプログラミングの核となる重要な概念です。
クラステンプレートの特殊化
[編集]最後に、クラステンプレートの特殊化について確認しましょう。
template<typename T> class Array { /* ... */ }; template<> class Array<bool> { /* bool専用の実装 */ };
このように、クラステンプレートを型パラメータごとに特殊化して別の実装を行うことができます。
これを応用すると、部分特殊化も可能になります。
template<typename T, int N> class Array { /* ... */ }; template<int N> class Array<bool, N> { /* bool専用の実装 */ };
ここではbool
型のArray
について、要素数N
に関係なく特殊化した実装を行っています。
部分特殊化は、あるパターンに合致した場合のみ別の実装を行いたい場合に使えます。メタプログラミングでは状況に応じた最適な実装を行うために、この特殊化のテクニックがよく使われます。
以上が、テンプレートメタプログラミングの基礎となる概念の説明でした。次に、実際のメタプログラミングで使われるテクニックについて見ていきましょう。
メタプログラミングテクニック
[編集]ここまでテンプレートメタプログラミングの基礎となる概念を説明してきました。この章では、実際のメタプログラミングで使われる主要なテクニックを紹介します。
型traits
[編集]型traitsとは、型の性質を調べるためのテンプレートクラスのことです。C++の標準ライブラリにいくつかの型traitsが定義されています。
std::is_same<int, int>::value // true std::is_same<int, double>::value // false std::is_pointer<int*>::value // true std::is_pointer<int>::value // false std::is_arithmetic<int>::value // true (整数型、浮動小数点数型) std::is_arithmetic<std::string> // false
このように、is_same
、is_pointer
、is_arithmetic
といった型traitsを使うと、型の性質を調べられます。これらはクラステンプレートとして実装されており、値メンバvalue
が型の性質を表します。
型traitsはコンパイル時に評価されるので、メタプログラミングでよく利用されます。実際に型traitsを使ってコンパイル時のディスパッチや処理振り分けなどを行うことができます。
また、型traitsは単に使うだけでなく、独自に実装することもできます。実装方法については、この後の項で解説します。
テンプレートメタプログラミングでの再帰
[編集]#テンプレートメタ関数の節で少しだけ触れましたが、テンプレートメタプログラミングでは再帰的な定義が可能です。それではどのように再帰を行うのでしょうか。
典型的なパターンとして、空の場合と非空の場合を分けて定義するのが一般的です。
// 非空の場合の再帰定義 template<typename T, typename... Remain> struct TypePrinter { static void print() { std::cout << typeid(T).name() << ", "; TypePrinter<Remain...>::print(); } }; // 空の場合の終了条件 template<> struct TypePrinter<> { static void print() { std::cout << std::endl; } };
ここではTypePrinter
というテンプレートメタ関数を定義しています。非空の場合はヘッド(T
)を出力し、残り(Remain...
)について再帰的に呼び出します。空の場合は終了条件として改行を出力します。
使い方は以下のようになります。
TypePrinter<int, double, char>::print(); // 出力: int, double, char,
このように、テンプレートの部分特殊化を使って、終了条件を定義することで再帰的な定義を行えます。メタプログラムでよく使われるテクニックです。
引数パック
[編集]前節でも少し出てきましたが、C++11から可変長引数テンプレートが導入され、引数パックを扱えるようになりました。
template<typename... Ts> struct TypeList {}; template<typename T, typename... Remain> struct Push<TypeList<Remain...>, T> { using type = TypeList<Remain..., T>; };
ここでは、TypeList
という型のリストを定義しています。Remain...
が可変長の型パラメータになっています。
Push
テンプレートでは、リストの末尾に新しい型T
を追加します。Remain...
を使うことで、リストに含まれる型を残しつつ新しい型を追加できます。
引数パックは可変長引数テンプレートでのみ使えるわけではありません。関数テンプレートでも使えます。
template<typename... Ts> void print(Ts... args) { // 可変長引数の処理 }
このprint
関数では、args
が可変長引数になっています。この引数に対して繰り返し的な処理を行うこともできます。
このように、引数パックはメタプログラミングで様々な用途で用いられるテクニックです。
折りたたみ式
[編集]引数パックの利用法として、C++17で導入された折りたたみ式(fold expression)が挙げられます。
折りたたみ式を使うと、引数パックに対して二項演算を再帰的に適用できます。例えば以下のように足し算を行えます。
template<typename... Ts> constexpr auto sum(Ts... args) { return (... + args); // 折りたたみ式 } int x = sum(1, 2, 3, 4, 5); // x = 15
このsum
関数では、args
の各要素に対して+
演算子を左から右に適用し、その結果を返しています。(1 + 2 + 3 + 4 + 5)
と同じ計算になります。
folding操作には、二項演算子の他に、カンマ演算子、代入演算子、論理演算子なども使えます。
template<typename... Ts> auto make_tuple(Ts... args) { return std::tuple(args...); // カンマ演算子を使った折りたたみ }
このように、引数パックに対して様々な二項演算を簡潔に適用できるのが折りたたみ式の特徴です。
また、折りたたみ式は左から右に適用するだけでなく、右から左へ適用することも可能です。
template<typename... Ts> bool all(Ts... args) { return (true && ... && args); // 右から左の折りたたみ }
ここでは論理積を求めるため、右から左へ&&
演算子を適用しています。このように左右いずれの方向でも折りたたみ演算を行えます。
メタプログラミングでは、このような折りたたみ式を駆使して引数パックを効率的に処理していきます。C++17の折りたたみ式は、引数パックの利用を劇的に簡潔化する大変重要な機能なのです。
応用例
[編集]ここまでテンプレートメタプログラミングの基礎概念とテクニックを説明してきました。この章では、実際にそれらをどのように応用するのか、いくつかの具体例を紹介します。
型安全プログラミング
[編集]テンプレートメタプログラミングでは、型を活用した安全性の高いコーディングが可能になります。
静的アサート
[編集]静的アサートとは、コンパイル時に条件を評価し、失敗した場合にコンパイルエラーを出す機能です。これを使えば、危険な状態をコンパイル時に検出でき、型の安全性が高まります。
template <bool> struct static_assert_failure; template <> struct static_assert_failure<true> {}; #define static_assert(cond, msg) \ static_assert_failure<(cond) != false> \ STATIC_ASSERT_JOINING_TRICK(msg, __LINE__) template <typename> struct STATIC_ASSERT_JOINING_TRICK; static_assert(sizeof(int) == 4, "int size is not 4 bytes");
このstatic_assert
は、指定された条件式がfalse
の場合に、指定されたメッセージを出力してコンパイルエラーとなります。int
のサイズが4バイトでない環境ではコンパイルエラーになります。
単位次元チェック
[編集]単位次元のチェックをメタプログラミングで行うことで、間違った次元の演算を防げます。
constexpr auto operator"" _m(long double x) { return Dim<0,1,0,0,0,0,0>(x); } constexpr auto operator"" _kg(long double x) { return Dim<0,0,1,0,0,0,0>(x); } constexpr auto operator"" _s(long double x) { return Dim<0,0,0,1,0,0,0>(x); } template<int M, int L, int K, int S, int A, int...> struct Dim { Dim(long double v) : value(v) {} long double value; }; template<int M1, int L1, int K1, int S1, int A1, int M2, int L2, int K2, int S2, int A2> constexpr auto operator*(Dim<M1,L1,K1,S1,A1,0,0,0,0,0> d1, Dim<M2,L2,K2,S2,A2,0,0,0,0,0> d2) { return Dim<M1+M2,L1+L2,K1+K2,S1+S2,A1+A2,0,0,0,0,0>(d1.value * d2.value); }
ここではDim
という型で単位次元を表現しています。メートル_m
、キログラム_kg
、秒_s
というリテラル値を定義しています。
operator*
をDim
型に対して定義しています。単位次元の計算が行われ、異なる単位の乗算はコンパイルエラーとなります。
このような実装により、プログラム上で単位次元の一貫性を保つことができます。テンプレートメタプログラミングを活用することで、型安全性が高まるのです。
多次元配列アクセス
[編集]配列のサイズや次元数をコンパイル時に評価し、実行時のチェックコストを削減できます。
template<typename Dim, size_t Level = Dim::Depth(), typename = std::enable_if_t<(Level > 0)>> class MultiArray; template<size_t... Indices, typename T> class MultiArray<std::integer_sequence<size_t, Indices...>, 0, T> { std::array<T, sizeof...(Indices)> data; public: constexpr T& operator[](size_t idx) { return data[idx]; } // ... }; template<size_t Head, size_t... Tail, typename T> class MultiArray<std::integer_sequence<size_t, Head, Tail...>, 0, T> { std::array<MultiArray<std::integer_sequencesizeTail...>, 0, T>, Head> data; public: constexpr auto& operator[](size_t idx) { return data[idx]; } // ... };
ここではMultiArray
というテンプレートクラスで多次元配列を実装しています。サイズのパラメータ化と再帰的な実装により、任意の次元数、任意のサイズの配列を扱えます。
配列サイズはコンパイル時に評価されるので、実行時のサイズチェックが不要になります。添字のチェックも実装次第でコンパイル時に行えます。このようにメタプログラミングにより、実行時オーバーヘッドを最小限に抑えつつ、柔軟な多次元配列を実装できます。
実装の詳細を見ていきましょう。MultiArray
クラスはstd::integer_sequence
を使って次元数とサイズを表現しています。
Level
が0の場合は、最内の次元なので実際のデータをstd::array
で保持します。Level
が0でない場合は、外側の次元なので内側のMultiArray
の配列を保持します。
このように再帰的に定義され、最終的に実データを保持するstd::array
に行き着きます。
添字アクセスはoperator[]
で行われます。
MultiArray<std::integer_sequence<size_t, 3, 4, 5>, 0, int> arr; arr[1][2][3] = 42; // OK
最外側の次元から内側へとネストされたoperator[]
呼び出しになります。
サイズのチェックはクラステンプレートの特殊化により実装できます。
template<size_t... Sizes, size_t Idx, size_t... Indices> constexpr bool checkBounds(std::integer_sequence<size_t, Sizes...>, std::integer_sequence<size_t, Idx, Indices...>) { return Idx < Sizes && checkBounds(std::integer_sequence<size_t, Indices...>()); }
このcheckBounds
関数は、指定されたインデックス列が配列サイズの範囲内かどうかをコンパイル時にチェックします。
MultiArray
のoperator[]
内でこの関数を呼び出すことで、誤ったインデックスアクセスに対してコンパイルエラーを出すことができます。
このように、テンプレートメタプログラミングの機能を活用すれば、実行時のオーバーヘッドを極力排除しつつ、型安全で柔軟な多次元配列を実装できるのです。
式の構文解析
[編集]テンプレートメタプログラミングでは、文字列や式の構文解析も可能です。これにより、プログラムに対する静的解析やコード生成などが実現できます。
例として、簡単な数式の構文解析を行うメタプログラムを示します。
template<char...> struct ExprParser; template<> struct ExprParser<> { static constexpr int result = 0; }; template<char... Expr> struct ExprParser { private: template<char C> static constexpr int digVal() { return C >= '0' && C <= '9' ? C - '0' : 0; } static constexpr int lhsVal() { constexpr int first = digVal<Expr[0]>(); if(sizeof...(Expr) > 1) return first * ExprParser<Expr[1], Expr[2]...>::result + lhsVal(); else return first; } static constexpr int rhsVal() { constexpr char op = Expr[0]; constexpr int first = digVal<Expr[1]>(); if(sizeof...(Expr) > 2) return first * ExprParser<Expr[2], Expr[3]...>::result + rhsVal(); else return first; } static constexpr int opVal() { constexpr char op = Expr[0]; return op == '+' ? lhsVal() + rhsVal() : lhsVal() - rhsVal(); } public: static constexpr int result = opVal(); }; static_assert(ExprParser<'8','+','3','*','5'>::result == 23);
このメタプログラムは、文字列から構成される単純な数式の値を計算します。例えばExprParser<'8','+','3','*','5'>::result
なら(8 + 3*5) = 23となります。
実装の方法としては、数式を文字列に変換し、メタプログラミングでその文字列を解析しています。以下のような処理を行っています。
lhsVal()
で左辺値を再帰的に解析するrhsVal()
で右辺値を再帰的に解析するopVal()
で演算子に従って左右の値を計算する
文字列の解析は再帰的に行っており、最終的にインスタンスのresult
メンバに計算結果を格納しています。
コンパイル時のループ構造は書けませんが、再帰的にパターンマッチすることで擬似的に文字列処理ができます。このようなメタプログラミングの手法により、プログラムを静的に解析したり、コード生成を行うこともできるのです。
このように、テンプレートメタプログラミングは型安全性や最適化はもちろん、プログラムのメタレベルでの解析や変換にも応用できる、非常に強力な機能なのです。
テンプレートメタプログラミングの制限と展望
[編集]コンパイル時間の問題
[編集]テンプレートメタプログラミングの最大の問題点は、コンパイル時間が長くなることです。複雑なメタプログラムになると、コンパイル時の計算量が膨大になり、コンパイル時間が実用的でなくなる可能性があります。
また、メタプログラムに無限ループが発生した場合、コンパイラはそれを検出できず、コンパイル時に無限ループに陥ってしまう問題もあります。
このようなコンパイル時間の問題は、メタプログラミング全般に内在する本質的な課題です。プログラムの規模や複雑さに応じて、メタプログラミングによる恩恵とコンパイル時間の増加のトレードオフを考慮する必要があります。
可読性の問題
[編集]メタプログラミングの可読性は非常に悪いという問題もあります。コンパイル時に実行されるコードなので、実行フローが直感的に追えない場合があります。また、テンプレートのエラーメッセージも非常に分かりづらいことが多いです。
そのため、比較的単純なメタプログラムであれば問題ないものの、複雑になるとメンテナンス性が極端に低下してしまいます。可読性を意識した設計が必須となります。
将来の展望
[編集]コンパイル時間と可読性の問題は根強い課題ですが、言語仕様の改善や新しい提案によってある程度は改善される見込みです。
- コンパイル時計算の最適化
- コンパイル時ループなどの導入
- 現在は引数パックと再帰の組み合わせで表現している繰り返しを、より平易な表現で行われる湯になる。
- 静的リフレクション
- 型の情報をコンパイル時に取得できる機能の提案があります。これにより、型の構造をメタプログラミングで柔軟に操作できるようになります。
- コンパイル時実行
- プログラムを完全にコンパイル時に実行し、実行ファイルを生成しないコンパイル時コード実行の提案もあります。これにより、メタプログラミングの適用範囲が劇的に広がる可能性があります。
- コンパイル時モジュール
- モジュールシステムの導入により、コンパイル時間が大幅に改善される見込みです。モジュール間の依存関係が分離されるため、不要な再コンパイルが回避できます。
- 改善されたエラーメッセージ
- コンパイラベンダーによるエラーメッセージの改善や、新しい言語機能の導入により、メタプログラミングのデバッグ作業が容易になることが期待されます。
このように、メタプログラミングの抱える課題は、言語仕様の進化により徐々に改善されていくと考えられています。一方で、根本的にコンパイル時プログラミングには限界があり、実行時のコードに頼らざるを得ない部分もあるでしょう。
しかし、C++ではゼロオーバーヘッドの原則が重視されており、メタプログラミングの重要性は今後も変わらず高いと予想されます。型安全性とパフォーマンスの両立を目指す上で、メタプログラミングはC++における中心的な概念の1つとして発展していくことでしょう。
まとめ
[編集]この章では、C++のテンプレートメタプログラミングについて詳しく解説してきました。コンパイル時プログラミングという発想の上に成り立つこの高度なテクニックは、C++の型安全性とパフォーマンスの両立を支える重要な機能です。
まず基礎として、テンプレートの型パラメータと値パラメータ、テンプレートメタ関数、クラステンプレートの特殊化について説明しました。
次にメタプログラミングで使われる主要なテクニックとして、型traits、再帰、引数パック、折りたたみ式を取り上げました。
そしてメタプログラミングの応用例を、型安全プログラミング、多次元配列アクセス、式の構文解析という具体例で示しました。
一方で、メタプログラミングには、コンパイル時間と可読性の問題があることも指摘しました。しかし、言語仕様の進化によりこれらの課題は徐々に解決されていく見込みです。
テンプレートメタプログラミングは、C++の本質的な機能であり、今後ますますその重要性が高まっていくでしょう。本書を通して、この高度だが強力な概念を理解していただけたら幸いです。