コンテンツにスキップ

C++/コンセプト

出典: フリー教科書『ウィキブックス(Wikibooks)』
< C++


コンセプトの概要[編集]

コンセプトとは何か[編集]

コンセプト(concept)は、C++20から導入された新しい言語機能です。コンセプトは、型が満たすべき一連の要件を定義するものです。これにより、ジェネリックプログラミングにおいて、テンプレート引数として渡される型がその要件を満たしているかどうかをコンパイル時に検査できるようになります。

コンセプトの役割と目的[編集]

コンセプトの主な役割は、ジェネリックコードの安全性と expressivenessを高めることです。安全性が高まるのは、不適切な型がテンプレート引数として渡された場合にコンパイルエラーが発生するためです。expressivenessが向上するのは、コンセプトによってコードの意図がより明確に表現できるようになるためです。

コンセプトの目的は、以下の3つにまとめられます。

  1. コンパイル時の型検査を可能にする
  2. ジェネリックコードの意図を明確に表現する
  3. 適切なエラーメッセージを出力する

コンセプトによるジェネリックプログラミングの利点[編集]

コンセプトを使うことで、ジェネリックプログラミングにおいて以下の利点が得られます。

安全性の向上
コンパイル時に型の要件を検査できるため、実行時エラーを減らすことができます。
expressivenessの向上
コンセプトによって要件を明示的に表現できるため、コードの意図が明確になります。
エラーメッセージの改善
コンセプトに基づいたエラーメッセージがコンパイラから出力されるため、ユーザーがエラーの原因を特定しやすくなります。
コード再利用性の向上
要件をコンセプトとして抽出することで、複数の場所で再利用できるようになります。
拡張性の向上
新しいコンセプトを定義して要件を追加することで、ジェネリックコードの用途を広げることができます。

コンセプトの種類[編集]

C++20では、様々なコンセプトが事前に定義されています。主なコンセプトは以下のようにカテゴリ分けされます。

簡単なコンセプト(EqualityComparable、LessThanComparable など)[編集]

簡単なコンセプトは、型が等値比較や大小比較をサポートしているかどうかを表します。代表的なコンセプトは以下の通りです。

EqualityComparable
==!=演算子がサポートされている型
EqualityComparableWith
2つの型間で==!=演算子がサポートされている
LessThanComparable
<演算子がサポートされている型
LessThanComparableWith
2つの型間で<演算子がサポートされている

これらのコンセプトは、ソート済みコンテナやヒープ実装などで使用されます。

コンテナ向けのコンセプト(Sequence、AssociativeContainer など)[編集]

コンテナ向けのコンセプトは、型がコンテナの要件を満たしているかどうかを表します。代表的なコンセプトは以下の通りです。

Sequence
順序付きコンテナ(vector、dequeなど)の要件を満たす型
RandomAccessRange
ランダムアクセスイテレータをサポートする範囲
AssociativeContainer
関数射影によって値にアクセスできるコンテナ(map、setなど)の要件を満たす型
UnorderedAssociativeContainer
ハッシュ関数を使用する連想コンテナの要件を満たす型

これらのコンセプトは、コンテナをパラメータ化した関数テンプレートで使用されます。

イテレータ向けのコンセプト(InputIterator、BidirectionalIterator など)[編集]

イテレータ向けのコンセプトは、イテレータの種類に応じた要件を表します。代表的なコンセプトは以下の通りです。

InputIterator
単方向イテレータの要件を満たすイテレータ
OutputIterator
出力イテレータの要件を満たすイテレータ
ForwardIterator
前方向イテレータの要件を満たすイテレータ
BidirectionalIterator
双方向イテレータの要件を満たすイテレータ
RandomAccessIterator
ランダムアクセスイテレータの要件を満たすイテレータ

これらのコンセプトは、イテレータをパラメータ化した関数テンプレートで使用されます。たとえば、std::copystd::sortがその例です。

その他のコンセプト(Callable、Predicate など)[編集]

その他にも、様々なコンセプトが用意されています。

Callable
関数呼び出し可能な型
Predicate
述語(bool値を返す関数オブジェクト)の要件を満たす型
Range
範囲の要件を満たす型
Semiregular
コピー構築可能でデフォルト構築可能な型
Regular
値の等値比較ができてコピー代入可能な型

これらのコンセプトは、アルゴリズムやユーティリティ関数で広く使われています。たとえば、std::all_ofstd::find_ifではPredicateコンセプトが使用されています。

コンセプトの定義方法[編集]

概念的制約(concept constraints)の記述[編集]

コンセプトは、型が満たすべき一連の制約条件を表します。これらの制約条件は、概念的制約(concept constraints)と呼ばれます。概念的制約は以下のように記述されます。

template<typename T>
concept concept_name = /* 制約条件 */;

制約条件には、型が満たすべき要件を論理式で記述します。論理式では、型トレイト、型の有する演算子、型メンバ関数の有無などを使って要件を表現します。

例えば、EqualityComparableコンセプトは次のように定義されています。

