[C++] std::rangesの範囲アクセス関数(オブジェクト)の使いみち

C++20のRangeライブラリ導入に伴って、std::ranges名前空間の下にbegin,end,size,data等の、これまでコンテナアクセスに用いられていた関数が追加されています。しかし、これらの関数はすでにstd名前空間に存在しており、一見すると同じ役割を持つものが重複して存在しているようにも見えます。

従来のstd::begin()/std::end()の問題点

元々、範囲を表すイテレータのペアを取り出す口としてのbegin()/end()は各コンテナのメンバ関数として定義されていました。しかしその場合、メンバ関数を持つことのできない生の配列型だけは特別扱いする必要があり、イテレータのペアを取り出す操作が完全に共通化できていませんでした。

C++11にて、std::begin()/std::end()が追加され、この関数を利用することで配列とコンテナの間で共通の操作によってイテレータのペアを取り出せるようになりました。
しかし、begin()/end()をメンバではなくグローバル関数として定義しているような標準ライブラリ外のユーザー定義型に対してはこれに加えて以下のような一手間を加えなければなりません。

//イテレータのペアを取り出す
template<typename Iterable>
auto get_range(Iterable& range_obj) {
  using std::begin;
  using std::end;

  return std::make_pair(begin(range_obj), end(range_obj));
}

std::begin()/std::end()をそれぞれusingした上でbegin()/end()するのです。これによってメンバではなく同じ名前空間begin()/end()を用意しているユーザー定義型の場合はADLによってそれを発見し、それ以外の型はstd::begin()/std::end()を経由してメンバのものを呼び出すか配列として処理されます。

C++17まではイテレータ範囲をジェネリックに得るにはこうする事がベストでした(はずです)。

std::ranges::begin/std::ranges::end

しかし、先ほどのコードは正直言って意味が分からないし、知らなければああいう風に書くという発想は出てきません。それに何より一々あんなまどろっこしい書き方をしたくないです。

そこで、Rangeライブラリでは独自にbegin()/end()を備えておくことにしました。それが、std::ranges::begin/std::ranges::endの関数(オブジェクト)です。その効果はおおよそ以下のようになります。

  • 配列ならその先頭(終端)のポインタを返す
  • そうではなく、メンバ関数E.begin()/E.end()があればその結果を返す
  • そうでもなく、フリー関数のbegin(E)/end(E)が利用可能ならばその結果を返す
  • そうでもなければill-formed

つまりC++17までのベストプラクティスだったコードで暗黙に行われていた事を全て中でやってくれるすごいやつです。なお、突然出てきたEstd::ranges::begin(E)のように呼び出されたときの引数に当たります。

そして、このstd::ranges::begin/std::ranges::endによってイテレータペアを取得可能な型こそがRangeライブラリの基本要件であるstd::ranges::rangeコンセプトを満足する事ができます。

C++20からは先ほどのコードは以下のように書けるでしょう。

//イテレータのペアを取り出す
template<std::ranges::range Iterable>
auto get_range(Iterable& range_obj) {
  return std::make_pair(std::ranges::begin(range_obj), std::ranges::end(range_obj));
}

std::ranges::swap

begin()/end()と全く同じ問題を抱えているのがswap()関数です。std::swap()は通常ムーブコンストラクタとムーブ代入演算子を用いて典型的なswap操作を行います。標準ライブラリの型はメンバ関数swap()を持っており、std::swap()の特殊化を経由してそれを使ってもらっています。

ユーザー定義型で特殊なswapを行いたい場合は、非メンバ関数としてswap()を定義します(この時、メンバ関数として定義したswap()を非メンバ関数swap()から使うようにしてもokです)。そして、使用するにあたってはさっき見たようなコードを書きます。

template<typename T, typename U>
void my_swap(T&& t, U&& u) {
  using std::swap;

  swap(std::forward<T>(t), std::forward<U>(u));
}

これの問題点は上で説明した通りです。

対して、std::ranges::swap関数(オブジェクト)は以下のような効果を持ちます。

  • 引数E1, E2がクラス・列挙型でフリー関数のswap(E1, E2)が利用可能ならそれを利用
  • そうではなく、引数E1, E2が配列型でstd::ranges::swap(*E1, *E2)が有効なら、std::ranges::swap_ranges(E1, E2)
  • そうでもなく、引数E1, E2が同じ型Tの左辺値でstd::move_­constructible<T>std::assignable_­from<T&, T>両コンセプトのモデルである場合、デフォルトのstd::swap()相当の式によってswapする。
  • そうでもなければill-formed

