C++20のRangeライブラリ導入に伴って、std::ranges
名前空間の下にbegin,end,size,data
等の、これまでコンテナアクセスに用いられていた関数が追加されています。しかし、これらの関数はすでにstd
名前空間に存在しており、一見すると同じ役割を持つものが重複して存在しているようにも見えます。
- 従来のstd::begin()/std::end()の問題点
- std::ranges::begin/std::ranges::end
- std::ranges::swap
- std::ranges::size
- std::ranges::empty
- std::ranges::data
- 結論
- 参考文献
- 謝辞
従来の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までのベストプラクティスだったコードで暗黙に行われていた事を全て中でやってくれるすごいやつです。なお、突然出てきたE
はstd::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
名前空間の下にあるこれらの関数(オブジェクト)を使いましょう。
参考文献
- 24.3 Range access [range.access]
- 18.4.9 Concept swappable[concept.swappable]
- Customization Point Object - yohhoyの日記
- C++標準化委員会の文書集、2015-04 pre-Lenexa mailingsのレビュー: N4381-N4389 - 本の虫
- [C++]expression-equivalentのお気持ち - 地面を見下ろす少年の足蹴にされる私
謝辞
この記事の6割は以下の方々によるご指摘によって成り立っています。