「コンセプト」はなぜ必要?
「コンセプト」はテンプレート引数に条件を持たせる機能です。例えば、2つの等しい型のオブジェクトがあるとして、それらをスワップするとき、そもそもオブジェクトにはスワップが可能であることを要求するはずです。しかし、従来の機能では要求を満たしているかという条件は無視されていました。それにより、テンプレート引数に意図しない型が入力されバグの原因となる可能性があったのです。
問題点の確認
従来の機能での問題をコードで確認してみましょう。
実行環境は以下の通りです。コンパイルはC++20で行っています。
- Ubuntu22.04
- g++ 11.4.0
#include<iostream>
template<class T, class U>
void swap(T& x, U& y) {
T tmp = x;
x = y;
y = tmp;
}
int main() {
unsigned int a = 2 << 31 - 1;
int b = 10;
std::cout << "a=" << a << ",b=" << b << std::endl;
swap(a,b);
std::cout << "a=" << a << ",b=" << b << std::endl;
return 0;
}
a=2147483648,b=10
a=10,b=-2147483648
上記プログラムの問題点は何でしょう?入れ替えた後、「b=2147483648」となるはずが「b=-2147483648」となっています。この原因は2つの型の表現できる範囲の差にあります。これは実行前に気づくことが可能であるため、コンパイラがエラーを吐くべきですが警告されませんでした。そこで、登場するのが「コンセプト」です。これを用いて上記プログラムを書き直してみましょう。
#include<iostream>
#include<concepts>
template<class T, class U> requires std::same_as<T,U>
void swap(T& x, U& y) {
T tmp = x;
x = y;
y = tmp;
}
int main() {
unsigned int a = 2 << 31 - 1;
int b = 10;
std::cout << "a=" << a << ",b=" << b << std::endl;
swap(a,b);
std::cout << "a=" << a << ",b=" << b << std::endl;
return 0;
}
main.cpp: In function ‘int main()’:
main.cpp:16:13: error: no matching function for call to ‘swap(unsigned int&, int&)’
16 | swap(a,b);
| ~~~~^~~~~
上記プログラムでは、型Tと型Uが等しいことを要求しています。そのため、後にswap関数の呼び出しが見つかった際にコンパイラは、型が等しくないことを発見し警告してくれています。
ここで型Tと型Uが等しいことを要求するよう指定しているのはstd::same_as<T,U>です。これは、<concepts>ヘッダに標準的に含まれているコンセプトです。テンプレート引数にとる2つの型が等しいとき、真を返します。他にも提供されているコンセプトは多くあります。知りたい方は以下のリンクを参照してください。
宣言のジェネリック化
クラス/関数…の宣言において、テンプレート引数の型に制約を行う方法は3つあります。
- class/typenameをコンセプトで置き換える
- requiresを使用する
- 簡略構文を用いる
プログラム内では2番目の「requiresを使用する」ことで制約を行っています。他の2つの方法が使用可能なときは常にこの方法に代替することができます。
つまり、「requiresを使用する」この方法さえ覚えていれば「コンセプト」は活用できます。
しかし、この方法だけ使用していてはコードが冗長になってしまう可能性があります。例えば、以下のコードを見てみましょう。以下のコードは整数型と整数型の等値比較を示しています。また、関数equalA、関数equalB、関数equalCは上から順に3つの方法で実装したものです。
#include<iostream>
#include<concepts>
namespace test {
template<std::integral T, std::integral U>
bool equalA(T x, U y) {
return x == y;
}
template<class T, class U> requires std::integral<T> && std::integral<U>
bool equalB(T x, U y) {
return x == y;
}
bool equalC(std::integral auto x, std::integral auto y) {
return x == y;
}
}
int main() {
std::cout << std::boolalpha;
std::cout << test::equalA(10, 10) << std::endl;
std::cout << test::equalB(10, 10) << std::endl;
std::cout << test::equalC(10, 20) << std::endl;
return 0;
}
関数equalBでは、requires節に&&(かつ)を利用して「型Tが整数型であり、型Uも整数型である」を要求しています。
関数equalAでは、各テンプレート引数に要求される条件は1つであるため、class/typenameをコンセプトに置き換えて実装しています。
関数equalCは、「class/typenameをコンセプトで置き換える」方法を簡略化した方法で実装しています。関数の引数には「std::integral auto x, std::integral auto y」と書かれています。autoはautoプレースホルダーのことです。次のように展開されるとイメージするとよいでしょう。
template<class T, class U>
bool equalC(std::integral T x, std::integral U y) {
return x == y;
}
template<std::integral T, std::integral U>
bool equalC(T x, U y) {
return x == y;
}
- autoプレースホルダーが展開される
- 対応する型へコンセプトが適用される
ユーザー定義のコンセプト
標準ライブラリにより基本的なコンセプトは提供されています。
しかし、これだけでは不十分な場合は必ず生じるでしょう。コンセプトはユーザーにより新しく定義されることが可能です。
早速、例を見てみましょう。以下のコードは2つの値に+演算を行う例です。
#include<iostream>
#include<concepts>
namespace test {
template<class T, class U>
concept plusable = requires(T x, U y) {
x + y;
};
template<class T, class U> requires plusable<T,U>
void plus(T x, U y) {
std::cout << x + y << std::endl;
}
}
int main() {
test::plus(10, 10); //1
test::plus(9.0f, 7); //2
test::plus("Hello World!", 3); //3
test::plus(std::string("Hello World!"), 3); //4 エラー
return 0;
}
以上のコードでは、関数plusへコンセプトplusableを適用しています。シンプルに関数plus内でx+y、つまりx.operator+(y)を呼び出しているため、コンセプトplusableで「型Tと型Uにおいて、T.operator+(U)が有効である」を要求しています。
式1~2がエラーになることはありません。また、const char*型とint型の+演算は有効であるため式3も間違っていません。しかし、4式は無効です。なぜなら、string型とint型に+演算が宣言されていないからです。これは以下のリンクから確認できます。
コンセプトの定義には、他のコンセプトを持ってくることも可能です。ここでは例としてstd::same_asの定義を利用します。
template <class T, class U>
struct is_same;
template <class T, class U>
inline constexpr bool is_same_v = is_same<T, U>::value;
template <class T, class U>
concept same-as-impl = is_same_v<T, U>;
template <class T, class U>
concept same_as = same-as-impl<T, U> && same-as-impl<U, T>;
このように事前に定義されたコンセプトを条件として利用することが可能です。複数利用する場合は、&&または||を用いて連ならせます。same-as-implを見ると定義に利用されているのはbool型だとわかります。また、same_asに関しても論理積演算記号&&だと解釈するとbool型を取っていることが分かります。コンセプトの定義時、右辺値に来るのはbool型または、requires式です。
requires式に関する例としてstd::convertible_toを利用します。
template<class From, class To>
concept convertible_to =
is_convertible_v<From, To> &&
requires(add_rvalue_reference_t<From> (&f)()) {
static_cast<To>(f());
};
ここでコードがどんな処理を示すのか考える必要はありません。注目してほしいのは文法です。
ここでも&&が登場します。これを論理積演算記号だと捉えればrequires式もまたbool型を返すことが予想できます。つまり、requires式のみを右辺に位置させることは可能ですし、論理演算記号を用いて連ねることもできます。
しかし、これではrequires式の重要性が分かりません。requires式の存在意義は何でしょうか。
それはローカルパラメーターにあります。
std::convertible_toにおいて、ローカルパラメーターにあたるのはadd_rvalue_reference_t<From> (&f)()です。これにより、型ではなく値で要件を要求できるのです。ここで注意したいのはx==0のような値の評価はできないことです。また、要求できる要件として他にメンバ型があります。
requires式を利用すると、例えば「関数fに型Tである値xを入力したときの出力がコンセプトを満たす」「型Tである値xと型Uである値yの和がコンセプトを満たす」などの条件を表現できます。
他にもrequires式内で利用できるものとして入れ子要件がありますが、ここでは説明しません。
終り
コンセプトはコードの品質を高められる重要な機能です。この記事は以下の参考文献を基に作られています。より深く知りたい場合は文献にあたってみてください。