[C++]カスタマイゼーションポイントオブジェクト(CPO)概論

C++20以降の必須教養となるであろうカスタマイゼーションポイントオブジェクトですが、その利便性の高さとは裏腹に理解が難しいものでもあります。これはその理解の一助となるべく私の頭の中の理解を書き出したメモ帳です。

C++17までのカスタマイゼーションポイントの問題点

C++17までにカスタマイゼーションポイントとなっていた関数(例えばstd::begin()/std:::end(), std::swap()など)にはアダプトして動作をカスタマイズするためのいくつかの方法が用意されており、より柔軟に自分が定義した型を適合できるようになっています。しかしその一方で、それによって使用するときに少し複雑な手順を必要としていました。例えばstd::begin()で見てみると

// イテレート可能な範囲を受けて何かする関数
template<typename Container>
void my_algo(Container&& rng) {
  using std::begin;

  // 先頭イテレータを得る
  auto first = begin(rng);
}

真にジェネリックに書くためにはこのように「std::begin()usingしてから、begin()名前空間修飾なしで呼び出す」という風に書くことで、std名前空間のもの及び配列にはstd::begin()が、ユーザー定義型に対しては同じ名前空間内にあるbegin()あるいはstd::begin()を通してメンバ関数begin()が呼び出されるようになります。しかし、手順1つ間違えただけでそのbegin()の呼び出しはたちまち汎用性を失います。これはstd:::end(), std::swap()等他のカスタマイゼーションポイントでも同様です。

C++17までのカスタマイゼーションポイントにはこのように、その正しい呼び出し方法が煩雑でそれを理解するにはC++を深めに理解する事が求められるなど、使いづらいという問題があります。

また、このようなカスタイマイゼーションポイントは標準ライブラリをよりジェネリックにするために不可欠な存在ですが、標準ライブラリはそのカスタマイゼーションポイントの名前(関数名)だけに着目して呼び出しを行うため、同名の全く異なる意味を持つ関数が定義されていると未定義動作に陥ります。特に、ADLが絡むとこれは発見しづらいバグを埋め込む事になるかもしれません。したがって、カスマイゼーションポイントを増やすと言う事は実質的に予約されている名前が増える事になり、ユーザーは注意深く関数名を決めなければならないなど負担を負うことになります。

C++20からのコンセプトはそのような問題を解決します。その呼び出しにおいてコンセプトを用いて対象の型が制約を満たしているかを構文的にチェックするようにし、カスタマイゼーションポイントに不適合な場合はオーバーロード候補から外れるようにする事で、ユーザーがカスタマイゼーションポイントとの名前被りを気にしなくても良くなります。結果的に、標準ライブラリにより多くのカスタマイゼーションポイントを設ける事ができるようになります。

しかし、コンセプトによって制約されたC++20カスタマイゼーションポイントの下では、先程のC++17までのカスタマイゼーションポイント使用時のベストプラクティスコードがむしろ最悪のコードになってしまうのです。

namespace std {

  // rangeコンセプトを満たす型だけが呼べるように制約してある新しいbegin()関数とする
  template<std::ranges::range C>
  constexpr auto begin(C& c) -> decltype(c.begin());  // (1)
}

namespace myns {

  struct my_struct {};

  // イテレータを取得するものではないbegin()関数
  bool begin(my_struct&);  // (2)
}


template<typename Container>
void my_algo(Container&& rng) {
  using std::begin;

  // 先頭イテレータを得る、はずが・・・
  auto first = begin(rng);  // my_structに対しては(2)が呼び出される
}

