yohhoyの日記

技術的メモをしていきたい日記

Customization Point Object

C++2a(C++20)標準ライブラリに導入される Customization Point Object についてメモ。*1

まとめ:

  • Customization Point == ユーザ定義型に対して事前定義した動作カスタマイズ可能点。具体的な処理実装ソースコードから呼び出される名前。
    • 2021-06-29追記:C++20標準ライブラリのCPOには Customization Point を規定しないものもある。これらはそのCPO名によって動作カスタマイズが行えない点を除いて、本記事で説明する “真のCPO” と同じ性質を持つ。名前付けもうちょっとこう...
  • Customization Point Object(CPO) == 上記目的のためにライブラリ側で定義する、グローバルな関数オブジェクト。
  • 最初に「コンセプトによる型制約を検査」してから「ADLによって適切な関数オーバーロードを検索」するための仕組み。
    • C++17現在の関数オーバーロード方式では、[A]誤って利用されるリスクが高く、[B]コンセプトを用いた型制約を強制できない という問題がある。
  • CPO呼び出しの引数型は、CPOが要求するコンセプトを満たす。引数型リストがコンセプトを満たさない場合は、オーバーロード解決候補から除外される。
  • CPO呼び出しの戻り値型は、CPOに対して定めたコンセプトを満たす。*2

C++17

C++17標準ライブラリでは swap, begin, end 関数が Customization Point となっている*3。ユーザ定義型に対するカスタマイズが正しく機能するには、“名前空間std配下の名前swapを導入(using)” した後に、“非修飾名(unqualified)でswap関数を呼び出す” 必要がある。この挙動の理解には込み入った知識を要求するため、C++プログラマに誤使用されやすいという問題があった。

namespace NS {
  struct S;
  // ユーザ定義型に対するカスタム動作
  void swap(S&, S&);
}

// OK: 正しいswap呼び出し
template <typename T>
void func0(T& a, T& b) {
  using std::swap;
  swap(a, b);
  // 変数型TがNS名前空間に属する場合、ADLにより
  // カスタマイズ版(NS::swap)関数が呼び出される
}

// NG: 完全修飾名による呼び出し
template <typename T>
void func1(T& a, T& b) {
  std::swap(a, b);
  // T=NS::Sに対してカスタマイズ版が呼び出されない
}

さらにC++2aではコンセプトを用いたテンプレートパラメータ制約を表現可能となるが、ユーザ定義型に対してライブラリ側で事前設計した型制約を回避できてしまうという問題が生じる。

namespace std {
  // 仮想的なコンセプト: Fooable
  template <typename T> concept Fooable = /*...*/;
  // 仮想的なCustomization Point: T型はFooableを満たすべき
  template <Fooable T> void foo(T x) { /*...*/ }
}

namespace NS {
  // std::Fooableコンセプトを満たさないユーザ定義型
  struct S;
  // ユーザ定義型に対するカスタム動作(型制約を無視)
  void foo(S);
}

NS::S x;
using std::foo;
foo(x);  // ★OK!?
// std::Fooableコンセプト要件の検査をバイパスして
// カスタマイズ版NS::fooの呼び出しに成功してしまう

C++2a

C++標準ライブラリへのCPO導入によって、既存の2つの課題解決をはかっている。(説明用 Customization Point として名前 foo を利用)

  • 完全修飾名呼び出しstd::foo(a);または非修飾名呼び出しusing std::foo; foo(a);は、いずれの呼び出しでも同じ振舞いになること。
  • using std::foo; foo(a);としても、CPO foo が要求する型制約がバイパス(無視)されないこと。

CPOの実装例は次の通り。

namespace std {
  // 仮想的なコンセプト: Fooable
  template <typename T> concept Fooable = /*...*/;
  // 少なくともint型, double型はFooableコンセプトを満たすと仮定

  namespace detail {
    // ライブラリ標準のfoo動作を定義...
    void foo(int T);     // #1
    void foo(double T);  // #2

    struct foo_cpo {
      // 型パラメータTはFooableコンセプトを満たすべき
      template <Fooable T>
      void operator()(T a) {
        // 非修飾名呼び出しによってdetail::foo()への解決
        // またはADLによる名前解決が行われる
        foo(a);
      }
    };
  }

  // グローバル関数オブジェクトとしてCPO定義
  inline constexpr detail::foo_cpo foo{}; 
}

namespace NS {
  // std::Fooableコンセプトを満たさないユーザ定義型NS::BadS
  struct BadS;
  void foo(BadS);  // #3
  // std::Fooableコンセプトを満たすユーザ定義型NS::FooU
  struct FooU;
  void foo(FooU);  // #4
}

int val = 42;
NS::BadS bad;
NS::FooU good;
// 完全修飾名による呼び出し
{
  std::foo(val);  // OK: #1を呼び出し
  std::foo(bad);  // NG: ill-formed
  std::foo(good); // OK: #4を呼び出し
}
// using+非修飾名による呼び出し
{
  using std::foo;
  foo(val);  // OK: #1を呼び出し
  foo(bad);  // NG: ill-formed
  foo(good); // OK: #4を呼び出し
}

WD N4810現在のC++2a標準ライブラリでは、Rangesライブラリ要素として下記CPOを導入する(名前空間stdは省略)。後方互換性維持のため、従来swap, begin, endC++17ライブラリ仕様のまま維持される。

  • ranges::swap
  • ranges::iter_move, ranges::iter_swap
  • ranges::(c)(r)begin, ranges::(c)(r)end*4
  • ranges::size
  • ranges::empty
  • ranges::data, ranges::cdata
  • view::single
  • view::iota
  • view::all
  • view::filter
  • view::transform
  • view::take
  • view::join
  • view::split
  • view::counted
  • view::common
  • view::reverse

2021-01-28追記:C++20標準ライブラリでは上記に加えてranges::ssize名前空間std直下の{strong,weak,partial}_order, compare_{strong,weark,partial}_order_fallbackもCPOとして定義される。

2021-06-29追記:あるCPOがユーザ定義型に対して直接的に動作カスタマイズ可能か否かは、該当CPOの動作仕様に依存する

  • Customization Pointを規定するCPO:
    • 名前空間std以下のstrong_order, weak_order, partial_order
    • 名前空間std::ranges以下のswap, begin, end, rbegin, rend, size, data, empty*5
  • 例えばranges::{cbegin, cend}名前空間views以下のRangeアダプタオブジェクト(range adaptor object)は標準ライブラリ定義上 Customization Point Object とされるが、ユーザ定義型に対する直接的な動作カスタマイズは行えない。*6

関連URL

*1:この記事は C++20を相談しながら調べる会 #1 のアウトプットとして書かれました。参加中に全てをまとめた訳ではないのですが、同イベントは強い動機付けになっています。

*2:ライブラリでは名前 foo とコンセプト C を定義しておき、ライブラリ内部実装がコンセプト C を要求する箇所において式 foo(x) を利用する。

*3:C++17言語仕様には直接 "Customization Point" という用語は登場しないが、swap, begin, end 関数テンプレートを非修飾名で呼び出すことを規定している。このようなC++ライブラリ仕様により、ユーザ定義型に対するカスタマイズ版が適切に利用される。

*4:begin, cbegin, rbegin, crbegin の略。end についても同様。

*5:CPO data, empty はユーザ定義型のメンバ関数を介してのみ動作カスタマイズ可能。それ以外のCPOでは、ADL経由非メンバ関数呼び出しによる動作カスタマイズがサポートされる。

*6:例えば cbegin, cend はカスタマイズ可能なCPO begin, end を介して、間接的かつ部分的にその動作をカスタマイズできる。