ユーザー定義swap()関数は1つ目の条件によって発見されます。using std::swapをする必要はありません。もっと言えば、swapを汎用的に行うためにさっきのmy_swap()のような関数を書くは必要ありません、このstd::ranges::swapを使えばいいのです。そして、std::swappable(_with)コンセプトはこのstd::ranges::swapを用いて定義されます。

ちなみに、このstd::ranges::swapはなぜか<range>ヘッダではなく<concepts>ヘッダにあります。多分std::swappableコンセプト定義の関係だと思いますが、見つけるのに少し苦労しました・・・。

std::ranges::size

これも先ほどまでと似たようなものです。標準ライブラリにあるコンテナはほとんど現在抱えている要素数size()メンバ関数で取得できます。しかし、生配列は当然そうではなくbegin()/end()の時と同じくメンバ関数ではなくフリー関数で定義している場合もあるかもしれません。

C++17までは、フリー関数のstd::size()を使えばstd::forward_list以外の標準のコンテナからは要素数を取得できました。また、非メンバで定義されていた場合のケアもしてくれていました。

std::ranges::size関数(オブジェクト)はもう少し頑張って、以下のような効果を持ちます。

  • 配列型ならその要素数を返す
  • そうではなく、メンバ関数E.size()があればその結果を返す
  • そうでもなく、フリー関数のsize(E)が利用可能ならばその結果を返す
  • そうでもなく、std::ranges::end(E) - std::ranges::begin(E)が有効ならばその結果を返す
  • そうでもなければill-formed

フリー関数として定義されている場合のフォローと、定義が無くてもイテレータを使ってサイズを求めてくれています。残念ながら、std::forward_listはサイズを定数時間(O(1))で求められないので、この関数ではサイズを得られません。

std::ranges::empty

これも似たようなものです。標準ライブラリのコンテナは多分全てempty()メンバ関数を備えていますが、生配列とinitilizer_listはそうではありませんでした。

std::ranges::empty関数(オブジェクト)は以下のような効果を持ちます。

  • メンバ関数E.empty()があればその結果を返す
  • そうではなく、std::ranges::size(E) == 0が有効ならその結果を返す
  • そうでもなく、bool(std::ranges::begin(E) == std::ranges::end(E))が有効ならその結果を返す
  • そうでもなければill-formed

すごく頑張ってくれているのが伝わります。2番目に引っかからずに3番目に引っかかるのはempty()を持たないstd::forward_listみたいな型の場合のようです。

std::ranges::data

標準ライブラリでdata()メンバ関数を持つのはその領域にメモリ連続性があるコンテナだけです(イテレータcontiguous iteratorであるということでもあります)。そして、その戻り値はそのような領域の先頭ポインタです。

対して、std::ranges::data関数(オブジェクト)は以下のような効果を持ちます。

  • 引数Eが左辺値であり、メンバ関数E.data()が使用可能ならその結果を返す
  • そうではなく、std::ranges::begin(E)が有効でありその戻り値型がstd::contiguous_iteratorコンセプトのモデルである場合は、std::to_address(std::ranges::begin(E))
  • そうでもなければill-formed

基本的にはdata()を利用できる型にその抱える要素列がメモリ連続性を満たしていることを要求する点は変わりませんが、謎の頑張りによってその対象を広げています。

std::to_address()はポインタもしくはポインタとみなせる型(スマートポインタ等、ファンシーポインタと呼ぶらしい)のオブジェクトからアドレスを取得するものです。
std::contiguous_iteratorコンセプトはrandom access iteratorでありメモリ連続性を持つイテレータが満たす事の出来るコンセプトです。

おそらく、std::ranges::begin()でその内部のメモリ領域へのファンシーポインタを返すような型を意識しているのだと思います。ユーザー定義型ならばもしかしたらこれが有効なケースがあるかもしれません。標準ライブラリには無いはず(もしかしたらRangeライブラリにはこのような事を行う何かが潜んでいるのかもしれません)・・・

結論

このように、これらの範囲アクセス関数(オブジェクト)は今まで用意されていた同名の関数と比べて、さらにもう少し頑張ってその目的を達成しようとするものです。そして、その目的を達するために非自明な追加の操作を要求しません。
またこれらはカスタマイゼーションポイントオブジェクトと呼ばれる関数オブジェクトであって関数ではありません。そのため、その呼び出しに際して効果を発揮する際に要求するコンセプトのチェックが確実に行われます(ユーザーが巧妙に迂回することができない)。

C++20からは従来の関数のことは忘れてstd::ranges名前空間の下にあるこれらの関数(オブジェクト)を使いましょう。

参考文献

謝辞

この記事の6割は以下の方々によるご指摘によって成り立っています。

この記事のMarkdownソース