C++のconceptとは
C++のconceptとは、テンプレート引数が満たすべき条件(制約)を定義するための機能です。
C++20で正式に導入され、テンプレートを利用するときに「どのような型やテンプレート引数を受け付けるのか」を明確に表現できるようになりました。
従来のC++では、SFINAEやstd::enable_ifなどを使ってテンプレート引数を制限する方法が一般的でした。
しかし、条件が複雑になるにつれてコードが読みにくくなり、コンパイルエラーも理解しにくくなることがありました。
conceptを利用すると、テンプレートが要求する条件をより簡潔かつ明確に記述できます。
conceptの基本的な役割
たとえば、次のような関数テンプレートがあるとします。
template <typename T>
T add(T a, T b)
{
return a + b;
}
この関数は、a + bという演算が可能な型でなければ正常に使用できません。
しかし、このコードだけでは「+演算子を使用できる型が必要」という条件が明示されていません。
そこで、conceptを利用して次のように条件を定義できます。
template <typename T>
concept Addable = requires(T a, T b)
{
a + b;
};
このAddableは、「型Tの値同士でa + bという式が成立する」という条件を表しています。
定義したconceptは、次のように関数テンプレートに利用できます。
template <Addable T>
T add(T a, T b)
{
return a + b;
}
これにより、add関数が+演算可能な型を要求していることがコードから分かりやすくなります。
なお、このAddableが保証しているのは、あくまでa + bという式が有効であることです。
演算結果が必ずT型になることまで保証しているわけではありません。
C++にconceptが導入された理由
C++20より前でも、テンプレート引数に条件を設定することは可能でした。
代表的な方法として、SFINAEやstd::enable_ifがあります。
たとえば、整数型だけを受け付ける関数をstd::enable_ifで記述すると、次のようになります。
#include <type_traits>
template <
typename T,
typename = std::enable_if_t<std::is_integral_v<T>>
>
void print(T value)
{
}
この方法でもテンプレート引数を制限できますが、条件が増えるほどコードが複雑になりやすいという問題があります。
C++20の標準conceptを利用すると、同じ条件をより簡潔に記述できます。
#include <concepts>
template <std::integral T>
void print(T value)
{
}
std::integralという名前から、Tが整数型であることを要求していると簡単に理解できます。
conceptによってテンプレートの意図が分かりやすくなる
conceptの大きな特徴は、テンプレートが要求する条件そのものをコード上で表現できることです。
たとえば、
template <std::integral T>
と記述されていれば、「このテンプレートは整数型を対象としている」という意図が明確です。
テンプレートを実装する側だけでなく、利用する側にとっても仕様を理解しやすくなる点がconceptのメリットです。
conceptの基本的な書き方
独自のconceptは、基本的に次の形式で定義します。
template <typename T>
concept ConceptName = 条件;
たとえば、整数型であることを表すconceptは次のように定義できます。
#include <type_traits>
template <typename T>
concept Integer = std::is_integral_v<T>;
定義したIntegerは、関数テンプレートなどで利用できます。
template <Integer T>
void func(T value)
{
}
次の呼び出しは条件を満たします。
func(10);
10はint型であり、std::is_integral_v<int>はtrueになるためです。
一方、次の呼び出しは制約を満たしません。
func(3.14);
3.14は通常double型であり、整数型ではないためです。
requires句を使ってテンプレートを制約する
conceptによる制約は、requires句を使って記述することもできます。
たとえば、整数型だけを受け付ける関数は次のように記述できます。
#include <concepts>
template <typename T>
requires std::integral<T>
void func(T value)
{
}
また、関数宣言の後ろにrequires句を書くこともできます。
template <typename T>
void func(T value)
requires std::integral<T>
{
}
さらに、conceptをテンプレートパラメータ部分に直接指定することも可能です。
template <std::integral T>
void func(T value)
{
}
これらは、この例ではいずれも「Tがstd::integralを満たすこと」を要求しています。
requires句とrequires式は異なる
C++では、requiresというキーワードが複数の用途で使われます。
テンプレートに制約を付ける、
requires std::integral<T>
はrequires句です。
一方、
requires(T value)
{
value.size();
}
のように、特定の式や型が有効かどうかを調べるものはrequires式と呼ばれます。
名前は似ていますが、役割が異なるため区別して理解することが重要です。
requires式とは
requires式を利用すると、「特定の演算が可能か」「特定のメンバー関数を呼び出せるか」などをコンパイル時に確認できます。
たとえば、+演算子を使用できる型を表すconceptは次のように記述できます。
template <typename T>
concept Addable = requires(T a, T b)
{
a + b;
};
このconceptでは、a + bという式が有効であることを要求しています。
intやdoubleなどは通常+演算が可能なので、この条件を満たします。
演算結果にも条件を設定できる
requires式では、単に式が有効であることだけでなく、その結果にも条件を付けられます。
たとえば、a + bの結果がTに変換可能であることを要求する場合は、次のように記述できます。
#include <concepts>
template <typename T>
concept Addable = requires(T a, T b)
{
{ a + b } -> std::convertible_to<T>;
};
このように、conceptを利用するとテンプレートが必要とする条件をより細かく定義できます。
requires式でメンバー関数を確認する
requires式では、ある型が特定のメンバー関数を持っているかどうかも確認できます。
たとえば、size()を呼び出せる型を表すconceptは次のように定義できます。
template <typename T>
concept HasSize = requires(const T& value)
{
value.size();
};
このconceptを使って、次のような関数を作成できます。
template <HasSize T>
void printSize(const T& value)
{
std::cout << value.size() << '\n';
}
std::vectorやstd::stringなど、const参照からsize()を呼び出せる型で利用できます。
std::vector<int> v{1, 2, 3};
printSize(v);
一方、通常のint型にはsize()というメンバー関数がないため、次のコードは制約を満たしません。
printSize(10);
requires式で戻り値の型を指定する
requires式では、ある式の結果が指定した型の条件を満たしているかどうかも確認できます。
たとえば、前置インクリメント演算子++valueの結果がT&であることを要求する場合は、次のように記述できます。
#include <concepts>
template <typename T>
concept Incrementable = requires(T value)
{
{ ++value } -> std::same_as<T&>;
};
ここでは、
{ ++value } -> std::same_as<T&>;
によって、++valueという式が有効であり、その結果がT&型であることを要求しています。
単純に「その処理ができるか」を確認するだけでなく、演算結果の型まで指定できる点がrequires式の特徴です。
C++の標準ライブラリで使える主なconcept
C++20では、<concepts>ヘッダーにさまざまな標準conceptが用意されています。
代表的なものは以下のとおりです。
std::same_as
std::same_asは、2つの型が同じ型であることを表すconceptです。
std::same_as<int, int>
は条件を満たします。
std::derived_from
std::derived_fromは、指定した型が別の型から適切に派生しているかを確認するconceptです。
主にクラスの継承関係を条件として指定するときに利用できます。
std::convertible_to
std::convertible_toは、ある型から別の型への変換が所定の要件を満たして可能であることを表します。
requires式の戻り値に条件を付ける場合にも利用できます。
std::integral
std::integralは、整数型であることを表すconceptです。
template <std::integral T>
void func(T value)
{
}
のように利用できます。
std::signed_integral
std::signed_integralは、符号付き整数型を表すconceptです。
intやlongなどが代表的です。
std::unsigned_integral
std::unsigned_integralは、符号なし整数型を表します。
unsigned intなどが該当します。
std::floating_point
std::floating_pointは、浮動小数点型を表すconceptです。
たとえば、浮動小数点型だけを受け付ける関数は次のように記述できます。
#include <concepts>
template <std::floating_point T>
T half(T value)
{
return value / 2;
}
doubleやfloatなどは条件を満たします。
その他の標準concept
ほかにも、次のようなconceptが用意されています。
std::assignable_fromstd::default_initializablestd::copy_constructiblestd::move_constructible
これらを利用すると、代入、初期化、コピー、ムーブなど、テンプレートが必要とする性質を明確に指定できます。
複数のconceptを組み合わせる方法
conceptは、論理演算子を使って複数の条件を組み合わせることができます。
OR条件を指定する
整数型または浮動小数点型を表すconceptを作る場合は、次のように記述できます。
#include <concepts>
template <typename T>
concept Number =
std::integral<T> ||
std::floating_point<T>;
このNumberを利用すると、次のような関数を作れます。
template <Number T>
T square(T value)
{
return value * value;
}
そのため、
square(10);
square(3.14);
のように、整数型と浮動小数点型の両方を受け付けられます。
AND条件を指定する
複数の条件をすべて満たすことを要求する場合は、&&を使用できます。
template <typename T>
concept SomeConcept =
ConditionA<T> &&
ConditionB<T>;
ただし、標準conceptの中には、すでに別のconceptの条件を含んでいるものがあります。
たとえばstd::signed_integral<T>は整数型であることも含めて判定するため、
std::integral<T> && std::signed_integral<T>
と書く必要はありません。
単純に、
template <typename T>
concept SignedInteger = std::signed_integral<T>;
と記述できます。
conceptを使った関数のオーバーロード
conceptは、関数テンプレートのオーバーロードにも活用できます。
たとえば、整数型と浮動小数点型で異なる処理を行う場合は次のように記述できます。
#include <concepts>
#include <iostream>
template <std::integral T>
void show(T value)
{
std::cout << "integer\n";
}
template <std::floating_point T>
void show(T value)
{
std::cout << "floating point\n";
}
次のように呼び出します。
show(10);
show(3.14);
10は整数型なのでstd::integral側が条件を満たし、3.14はdouble型なのでstd::floating_point側が条件を満たします。
conceptを利用すると、テンプレート引数の性質に応じて異なる関数を選択する設計がしやすくなります。
abbreviated function templateとは
C++20では、conceptと組み合わせて関数テンプレートを簡潔に記述できるabbreviated function templateも利用できます。
通常は次のように記述します。
template <std::integral T>
void print(T value)
{
}
これを次のように簡略化できます。
void print(std::integral auto value)
{
}
std::integral autoと指定することで、整数型として条件を満たす引数だけを受け付けます。
たとえば、
void print(std::integral auto value)
{
std::cout << value;
}
なら、
print(100);
のような呼び出しが可能です。
テンプレートパラメータを明示する必要がないため、短い関数テンプレートなどではコードを簡潔にできます。
conceptとSFINAEの違い
C++20以前は、テンプレート引数を制限する方法としてSFINAEやstd::enable_ifが広く利用されていました。
たとえば、整数型だけを受け付ける関数をstd::enable_ifで記述すると、次のようになります。
template <
typename T,
std::enable_if_t<std::is_integral_v<T>, int> = 0
>
void func(T value)
{
}
conceptを利用すると、次のように簡潔に記述できます。
template <std::integral T>
void func(T value)
{
}
どちらもテンプレートの候補を条件によって制御できますが、conceptのほうが「どのような条件を要求しているのか」を直接表現しやすいという特徴があります。
conceptがSFINAEを完全に置き換えるわけではない
C++20以降でも、SFINAEが使用できなくなったわけではありません。
C++では用途に応じて、
- concept・constraints
- SFINAE
if constexpr- tag dispatch
などを使い分けることがあります。
ただし、テンプレート引数に対する制約を表現する目的であれば、C++20以降ではconceptを利用することでコードを分かりやすくできるケースが多くあります。
C++でconceptを使うメリット
conceptを利用することで、テンプレートを使ったプログラムの可読性や保守性を高めやすくなります。
テンプレートの条件が分かりやすくなる
たとえば、
template <std::integral T>
と書かれていれば、Tに整数型が要求されていることを簡単に理解できます。
テンプレートの制約そのものがコードに現れるため、実装の意図を把握しやすくなります。
不適切なテンプレート引数を制約できる
次のようなテンプレートがある場合、
template <std::integral T>
void process(T value);
std::stringなどの整数型ではない型はstd::integralの条件を満たしません。
テンプレートの処理内部で問題が発生する前に、制約を満たしていない候補として扱えることがconceptの特徴です。
コンパイルエラーを理解しやすくできる
複雑なSFINAEを使用したテンプレートでは、非常に長いコンパイルエラーが表示される場合があります。
conceptによって必要な条件を明示すると、「指定した制約を満たしていない」という形で原因が示されやすくなります。
ただし、実際の診断メッセージはコンパイラの種類やコードの複雑さによって異なります。
C++でconceptを使う際の注意点
conceptは便利な機能ですが、用途を正しく理解して使うことが重要です。
conceptは通常の実行時条件判定とは異なる
conceptは、テンプレート引数がコンパイル時に満たすべき条件を表現するための仕組みです。
そのため、通常の関数引数として渡された値について、
「値が0より大きいか」
「値が100以下か」
といった実行時の状態をconceptで判定するものではありません。
たとえば、実行時に値が正か確認する場合は、
if (value > 0)
{
// 処理
}
のように通常の条件分岐を利用します。
conceptは型だけに限定された機能ではない
conceptは型に対して利用されるケースが非常に多いため、「型を制約する機能」と説明されることがあります。
しかし、より正確には、テンプレート引数が満たすべきコンパイル時の制約を定義するための仕組みです。
したがって、
「整数型であるか」
「特定の演算ができるか」
「特定のメンバー関数を利用できるか」
「別の型へ変換できるか」
といった条件をテンプレートの要件として表現できます。
C++のconceptを理解するためのポイント
C++のconceptは、単なる型チェック機能ではありません。
テンプレートが要求する性質やインターフェースを、名前付きの制約として表現するための仕組みです。
たとえば、
template <std::integral T>
T doubleValue(T value)
{
return value * 2;
}
というコードなら、doubleValueが整数型を対象とするテンプレートであることをコードから直接読み取れます。
また、独自のconceptを作ることもできます。
#include <iostream>
template <typename T>
concept Printable = requires(const T& value)
{
std::cout << value;
};
このconceptを利用すると、次のように出力可能な型だけを対象とした関数を定義できます。
template <Printable T>
void print(const T& value)
{
std::cout << value;
}
このようにC++20のconceptとrequiresを活用すると、テンプレートが必要とする条件をコード上で明確に表現できます。
特にテンプレートを多用するプログラムやライブラリでは、SFINAEやstd::enable_ifだけに頼る場合と比べて、可読性や保守性を向上させやすい点が大きなメリットです。
以上、C++のconceptについてでした。
最後までお読みいただき、ありがとうございました。
