1. サイトトップ
  2. ブログ
  3. C++
  4. C++20でConceptを使ってみる

C++20でConceptを使ってみる

こんにちは。情熱開発部プログラマの竹下です。
9月になり、夏のイベントが一通り終わりましたが、皆様は夏を満喫できましたでしょうか?
私はスイカや桃など夏が旬のフルーツをたくさん食べられて、とても満喫できました。
まだ暑い日が続いておりますが、秋も近づいているので、次はマスカットを食べたいと思っております。
そんなマスカットのおいしさについても語りたいところですが、、、

今回はC++20で追加されたConceptを紹介していきます。

※本記事では以下のバージョンを使用しています。
VisualStudio:2022

C++:20

Conceptとは

ConceptはC++20で追加された機能で、テンプレートの型に対して「満たすべき条件を明示的に定義する」機能です。
C++17以前の似た機能としてはSFINAEにあたり、テンプレート型によってコンパイル時に処理分岐を行う際に使用します。

Conceptのメリット

Conceptを使用するメリットをC++17のSFINAEと比べて紹介いたします。

可読性があがる

1つ目はSFINAEより簡潔に書くことができます。

// クラスがoperator deleteを持っているか判定
// C++17
template<typename, typename = void> struct has_class_delete : std::false_type {};
template<typename T>
struct has_class_delete<T, void_t<decltype(static_cast<void(*)(void*)>(&T::operator delete))>> : std::true_type {};

// C++20
template<typename T>
concept HasClassDelete = requires {
	{ &T::operator delete };
};

また、Conceptは使いまわすことが出来るため、一目で同じテンプレート型の制約をしていることが分かります。

// C++17
template <typename T> std::enable_if_t < std::is_same_v<std::remove_cv_t<T>, void*>> input(T value);
template <typename T> std::enable_if_t < std::is_same_v<std::remove_cv_t<T>, void*>> output(T value);

// C++20
template<typename T>
concept IsVoidPointer = std::is_same_v<std::remove_cv_t<T>, void*>;

template <IsVoidPointer T> void input(T value);
template <IsVoidPointer T> void output(T value);

エラー表示の見やすさ

2つ目のメリットは、エラー表示が短く明確になりました。
SFINAEではテンプレートの展開を最後まで行ってから失敗していたのに対して、Conceptはテンプレートの選択前に評価されるため、簡潔にエラーが表示されることが多くなりました。
VisualStudioではConceptのエラーは「関連する制約が満たされていません。HasPrint<B&>がfalseと評価されました。」と明確に表示されて読みやすくなっていることが分かります。

Conceptの実装方法

まず、簡単なConceptの宣言を紹介します。

ポインタの制約

// ポインタの制約
template<typename T>
concept Pointer = std::is_pointer_v<T>;
template<typename T>
concept NotPointer = !Pointer<T>;

// Tがポインタの時に通る関数
template<Pointer T> static void output(T ptr)
{
    std::cout << "ポインタです" << *ptr << "\n";
}

// Tがポインタ以外の時に通る関数
template<NotPointer T> static void output(T ptr)
{
    std::cout << "実体型です" << ptr << "\n";
}

int main()
{
    // ポインタ
    int* poionter = new int(50);
    output(poionter);

     // 実体型
    int nonPoionter = int(100);
    output(nonPoionter);
}
ポインタです50
実体型です100

特定のメンバー関数の制約

次に、詳細な制約を設定する場合はrequiresを使用します。
特定の関数を持っているか判別するような場合は、以下のように書きます。

// requiresを使用したconcept宣言
template<typename T>
concept HasOutputClass = requires {
    { &T::output };
};

// conceptで制約した関数
template <HasOutputClass T>
static void output(T value)
{
    std::cout << "output関数があります" << "\n";
    value.output();
}
template <typename T>
static void output(T value)
{
    std::cout << "output関数がありません" << "\n";
}

// output関数を持つクラス
class HasOutput
{
public:
    HasOutput() {}
    ~HasOutput() {};
    void output()
    {
        std::cout << "HasOutput::output()が呼ばれました" << std::endl;
    }
};
// output関数を持たないクラス
class NonOutput
{
public:
    NonOutput() {}
    ~NonOutput() {}

};

int main()
{
    // output関数を持つ
    HasOutput hasFunc;
    output(hasFunc);

     // output関数を持たない
    NonOutput nonFunc;
    output(nonFunc);
}
output関数があります
HasOutput::output()が呼ばれました
output関数がありません

複数の制約

また、複数の条件を組み合わせた制約の設定が可能です。

// ポインタでoutput関数を持つconcept
template<typename T>
concept HasOutputClassPointer = 
    std::is_pointer<T>::value &&
    requires(T t) {{ t->output() } -> std::same_as<void>;};