template<class T>
concept EqualityComparable = requires(T a, T b) {
    { a == b } -> Boolean; // a == bの結果はブール値に変換可能
    { a != b } -> Boolean; // a != bの結果もブール値に変換可能
};

この定義では、型Tが等値比較演算子==!=をサポートしている必要があることを表しています。

コンセプト定義の構文[編集]

コンセプトは、以下の構文で定義されます。

concept concept_name = constraint_expression;
  • concept_nameはコンセプトの名前です。
  • constraint_expressionは、型が満たすべき制約条件を表す論理式です。

制約条件には、以下のような要素が含まれます。

型トレイト
is_integral<T>, is_default_constructible<T> など
簡単な要件
sizeof(T) <= 16, has_virtual_destructor<T> など
合成要件
EqualityComparable<T>, std::derived_from<T, Base> など
型の有する演算子
a == b, a != b, a < b など
型メンバ関数の有無
a.empty(), a.size(), T::value_type など

これらの要素を組み合わせて、必要な制約条件を表現します。

コンパウンドコンセプト(compound concepts)[編集]

コンセプトは他のコンセプトから合成することもできます。このようなコンセプトをコンパウンドコンセプト(compound concepts)と呼びます。

コンパウンドコンセプトは、以下のように&&||!を使って定義されます。

template<class T>
concept C = /* 制約条件1 */ && /* 制約条件2 */;

template<class T, class U>  
concept D = /* 制約条件1 */ || /* 制約条件2 */;

template<class T>
concept E = /* 制約条件 */ && !C<T>;

コンパウンドコンセプトを使うことで、必要な制約条件を簡潔に表現できます。

コンセプトの使用例[編集]

関数テンプレートでのコンセプトの利用[編集]

コンセプトは主に関数テンプレートでの型制約に使われます。関数テンプレートの型パラメータにコンセプトを指定することで、その関数が要求する制約条件を明示的に表現できます。

template<EqualityComparable T>
void f(T a, T b) {
    if (a == b) {
        /* ... */
    }
}

この例では、EqualityComparableコンセプトを使って、関数fが等値比較をサポートする型のみを受け入れることを示しています。型TEqualityComparableの制約条件を満たさない場合、コンパイルエラーになります。

コンセプトは関数パラメータの制約にも使えます。

template<InputIterator Iter, Predicate<typename std::iterator_traits<Iter>::value_type> Pred>
Iter find_if(Iter first, Iter last, Pred pred) {
    /* ... */
}

この例では、InputIteratorPredicateの2つのコンセプトを使っています。Iterは入力イテレータの要件を、PredIterの要素型に対する述語の要件を満たす必要があります。

クラステンプレートでのコンセプトの利用[編集]

コンセプトはクラステンプレートでも使用できます。これにより、クラステンプレートがテンプレート引数として受け入れる型を制限できます。

template<RandomAccessIterator Iter, Semiregular T>
class vector {
    /* ... */
};

この例では、vectorクラステンプレートは、RandomAccessIterator要件を満たすイテレータ型とSemiregular要件を満たす値型のみを受け入れます。

クラステンプレートのメンバ関数にもコンセプトを使うことができます。

template<AssociativeContainer C>
class X {
public:
    template<typename K, typename V> 
        requires Semiregular<std::pair<const K, V>>
    void insert(K&& key, V&& value) {
        /* ... */
    }
};

この例では、insertメンバ関数は、std::pair型がSemiregular要件を満たす場合にのみ呼び出せます。

演算子オーバーロードでのコンセプトの利用[編集]

コンセプトは演算子のオーバーロード関数でも使えます。これにより、特定の型に対してのみ演算子をオーバーロードできます。

template<RegularInvocable BinaryOp, typename T>
constexpr T foldl(BinaryOp op, T init, const std::vector<T>& v) {
    for (const auto& x : v) {
        init = op(init, x); // op(T, T) -> Tが成り立つ必要がある
    }
    return init;
}

この例では、foldl関数の引数opRegularInvocableコンセプトを満たす必要があります。つまり、opは2つのT型の引数を取り、T型の値を返す二項演算である必要があります。このようにコンセプトを使うことで、安全性が高まります。

コンセプトによるエラーメッセージの改善[編集]

コンセプトを使うとエラーメッセージが改善され、ユーザーがエラーの原因を特定しやすくなります。次の例を見てみましょう。

template<typename T>
concept C = /* ... */;

template<typename U>
  requires C<U>
void f(U u) {
    /* ... */
}

struct X {};

int main() {
    X x;
    f(x); // エラー! Xは Cの要件を満たしていない
}

この場合、コンパイラは次のようなエラーメッセージを出力するでしょう。

</syntaxhighlight> error: constraints not satisfied for 'f(X) [with U=X]' note: because 'X' does not satisfy 'C' </syntaxhighlight>

このエラーメッセージから、関数fの制約条件CXが満たしていないことが分かります。つまり、この関数呼び出しがコンパイルエラーになった理由が明確に示されています。

