LoginSignup
7
10

More than 3 years have passed since last update.

【C++20】結局conceptは何が便利なのか

Posted at

conceptとは

 長年C++で議論が続けられてきたconceptですが、ついにC++20で導入されることになりました。簡単に言えばconceptはテンプレートパラメータを制限する機能ですが、そういわれてもどういう場面で使うのかピンとこないと思います。私も最初は何のためにある機能なのかよくわかっていませんでしたが、いろいろ試しているうちに何となくわかったかなという気がするので記事にします。conceptの使いどころを紹介する前に、まずはconceptの基本的な文法等をざっと紹介したいと思います。

requiresの使い方1

 conceptはテンプレートパラメータを 一つ以上 持ち、テンプレートパラメータの 第一引数 に関する制約を表します。boolに評価されるメタ関数の一種でもあります。conceptを定義するにはコンパイル時にboolに評価される式を使います。conceptを定義するうえで、特によく使うのがrequires式で、式の中に制約を箇条書きにすることができ、さらにこれは制約を満たすかどうかのbool値を返す式になります。

#include <cassert>

template<typename T> constexpr bool b = requires { // 制約を満たすなら true 満たさないなら false 
  typename T::type;           // 型 T がメンバ型に type を持つという制約
};

template<typename T> concept C1 = b<T>;  // "型 T が b<T> == true を満たす" という制約

template<typename T> concept C2 = requires {  // 直接 requires を書いても良い。C1 と同義。
  typename T::type;
};

struct S { using type = int; };

int main() {
  static_assert(b<S>);      // S は type をメンバ型に持つので b<S> == true
  static_assert(!b<int>);   // int は type をメンバ型に持たないので b<int> == false

  static_assert(C1<S>);     // concept も bool に評価できる
  static_assert(!C1<int>);

  static_assert(C2<S>); 
  static_assert(!C2<int>);
  return 0;
}

 また、requires式は引数のようなものを取ることができます。この引数は型情報のみが使用され、実際に評価はされません。これを使うことでメンバ変数やメンバ関数、非メンバ関数に関する制約を書きやすくなります。また、関数の戻り値も制約でき、これはconceptを用いて書けます。conceptの 第一テンプレート引数が戻り値に充てられる ことに注意してください。

#include <type_traits>
#include <vector>
#include <list>

template<typename T, typename U> concept C1 
  = std::is_convertible_v<T, U>; // "型 T が型 U へキャスト可能か" という制約。型 T に対する制約であることに注意

template<typename T> concept C2 = requires (T a, int b) {
  a.push_back(b);      // "型 T がメンバ関数に push_back を持つ" という制約
  { a[b] } -> C1<int>; // "型 T に operator[] があり、C1<decltype(a[b]), int> == true" という制約
  std::size(a);        // "非メンバ関数 std::size が引数に型 T のオブジェクトを受け取れる" という制約
};

int main() {
  static_assert(C2<std::vector<int>>);  // 制約を満たす
  static_assert(!C2<std::list<int>>);   // 制約を満たさない (operator[] がない)
  return 0;
}

requiresの使い方2

 requires には bool を返すメタ関数を定義する以外にテンプレートパラメータを制限する使い方もあります。requiresの後にboolに評価される式を置くことで、テンプレートパラメータを制限します。

#include <type_traits>
#include <utility>

// 関数テンプレートの制限
template<typename T> requires std::is_convertible_v<T, int> int cast_to_int(T a) {
  return static_cast<int>(a);
}

template<typename T> concept C = std::is_pointer_v<T>;
template<typename T> requires C<T> bool nullcheck(T ptr) {
  return ptr != nullptr;
}

// 最初の requires はテンプレートパラメータを制限し、二つ目の requires は bool を返すメタ関数を定義する
template<typename T> requires requires(T t) { t.first; }  // "メンバ変数に t.first を持つ" という制約
auto first(T x) {
  return x.first;
} 

// テンプレートクラス/構造体も制限可能
template<typename T> requires std::is_integral_v<T> struct S {
  T t = static_cast<T>(0);
};

int main() {
  cast_to_int('C');         // char は制約を満たすので OK
  // cast_to_int(nullptr);  // これは制約を満たさないのでコンパイルエラー

  nullcheck("C++");       // const char* は制約を満たすので OK
  // nullcheck('C');      // char は制約を満たさないのでエラー

  first(std::make_pair(42, 'C'));  // OK
  // first('C');                   // エラー

  S<unsinged> s1;    // OK
  // S<float> s2;    // エラー
  return 0;
}

より簡潔に

 requiresを使う以外にもconceptを用いてテンプレートパラメータを制限する方法があります。こちらの方が一般的にはきれいに書けます。