// 関数宣言
template <HasOutputClassPointer T>
static void output(T value)
{
    std::cout << "output関数があります" << "\n";
    value->output();
}
template <typename T>
static void output(T value)
{
    std::cout << "output関数がありません" << "\n";
}

// output関数を持つクラス
class HasOutput
{
public:
    HasOutput() {}
    ~HasOutput() {};
    void output()
    {
        std::cout << "HasOutput::output()が呼ばれました" << std::endl;
    }
};
// output関数を持たないクラス
class NonOutput
{
public:
    NonOutput() {}
    ~NonOutput() {}

};

int main()
{
    // output関数を持つポインタ
    HasOutput* hasFuncPointer = new HasOutput();
    output(hasFuncPointer);

    // output関数を持つ実体
    HasOutput hasFunc;
    output(hasFunc);

     // output関数を持たない
    NonOutput nonFunc;
    output(nonFunc);
}
output関数があります
HasOutput::output()が呼ばれました
output関数がありません
output関数がありません

SFINAEをConceptに置き換える

下記のC++17のSFINAEで実装した、配列newで確保したメモリを開放する関数をConceptでの実装に置き換えてみます。

// テンプレートクラスがoperator deleteをオーバーライド
template<typename, typename = void> struct has_class_delete : std::false_type {};
template<typename T>
struct has_class_delete<T, void_t<decltype(static_cast<void(*)(void*)>(&T::operator delete))>> : std::true_type {};

// クラスにdeleteを持っていない&&デストラクタを持っている
template<class T>
static	std::enable_if_t<!has_class_delete<T>::value&& std::is_trivially_destructible<T>::value>
freeArrayEx(T* ptr);

template<class T>// クラスにdeleteを持っていない&&デストラクタを持っていない
static	std::enable_if_t<!has_class_delete<T>::value && !std::is_trivially_destructible<T>::value>
freeArrayEx(T* ptr);

template<class T>// クラスにdeleteを持っている
static	std::enable_if_t<has_class_delete<T>::value>
freeArrayEx(T* ptr);

// void*用
static	void
freeArrayEx(void* ptr);

まず、operator deleteとデストラクタのそれぞれの制約を作成します。

// クラスがoperator deleteを持っているか判定
template<typename T>
concept HasClassDelete = requires {
	{ &T::operator delete };
};

// デストラクタ判別用
template<typename T>
concept Destructible = std::is_destructible_v<T>;

次に関数分岐用の制約を作成します。

// delete関数が無くデストラクタを持っている
template<typename T>
concept HasDestructible = !HasClassDelete<T> && Destructible<T>;

// delete関数が無くデストラクタを持っていない
template<typename T>
concept NonDestructible = !HasClassDelete<T> && !Destructible<T>;

// void*
template<typename T>
concept IsVoidPointer = std::is_same_v<std::remove_cv_t<T>, void*>;

そして各関数を宣言します。

// デストラクタを持っている
template<HasDestructible T>
void FreeArrayEx(T* ptr);

// デストラクタを持っていない
template<NonDestructible T>
void FreeArrayEx(T* ptr);

// クラスにdeleteを持っている
template<HasClassDelete T>
void FreeArrayEx(T* ptr);

// void*用
template<IsVoidPointer T>
void FreeArrayEx(T* ptr);

Conceptに置き換えることで、関数宣言が簡潔になり、一目で制約されている内容が分かりやすくなりました。

注意点(ネストの深さ)

Conceptを使用するにあたり注意点があります。
Concept宣言の中でConceptの入れ子が重なりネストが深くなると、可読性やエラー表示の見やすさが下がる可能性があります。
個人的には、ネストの深さは最大でも3階層までにしておくのが良さそうでした。

template<typename T>
concept Level1 = requires { typename T::type; };

template<typename T>
concept Level2 =
    Level1<T> &&
    requires(T t) {{ t.value } -> std::convertible_to<int>;};

template<typename T>
concept Level3 = 
    Level2<T> &&
    requires(T t) {typename T::Nested;};

template<typename T>
concept Level4 = 
    Level3<T> &&
    requires(T t) {{ t.deepFunction() } -> std::same_as<void>;};

template<typename T>
concept Level5 = 
    Level4<T> &&
    requires(T t) {{ t.finalStep() } -> std::same_as<int>;};

まとめ

C++20で追加された、Conceptを使用することで、汎用性の高さを維持したまま、可読性と保守性が高まることが分かりました。

C++17のSFINAEは条件文が長くなりがちでしたので、読みやすくなり置換もそこまで手間がかからないので良いと感じました。
また、実装者以外が関数宣言を一目見ただけで何をしている関数かが分かりやすくなっていたので、注意点に気を付けつつ積極的に使っていきたいと思います。
皆様も機会があればぜひ使ってみてください!


【免責事項】

本サイトでの情報を利用することによる損害等に対し、
株式会社ロジカルビートは一切の責任を負いません。