int main() {
  myns::my_struct st{};

  my_algo(st);  // ok、呼び出しは適格
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

このように、せっかくコンセプトで制約したにも関わらずADL経由で制約を満たさないbegin()が呼ばれています。別の見方をすれば、コンセプトによる制約を簡単に迂回できてしまっています。

これでは結局ユーザーはカスタマイゼーションポイント名を気にしてコードを書かなければならなくなるし、カスタマイゼーションポイントがコンセプトによって制約してあっても意味がなくなってしまいます・・・・

Customization Point Object(CPO)

カスタマイゼーションポイントオブジェクト(Customization Point Object)はこれら2つの問題を一挙に解決しつつ、将来的なカスタマイゼーションポイントの拡張も可能にしている素敵な魔法のようなすごいやつです!

例えば、これまでのstd::begin()に対応するカスタマイゼーションポイントオブジェクトであるstd::ranges::beginは次のように定義されます。

namespace std::ranges {
  inline namespace /*unspecified*/ {

    inline constexpr /*unspecified*/ begin = /*unspecified*/;
  }
}

unspecifiedなところは名前や型が規定されていない(実装定義である)ことを意味します。そして、このstd::ranges::beginは関数オブジェクトです。std::ranges::begin(E)のように呼び出してさも関数であるかのように使います。

std::ranges::begin(E)のように呼ばれた時、その引数の式Eによって以下のいずれかの処理を実行します(以下、TEの型、tは式Eの結果となる左辺値)。上から順番にチェックしていきます。

  1. Eが右辺値であり、std::ranges::enable_borrowed_range<remove_cv_t<T>> == falseならば、呼び出しは不適格。
  2. Tが配列型であり、 std::remove_all_extents_t<T>が不完全型ならば、呼び出しは不適格(診断不要)。
  3. Tが配列型であれば、std::ranges::begin(E)は式t + 0expression-equivalent
  4. decay-copy(t.begin())が有効な式であり、その結果の型がstd::input_or_output_iteratorコンセプトのモデルとなる(満たす)場合、std::ranges::begin(E)decay-copy(t.begin())expression-equivalent
  5. Tがクラス型か列挙型であり、decay-copy(begin(t))が有効な式であり、その結果の型がstd::input_or_output_iteratorコンセプトのモデルとなり、非修飾のbegin()に対する名前探索が以下2つの宣言だけを含むコンテキストでオーバーロード解決が実行される場合、std::ranges::begin(E)はそのコンテキストで実行されるオーバーロード解決を伴うdecay-copy(begin(t))expression-equivalent
// std::begin()を含まないコンテキストでオーバーロード解決をするということ
void begin(auto&) = delete;
void begin(const auto&) = delete;

「式Aは式Bとexpression-equivalent」というのは簡単に言うと式Aの効果は式Bと等価であり、式Aが例外を投げるかと定数実行可能かどうかも式Bと等価と言うことです。この場合の式Bは引数E由来なので、std::ranges::begin(E)の呼び出しが例外を投げるかどうかと定数実行可能かどうかは引数の型次第と言うことになります。

詳しく見ていくと、1,2番目の条件はまず呼び出しが適格ではない事が型レベルで分かるものを弾く条件です。enable_borrowed_rangeと言うのは右辺値のrangeであってもイテレータを取り出して操作する事が安全に行えるかを示すbool値です(たぶん)。
3番目以降がstd::ranges::beginの主たる効果です。3番目は配列の先頭のポインタを返します。t + 0というのは明示的にポインタにしてるようです。
4番目はメンバ関数として定義されたbegin()を呼び出します。標準ライブラリのほとんどの型がこれに当てはまります。
5番目はTと同じ名前空間にあるフリー関数のbegin()を探して呼び出すものです(Hidden friendsもここで探し出されます)。この時、std::begin()を見つけないようにするためにオーバーロード解決についての指定がなされています。

ユーザーがこのstd::ranges::beginにアダプトするときは、4番目か5番目に適合するようにしておきます。つまり、従来とやることは変わりません。一方、このstd::ranges::beginを使用する場合は逆に従来のような煩雑コードを書かなくてもよくなります。これまでやっていたことと同等(以上)のことを中で勝手にやってくれるようになります。

template<typename Container>
void my_algo(Container&& rng) {
  // using std::beginとかをしなくても、同じことを達成でき、よりジェネリック!
  auto first = std::ranges::begin(rng);
}

これによってまず、1つ目の問題(呼び出しが煩雑、使いづらい)が解消されている事がわかるでしょう。

さらに、ユーザー定義型に対しても行われうる4,5番目の処理では、戻り値型にコンセプトによる制約が要求されています。std::input_or_output_iteratorはインクリメントや間接参照等イテレータに要求される最小限のことを制約するコンセプトで、これによって使用されるbegin()イテレータを返さない場合にstd::ranges::begin(E)の呼び出しが不適格になります。そして、カスタマイゼーションポイントの呼び出しが診断可能な不適格となる場合は単にオーバーロード解決の候補から外れ、他に候補があれば別の適切な関数が呼び出されることになります。

namespace myns {

  struct my_struct {};

  // イテレータを取得するものではないbegin()関数
  bool begin(my_struct&);
}

int main() {
  myns::my_struct st{};

  std::ranges::begin(st);  // ng、戻り値型がinput_or_output_iteratorを満たさないためコンパイルエラー
}

こうして、2つ目の問題の一部(別の意味を持つ関数も呼び出してしまう)も解決されている事がわかりました。

関数オブジェクトとADL

最後に残ったのは、ADLによってカスタマイゼーションポイント呼び出しをフックできる、あるいは要求される型制約を無視できてしまう問題です。これはCPOが関数オブジェクトである事によって防止されます。

C++における名前探索では修飾名探索と非修飾名探索を行なった後、引数依存名前探索(ADL)を行いオーバーロード候補集合を決定します。この時、非修飾名探索の結果に関数以外のものが含まれているとADLは行われません。逆に言うと、ADLは関数名に対してしか行われません。つまり、関数オブジェクトに対してはADLは発動しません(6.5.2 Argument-dependent name lookup [basic.lookup.argdep])。

カスタマイゼーションポイントオブジェクトが関数オブジェクトであることによって、usingして使った時でも同名の関数によってADLでフックする事は出来なくなります。

namespace myns {

  struct my_struct {};

  // イテレータを取得するものではないbegin()関数
  bool begin(my_struct&); // (2)
}


template<typename Container>
void my_algo(Container&& rng) {
  using std::ranges::begin;

  // 先頭イテレータを得る
  auto first = begin(rng);  // std::ranges::beginが呼び出され、(2)は呼び出されない
                            // 戻り値型がinput_or_output_iteratorコンセプトを満たさないためコンパイルエラー
}

int main() {
  myns::my_struct st{};

  my_algo(st);  // ng
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

これらのように、カスタマイゼーションポイントオブジェクトではC++17までのカスタマイゼーションポイントに存在した問題が全て解決されている事が確認できたでしょう。

Template Method

遥か未来の世界で、イテレータを取得するのにbegin()だけではなく別の方法が追加された場合を考えてみます。例えば、first()関数が今のbegin()と同じ意味を持ったとします。その世界で統一的な操作としてstd::ranges::beginを使い続けるにはどうすればいいでしょうか?また、ユーザーは何をすべきでしょう?

答えは簡単です。先ほど5つほど羅列されていたstd::ranges::beginの条件にもう2つほど加えるだけです。標準ライブラリの実装は修正が必要ですが、それを利用するユーザーが何かをする必要はありません。first()関数がイテレータを返すようになった世界でもstd::ranges::beginを使い続けていれば何も変更する事なくイテレータを得る事ができます。

このように、C++20のカスタマイゼーションポイントオブジェクトはカスタマイゼーションポイントを追加する方向の変更に対して閉じています(そして、おそらく削除する変更は行われない)。ユーザー目線で見れば、そのような変更が行われたとしてもカスタマイゼーションポイントオブジェクトのインターフェースは常に安定しています。

このように、カスタマイゼーションポイントオブジェクトはよりジェネリックかつ静的なTemplate Methodパターン(あるいはNVI)だと見る事ができます。

inline名前空間

標準ライブラリのカスタマイゼーションポイントオブジェクトは、先ほど見たようになぜかinline名前空間に包まれています。

これはおそらく、将来行われうる変更に対してもABI互換性を維持するための布石です。

正しくは、CPOがstd名前空間にあるとき、標準ライブラリにあるクラスで定義されているHidden friends関数とCPOとで名前が衝突するため、それを回避するためのものです。

namespace mystd {
  
  namespace cpo_impl {
    
    // swap CPOの実装クラス
    struct swap_cpo {
      
      template<typename T, typename U>
      void operator()(T&, U&) const;
    };
  }

  // swap CPO #1
  inline constexpr cpo_impl::swap_cpo swap{};
  
  struct S {
    
    // Hidden friendsなswap関数 #2
    friend void swap(S& lhs, S& rhs);
  };
}

この例では#1と#2の異なる宣言が同じ名前空間にあるためにコンパイルエラーになっています。

この様な問題はメンバ関数との間では起こらず、非メンバ関数との間で起こります。正確にはswapなどほとんどのCPOはstd::ranges名前空間にありますが、Rangeライブラリのviewなど、Hidden friendsでCPOにアダプトする型との間で同様の問題が発生します。

この問題は、CPOをinline名前空間で囲むことによって解決されます。

namespace mystd {
  
  namespace cpo_impl {
    
    // swap CPOの実装クラス
    struct swap_cpo {
      
      template<typename T, typename U>
      void operator()(T&, U&) const;
    };
  }

  // CPO定義をinline名前空間で囲う
  inline namespace cpo {
    // swap CPO #1
    inline constexpr cpo_impl::swap_cpo swap{};
  }
  
  struct S {
    
    // Hidden friendsなswap関数 #2
    friend void swap(S& lhs, S& rhs);
  };
}

こうしてもmystd::swapという名前でswapCPOを参照できますし、CPOの内部からSに対するswapをADLによって正しく呼び出すことができます。しかし、#1と#2のswapは別の名前空間にいるために名前は衝突していません。

この様な事情から、標準ライブラリにあるCPOはほとんどのものがinline名前空間に包まれています。

その他の性質

標準ライブラリのカスタマイゼーションポイントオブジェクトは全て、リテラル型かつsemiregularであると規定されています。これはつまり、constexprにコピー・ムーブ・デフォルト構築/代入可能であると言う事です。

そしてそれら複数のインスタンスのカスタマイゼーションポイントオブジェクトとしての効果は呼び出しに使うインスタンスによって変化しない事も規定されています。

これらの性質によって、高階関数など関数オブジェクトを取るユーティリティでカスタマイゼーションポイントオブジェクトを自由に利用する事ができます。これはまた、従来のカスタマイゼーションポイント関数と比較した時のメリットでもあります(関数ポインタはジェネリックではいられないなど)。

実装してみよう!

言葉で語られても良く分からないのでここまで説明に使ってきたstd::ranges::beginを実装してみましょう。百聞は一見に如かずです。とはいってもstd名前空間ではなくオレオレ名前空間に書いてみます。

#include <concepts>

namespace mystd {
  
  namespace detail {
    
    // beginの実装型
    struct begin_impl {
      
      // 関数呼び出し演算子オーバーロードで定義していく
      template<typename E>
      constexpr auto operator()(E&&) const;
    };
  }
  
  inline namespace cpo {
    
    // ターゲットのカスタマイゼーションポイントオブジェクト begin
    inline constexpr detail::begin_impl begin{};
    
  }
}

概形はこんな感じで、detail::begin_implクラスの関数呼び出し演算子オーバーロードすることで実装していきます。

以下では、GCC10.1の実装を参考にしてます。

ケース1 右辺値

これは不適格な呼び出しとなり、該当する候補があるとハードエラーになってしまうので何も定義しません。ただし、右辺値かつstd::ranges::enable_borrowed_range<remove_cv_t<T>> == falseの場合は呼び出し不可でそうでない場合は呼び出し可能なのでそのようにしておきます。

struct begin_impl {

  template<typename T>
    requires std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>
  constexpr auto operator()(T&&) const;
};

右辺値かつstd::ranges::enable_borrowed_range<remove_cv_t<T>> == falseの時は呼び出しはill-formedにするので、その否定の場合は通すようにします。全体を否定してド・モルガンをした条件を制約しておきます。なぜ左辺値参照判定してるかというと、テンプレート引数推論の文脈ではT&&に右辺値が渡ってくるとTはそのままTに、左辺値が渡ってくるとTT&になって全体としてT&&& -> T&となるからです。つまり、この場合のテンプレートパラメータTは左辺値が渡された場合は左辺値参照となります。

この状態で右辺値vectorを入れると呼び出し可能な関数が無いとエラーになるので上手くいっていそうです。

ケース3 配列型、ケース2 不完全型の配列

先程の仮実装に配列判定を入れましょう。配列の要素型が不完全型である場合はここで弾いてやります。

// 不完全型の配列判定
template<typename T>
concept is_complete_array = requires {
  sizeof(std::remove_all_extents_t<std::remove_reference_t<T>>);
};

struct begin_impl {

  template<typename T>
    requires (std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>) and
              std::is_array_v<std::remove_reference_t<T>>
  constexpr auto operator()(T&& t) const noexcept {
    static_assert(is_complete_array<std::remove_all_extents_t<T>>, "Array element type is incomplete");
    return t + 0;
  }
};

不完全型の配列の時は診断不用とあり、SFINAEすることも求められないのでstatic_assertでハードエラーにします。

この状態で右辺値vectorを入れると適切にエラーになり、左辺値配列を入れると呼び出しは成功します。どうやら上手くいっているようです。

ケース4 ユーザー定義型のメンバbegin

コンセプトでメンバbeginが呼び出し可能かどうかを調べてやります。decay-copyauto戻り値型が勝手にやってくれるはず・・・

struct begin_impl {

  template<typename T>
    requires (std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>) and
             requires(T t) { {t.begin()} -> std::input_or_output_iterator; }
  constexpr auto operator()(T&& t) const noexcept(noexcept(t.begin())) {
    return t.begin();
  }
};

requires節の中のrequires式でメンバ関数beginが呼び出し可能かどうかをチェックします。ついでに戻り値型の制約もチェックしておきます。この場合でも右辺値でenable_borrowed_rangetrueならば呼び出しは可能(そうでなければSFINAEする)なので先程の条件を同時に指定しておく必要があります。

expression-equivalentというのもconstexpr指定と呼び出す式によるnoexcept二段重ねで自動化できます。

左辺値のvectorとかを入れてやるとエラーにならないので行けてそうですね。

ケース5 ユーザー定義型の非メンバbegin()

オーバーロードに関わる部分はstd::beginを含まないコンテキストで、という事なのでstd名前空間の外で実装するときには触らなくてよかったりします。それ以外は先ほどのメンバ関数ケースの時と同様に書けます。

struct begin_impl {

  // ケース5 メンバ関数begin
  template<typename T>
    requires (std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>) and
             (not requires(T t) { {t.begin()} -> std::input_or_output_iterator; }) and
             requires(T t) { {begin(t)} -> std::input_or_output_iterator; }
  constexpr auto operator()(T&& t) const noexcept(noexcept(begin(t))) {
    return begin(t);
  }
};

ただし素直にやると先ほどのケース4と曖昧になってしまうので、メンバ関数begin()を持たない場合、と言う条件を付け加えます(ケース4で追加した制約式の否定)。

適当にstd::vectorをラップしたような型を作って非メンバでbegin()を用意してやるとテストできます。大丈夫そうです。

完成!

#include <concepts>
#include <ranges>

namespace mystd {
  
  namespace detail {

    // 不完全型の配列判定
    template<typename T>
    concept is_complete_array = requires {
      sizeof(std::remove_all_extents_t<std::remove_reference_t<T>>);
    };
    
    struct begin_impl {
      
      // ケース3 配列型
      template<typename T>
        requires (std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>) and
                  std::is_array_v<std::remove_reference_t<T>>
      constexpr auto operator()(T&& t) const noexcept {
        // ケース2をエラーに
        static_assert(is_complete_array<std::remove_all_extents_t<T>>, "Array element type is incomplete");
        return t + 0;
      }
      
      // ケース4 メンバ関数begin
      template<typename T>
        requires (std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>) and
                 requires(T t) { {t.begin()} -> std::input_or_output_iterator; }
      constexpr auto operator()(T&& t) const noexcept(noexcept(t.begin())) {
        return t.begin();
      }
      
      // ケース5 非メンバ関数begin
      template<typename T>
        requires (std::is_lvalue_reference_v<T> or std::ranges::enable_borrowed_range<std::remove_cv_t<T>>) and
                 (not requires(T t) { {t.begin()} -> std::input_or_output_iterator; }) and
                 requires(T t) { {begin(t)} -> std::input_or_output_iterator; }
      constexpr auto operator()(T&& t) const noexcept(noexcept(begin(t))) {
        return begin(t);
      }
    };
  }
  
  inline namespace cpo {
    
    // オレオレstd::ranges::beginカスタマイゼーションポイントオブジェクト!
    inline constexpr detail::begin_impl begin{};
    
  }
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

正しいかはともかく、それっぽいものができました。カスタマイゼーションポイントオブジェクトの多くは大体この様に実装できます。コンセプトを使うと非常に簡単になるだけで、C++17以前の環境でもSFINAEを駆使するなどして実装することができます。この例ではオーバーロードに分けましたが、関数1つにまとめてconstexpr ifを使うと言う手もあります(GCCの実装はそっち)。

実装を知ってみれば、素敵な魔法というよりはそこそこ愚直な力業でできており、少し不透明さが取り払えることでしょう。

これまでのカスタマイゼーションポイント

本来は従来のカスタマイゼーションポイントとなっている関数をカスタマイゼーションポイントオブジェクトに置き換えたかったようですが、それは互換性の問題からできなかったようです。そのため、カスタマイゼーションポイントオブジェクトは別の名前空間に同名で定義されています。

ここまで見たことから分かるように、関数ではコンセプト時代のカスタマイゼーションポイントとしてはふさわしくないため、残っているのはほとんど後方互換のためでしょう。C++20以降だけをターゲットに出来るなら、それらの関数を使わずにカスタマイゼーションポイントオブジェクトを使うべきです。

C++20のカスタマイゼーションポイントオブジェクト

C++17のカスタマイゼーションポイントとC++20からのカスタマイゼーションポイントオブジェクトの対応と一覧を載せておきます。

C++17のカスタマイゼーションポイント関数 C++20のCPO 効果
std::begin() std::ranges::begin 範囲の先頭を指すイテレータを取得する
std::end() std::ranges::end 範囲の終端を指すイテレータを取得する
std::cbegin() std::ranges::cbegin 範囲の先頭を指すconstイテレータを取得する
std::cend() std::ranges::cend 範囲の終端を指すconstイテレータを取得する
std::rbegin() std::ranges::rbegin 逆順範囲の先頭を指すイテレータを取得する
std::rend() std::ranges::rend 逆順範囲の終端を指すイテレータを取得する
std::crbegin() std::ranges::crbegin 逆順範囲の先頭を指すconstイテレータを取得する
std::crend() std::ranges::crend 逆順範囲の終端を指すconstイテレータを取得する
std::size() std::ranges::size 範囲の長さを取得する
std::ssize() (C++20) std::ranges::ssize 範囲の長さを符号付き整数型で取得する
std::empty() std::ranges::empty 範囲が空であるかを取得する
std::data() std::ranges::data 範囲の領域先頭へのポインタを取得する
std::ranges::cdata 範囲の領域先頭へのconstポインタを取得する
std::swap() std::ranges::swap 二つのオブジェクトの内容を入れ替える
std::ranges::iter_move イテレータの指す要素をムーブする
std::ranges::iter_swap イテレータの指す要素をswapする
std::strong_order 全順序の上での三方比較を行う
std::weak_order 弱順序の上での三方比較を行う
std::partial_order 半順序の上での三方比較を行う
std::strong_order_fallback <=>が無い場合に< ==にフォールバックするstd::strong_order
std::weak_order_fallback <=>が無い場合に< ==にフォールバックするstd::weak_order
std::partial_order_fallback <=>が無い場合に< ==にフォールバックするstd::partial_order

もしかしたらほかにもあるかもしれません。

C++23以降の標準ライブラリ

カスタマイゼーションポイントを増やしづらかった時代はコンセプトとカスタマイゼーションポイントオブジェクトによって終わりを告げたため、これからのライブラリはそれらを中心として設計されるでしょう。提案文書のレベルでは、新規提案のほとんどが何かしらの形でコンセプトを用いており、規模の大きめな新規ライブラリ提案ではカスタマイゼーションポイントオブジェクトが普通に用いられています。

特にC++23に導入されるのがほぼ確実視されているExecutorライブラリは、現段階ですでにコンセプトとカスタマイゼーションポイントオブジェクトベースの非常にジェネリックな最先端のライブラリです。C++23とそれ以降の標準ライブラリではカスタマイゼーションポイントオブジェクトとコンセプトは空気のような存在になるでしょう。

onihusube.hatenablog.com

カスタマイゼーションポイントオブジェクトという言葉

カスタマイゼーションポイントオブジェクトの効果はそれぞれ異なりますが、おおよそ全般的に共通しているものがあり、単にCPOやカスタマイゼーションポイントオブジェクトと呼んだ時にはそのような性質を暗黙的に仮定していることがあります。

任意のカスタマイゼーションポイントオブジェクトの名前をcpo_nameとすると

  • (標準)ライブラリ側で用意されている関数オブジェクトである
  • カスタマイゼーションポイントオブジェクトによる処理は特定の型に限定されない
  • 呼び出しに当たっては引数あるいは戻り値型に(その文脈で)適切なコンセプトによる制約を行う
  • 少なくとも、cpo_nameと同じ名前のメンバ関数と非メンバ関数Hidden Friends含む)を捜索して呼び出すように定義される

これらの事を頭の片隅に入れておくと、カスタマイゼーションポイントオブジェクトが出て来た時にその意味を理解しやすくなるかもしれません。

参考文献

この記事のMarkdownソース