#include <type_traits>

template<typename T> concept integral_c = std::is_integral_v<T>;
template<integral_c T, integral_c U>    // integral_c<T> == integral_c<U> == true となる T, U のみを許可
auto add(T t, U u) {
  return t + u;
}

template<typename T, typename U> concept convertible_to_c = std::is_convertible_v<T, U>;
template<convertible_to_c<int> T>       // convertible_to_c<T, int> == true となる T のみを許可
int cast_to_int(T a) {
  return a;
}

template<integral_c T> struct S {    // テンプレートクラス/構造体も同様に制限可能
  T t = static_cast<T>(0);
};

int main() {
  add(42, 420LL);      // OK
  // add(42, 4.2);     // NG

  cast_to_int('a');         // OK
  // cast_to_int(nullptr);  // NG

  S<unsigned> s1;   // OK
  // S<float> s2;   // NG
  return 0;
}

 さらにテンプレート関数はautoを使ってもっと簡潔に制限できます。

#include <type_traits>

template<typename T> concept integral_c = std::is_integral_v<T>;
auto add(integral_c auto t, integral_c auto u) {  // 先ほどのadd関数と全く同義。引数のautoはテンプレートパラメータとみなされる
  return t + u;
}

int main() {
  add(42, 420LL);     // OK
  // add(42, 4.2);    // NG
  return 0;
}

何の役に立つの?

 さて、長い前座が終わりここからが本題ですが、いったい何の役に立つのかを見ていきたいと思います。よく言われる利点の一つとしてエラーメッセージが読みやすいということがあります。確かにエラーメッセージは読みやすくはなります。しかし、それ以上の恩恵がconceptにはあります。それは オーバーロード解決やconstexpr ifと組み合わせたとき に発揮されます。下のコード例を見てみましょう。

#include <vector>
#include <list>

template<typename T> concept forward_iterator_c = requires(T it1, T it2) {
  // 本来は戻り値に関する制約もかけるべきだが、とりあえず省略。他の細かい部分も省略。
  it1++;                ++it1;                     // イテレーション
  *it1;                                            // 参照
  it1 = it2;            it1 = std::move(it2);      // 代入
  T(it1);               T(std::move(it1));         // コンストラクタ
  it1 == it2;           it1 != it2;                // 等値比較
};

template<typename T> concept random_access_iterator_c = forward_iterator_c<T>
  && requires (T it1, T it2, int a) {
  it1 + a;              it1 - a;                   // ランダムイテレーション
  it1 - it2;                                       // 差分
  it1 < it2;            it1 > it2;                 // 比較
  it1 <= it2;           it1 >= it2;                // 比較
};

template<forward_iterator_c FwdIt> int count(FwdIt first, FwdIt last) {        // 1
  int sum = 0;
  for (FwdIt it = first; it != last; ++it) { sum++; }
  return sum;
}

template<random_access_iterator_c RndIt> int count(RndIt first, RndIt last) {  // 2
  return last - first;
}

int main() {
  std::vector<int> vec(10);
  std::list<int> li(10);
  count(std::begin(vec), std::end(vec));   // 2 の count が呼ばれる
  count(std::begin(li), std::end(li));     // 1 の count が呼ばれる
  return 0;
}

 上のコードでは2のcountは1のcountよりも厳しい制約が課されているのがわかります。これによって、2が1の部分特殊化になっているとみなされ、制約を満たすかどうかでオーバーロード解決が行われます。そのため、 イテレータがランダムアクセスイテレータかフォワードイテレータかで分岐させることができる ようになるという仕組みです。もちろんconstexpr ifを使って、以下のようなコードも書けます。私は下の書き方の方が好みです。

template<forward_iterator_c FwdIt> int count(FwdIt first, FwdIt last) {
  if constexpr (random_access_iterator_c<FwdIt>) {
    return last - first;
  }
  else {
    int sum = 0;
    for (FwdIt it = first; it != last; ++it) { sum++; }
    return sum;
  }
}

 このように、イテレータのような似たインターフェイスを持つものに異なる挙動をさせたい時にconceptは非常に役に立ちます。これは 継承やポリモーフィズムの概念に似たもの を感じますね。というわけで、継承やポリモーフィズムを使ったコードとconceptを使ったコードを見比べてみましょう。

#include <iostream>