対照的に、コンセプトを使わない場合のエラーメッセージは以下のようになります。

template<typename T, 
         typename = std::enable_if_t</* 制約条件 */>>
void f(T t) {
    /* ... */
}

error: no matching function for call to 'f(X)'

この場合、エラーの原因があまり明確ではありません。コンパイラは単に関数fの呼び出しがマッチしないと報告するだけです。制約条件を満たしていない理由は分かりません。

コンセプトを使うことで、エラーメッセージが大幅に改善され、ユーザーがエラーの原因を特定しやすくなります。これは、ジェネリックコードのデバッグを容易にするという重要な利点です。

コンセプトとtypedefs/usingの比較[編集]

古典的なテンプレートプログラミングとの違い[編集]

C++20以前のテンプレートプログラミングでは、テンプレート引数の型制約を表すためにtypedefsusingエイリアスが使われていました。

// typedefs/usingによる制約の表現
template<typename T>
using IsIntegral = std::is_integral<T>::value;

template<typename T>
using IsEqualityComparable = std::is_same<typename std::decay<T>::type, T>::value &&
                              std::is_default_constructible<T>::value &&
                              /* ... */;

// 制約付きのテンプレート
template<typename T, 
         typename = std::enable_if_t<IsIntegral<T>>>
T add(T a, T b) {
    return a + b;
}

このアプローチには以下の問題がありました。

  • 制約条件を表す型特性を明示的に定義する必要がある
  • エラーメッセージが分かりにくい
  • テンプレートの意図が明確でない

コンセプトの導入により、これらの問題が解決されました。コンセプトでは制約条件を直接的に表現でき、意図が明確になり、分かりやすいエラーメッセージが出力されるようになりました。

コンセプトの長所と短所[編集]

長所
制約条件を直接的に表現できる
コードの意図が明確になる
分かりやすいエラーメッセージが得られる
コンセプトの再利用が可能
コンパウンドコンセプトで複雑な制約を表現できる
短所
C++20以降の機能なので、古いコンパイラでは使用できない
制約条件の記述が複雑になる可能性がある
コンセプトの導入によるコードサイズの増加

全体として、コンセプトの導入によりジェネリックプログラミングが大きく改善されました。コンセプトを上手く活用することで、安全で expressivenessの高いコードを書くことができます。一方で、制約条件の記述が複雑になる可能性や、一時的な最適化の問題が生じる場合があることに注意が必要です。

コンセプトとモジュール[編集]

モジュールでのコンセプトのexport/import[編集]

C++20のモジュールシステムでは、コンセプトをモジュール間で export/import できます。これにより、コンセプトの再利用性が高まります。

モジュールインターフェース(.cppm)ファイルでコンセプトをエクスポートするには、以下のようにします。

// concepts.cppm
export module concepts;

export concept EqualityComparable<typename T> { ... }
export concept Range<typename T> { ... }

別のモジュールからこれらのコンセプトをインポートするには、以下のようにします。

// algorithms.cppm 
import concepts;

export template<EqualityComparable T>
bool is_permutation(const Range<T>& a, const Range<T>& b) { ... }

モジュールでコンセプトをエクスポート/インポートすることで、プロジェクト全体で一貫したコンセプトの定義と使用が可能になります。

モジュールとコンセプトの相互作用[編集]

モジュールは、コンセプトと密接に関係しています。モジュールのインターフェースでコンセプトを宣言すると、そのモジュールの実装ファイル内でのみ、そのコンセプトの定義を記述できます。

// concepts.cppm  (モジュールインターフェース)
export module concepts;
export concept EqualityComparable<typename T>;

// concepts.cpp (モジュール実装)  
module concepts;
template<typename T>
concept EqualityComparable = /* 定義 */;

このように、モジュールとコンセプトを組み合わせることで、コンセプトの可視性とカプセル化が実現できます。

モジュールは、コンパートメント化された独立したコンテキストを提供します。つまり、同じ名前のコンセプトが異なるモジュールで定義されていても、それぞれのモジュールで独立して扱われます。これにより、名前の衝突を避けることができます。

まとめ[編集]

コンセプトは、C++20で導入された強力な機能です。コンセプトを使うことで、ジェネリックプログラミングの安全性と expressivenessが大幅に向上しました。コンセプトの主な利点は以下の通りです。

  • コンパイル時の型検査が可能になり、実行時エラーを減らせる
  • コードの意図が明確になる
  • エラーメッセージが分かりやすくなる
  • コード再利用性が向上する
  • 新しい要件を追加しやすくなり、拡張性が高まる

一方で、コンセプトの記述が複雑になる可能性や、一時的な最適化の問題が生じる場合があるため、注意が必要です。

今後、コンパイラの最適化が進めば、これらの短所は軽減されていくことが期待されます。モジュールシステムとの連携により、コンセプトの再利用性と一貫性も高まるでしょう。コンセプトは、C++がよりモダンでクリーンなジェネリックプログラミングを実現する上で、重要な役割を果たすことになるでしょう。