/* ポリモーフィズムと継承 */
struct PolymorphicCharacter {    // ベースとなるインターフェイス
  virtual ~PolymorphicCharacter() noexcept = default;
};
struct PolymorphicNPC : PolymorphicCharacter {  // インターフェイス 1
  virtual void action() const = 0;
  ~PolymorphicNPC() noexcept override = default;
};
struct PolymorphicEnemy : PolymorphicCharacter {  // インターフェイス 2
  virtual void attack() const = 0;
  ~PolymorphicEnemy() noexcept override = default;
};
struct PolymorphicCat : PolymorphicNPC {  // NPCにしかならない
  void action() const override { std::cout << "こたつでポリモーフィックに寝る" << std::endl; }
  ~PolymorphicCat() noexcept override = default;
};
struct PolymorphicDog : PolymorphicNPC, PolymorphicEnemy {  // NPCにもEnemyにもなりうるCharacter
  void action() const override { std::cout << "棒にポリモーフィックに当たる" << std::endl; }
  void attack() const override { std::cout << "棒でポリモーフィックに叩く" << std::endl; }
  ~PolymorphicDog() noexcept override = default;
};
struct PolymorphicMonkey : PolymorphicEnemy {  // Enemyにしかならない
  void attack() const override { std::cout << "木からポリモーフィックに落とす" << std::endl; }
  ~PolymorphicMonkey() noexcept override = default;
};
void polymorphism_action_n_times(const PolymorphicNPC& npc, int n) {
  for (int i = 0; i < n; i++) { npc.action(); }
}
void polymorphism_attack_n_times(const PolymorphicEnemy& enemy, int n) {
  for (int i = 0; i < n; i++) { enemy.attack(); }
}

/* concept */
template<typename T> concept ConceptCharacter = true;   // ベースとなるインターフェイス
template<typename T> concept ConceptNPC = ConceptCharacter<T> && requires(T npc) { npc.action(); };  // インターフェイス 1
template<typename T> concept ConceptEnemy = ConceptCharacter<T> && requires(T enemy) { enemy.attack(); };  // インターフェイス 2
struct ConceptCat {   // NPCにしかならない
  void action() const { std::cout << "こたつでコンセプチュアルに寝る" << std::endl; }
};
struct ConceptDog {   // NPCにもEnemyにもなりうるCharacter
  void action() const { std::cout << "棒にコンセプチュアルに当たる" << std::endl; }
  void attack() const { std::cout << "棒でコンセプチュアルに叩く" << std::endl; }
};
struct ConceptMonkey {  // Enemyにしかならない
  void attack() const { std::cout << "木からコンセプチュアルに落とす" << std::endl; }
};
void concept_action_n_times(const ConceptNPC auto& npc, int n) {
  for (int i = 0; i < n; i++) { npc.action(); }
}
void concept_attack_n_times(const ConceptEnemy auto& enemy, int n) {
  for (int i = 0; i < n; i++) { enemy.attack(); }
} 

int main() {
  PolymorphicCat pcat;
  PolymorphicDog pdog;
  PolymorphicMonkey pmon;
  polymorphism_action_n_times(pcat, 3);
  polymorphism_action_n_times(pdog, 3);
  polymorphism_attack_n_times(pdog, 3);
  polymorphism_attack_n_times(pmon, 3);

  ConceptCat ccat;
  ConceptDog cdog;
  ConceptMonkey cmon;
  concept_action_n_times(ccat, 3);
  concept_action_n_times(cdog, 3);
  concept_attack_n_times(cdog, 3);
  concept_attack_n_times(cmon, 3);
  return 0;
}

 長い例になってしまいましたが、どちらも似たようなことを実現できていますね。しかし、この二つには決定的な違いがあります。ポリモーフィズムは呼び出す仮想関数を 動的に(実行時に) 決定するので、実行時の時間コストがかかります。それに対して、conceptは、関数を 静的に(コンパイル時に) 決定するので、実行時のコストは最小限に抑えられます。そのかわり、コンパイルしたときのバイナリファイルの大きさは大きくなります。また、conceptを用いると、ポリモーフィズムでいうオブジェクトのポインタや参照に子クラスのオブジェクトを入れるみたいなことはできなくなるので注意が必要です。それでも問題がない場合は積極的にconceptを使った方が良いでしょう。
 さらにconceptを使うと 厄介な多重継承が回避できる というのも大きいと思います。もちろん多重継承を使うことは悪いことではありませんが、できれば使いたくはないものです。

終わりに

 このようにconceptは継承とポリモーフィズムに取って代わるとまでは言いませんが、継承やポリモーフィズムを使う前にどのような実装を行うかの選択肢の候補にはなるでしょう。用途をよく考えてconceptを取り入れれば、実行速度の向上や、可読性の向上につながるはずです。用法容量を守って適切に楽しくconceptを使いましょう。

おしまい。

7
10
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
10