この記事はC++ Advent Calendar 2021の7日目の記事です。
owning_view
owning_view
については、ちょうど別に書いたので以下もご参照ください。
owning_view
は右辺値の範囲から構築され、それを所有することで右辺値範囲の寿命を延長するものです。定義は簡単なのでコピペしておくと次のようになっています
namespace std::ranges { template<range R> requires movable<R> && (!is-initializer-list<R>) // see [range.refinements] class owning_view : public view_interface<owning_view<R>> { private: R r_ = R(); // exposition only public: owning_view() requires default_initializable<R> = default; // 専ら使用するコンストラクタ constexpr owning_view(R&& t) : r_(std<200b>::<200b>move(t)) {} // ムーブコンストラクタ/代入演算子 owning_view(owning_view&&) = default; owning_view& operator=(owning_view&&) = default; // 保持するRのオブジェクトを取得する constexpr R& base() & noexcept { return r_; } constexpr const R& base() const& noexcept { return r_; } constexpr R&& base() && noexcept { return std::move(r_); } constexpr const R&& base() const&& noexcept { return std::move(r_); } // Rのイテレータをそのまま使用 constexpr iterator_t<R> begin() { return ranges::begin(r_); } constexpr sentinel_t<R> end() { return ranges::end(r_); } // Rがconst-iterableならそうなる constexpr auto begin() const requires range<const R> { return ranges::begin(r_); } constexpr auto end() const requires range<const R> { return ranges::end(r_); } constexpr bool empty() requires requires { ranges::empty(r_); } { return ranges::empty(r_); } constexpr bool empty() const requires requires { ranges::empty(r_); } { return ranges::empty(r_); } // Rがsized_rangeならそうなる constexpr auto size() requires sized_range<R> { return ranges::size(r_); } constexpr auto size() const requires sized_range<const R> { return ranges::size(r_); } // Rがcontiguous_rangeならそうなる constexpr auto data() requires contiguous_range<R> { return ranges::data(r_); } constexpr auto data() const requires contiguous_range<const R> { return ranges::data(r_); } }; }
ムーブコンストラクタを除くとコンストラクタは一つしかなく、そこではR
(range
かつmovable
)の右辺値(これはフォワーディングリファレンスではありません)を受け取り、それをメンバ変数r_
にムーブして保持します。このようにして入力の右辺値範囲の寿命を延長しており、それ以外の部分は見てわかるように元のR
の薄いラッパです。
views::all
とviews::all_t
owning_view
を生成するためのRangeアダプタとしてviews::all
が用意されていますが、views::all
はowning_view
だけでなくref_view
も返します。
型R
のオブジェクトr
に対して、views::all(r)
のように呼ばれた時の効果は
R
がview
のモデルであるなら、r
をdecay-copyして返す- decay-copyは
r
をコピーorムーブしてその型の新しいオブジェクトを作ってそれを返すこと
- decay-copyは
r
が左辺値ならref_view(r)
r
が右辺値ならowning_view(std::move(r))
このように、views::all
はrange
を入力としてview
を返すもので、別の言い方をするとrange
をview
に変換するものです。views::all
を主体としてみれば、ref_view
とかowning_view
の区別は重要ではないため、この2つをまとめて(あるいは、views::all
によるview
を)All viewと呼びます。
<ranges>
のRangeアダプタと呼ばれるview
は、任意のview
を入力として何か操作を適用したview
を返すものです。そのため、Rangeアダプタ(の実態のview
型)にrange
を渡すためには一度view
に変換する必要があり、views::all
はその変換を担うRangeアダプタとして標準に追加されています。とはいえ、ユーザーがRangeアダプタを使用する際に一々views::all
を使用しなければならないのかといえばそうではなく、この適用はAll viewを除く全てのRangeアダプタにおいて自動で行われます。そのため通常は、ユーザーがviews::all
およびref_view
やowning_view
を直接使う機会は稀なはずです。
views::all
の自動適用は推論補助をうまく利用して行われています。簡易な実装を書いてみると
using namespace std::ranges; // 任意のview template<view V> class xxx_view { V base_; public: // 入力viewを受け取るコンストラクタ xxx_view(V v) : base_(std::move(v)) {} }; // この推論補助が重要! template<range R> xxx_view(R&&) -> xxx_view<views::all_t<R>>;
views::all_t
はviews::all
の戻り値型を求めるもので、次のように定義されます。
namespace std::ranges::views { template<viewable_range R> using all_t = decltype(all(declval<R>())); }
このxxx_view
をxxx_view{r}
のように使用した時、クラステンプレートの実引数推定が起こることによって1つだけ定義されている推論補助が使用され、r
の型R
をviews::all_t<R>
のように通して、views::all(r)
の戻り値型をxxx_view
のテンプレートパラメータV
として取得します。views::all
の戻り値型は、r
がview
ならそのview
型(prvalueとしての素の型)、r
が左辺値ならref_view{r}
、r
が右辺値ならowning_view{r}
を返します。つまり、views::all_t<R>
は常にR
を変換したview
のCV修飾なし参照なしの素の型(prvalue)を得ます。
そうして得られた型をV
とすると、xxx_view{r}
はxxx_view<V>{r}
のような初期化式になります。xxx_view
(および標準Rangeアダプタのview
)のview
を受け取るコンストラクタはexplicit
がなく、テンプレートパラメータに指定されたview
型(V
、これは実引数r
の型R
に対してviews::all_t<R>
の型)を値として受けるものであるため、そのコンストラクタ引数ではR -> V
の暗黙変換によってviews::all(r)
を通したのと同じことが起こり、ここでviews::all
の自動適用が行われます。
これと同じことが、All viewを除く全てのRangeアダプタのview
型で実装されており、これによって、Rangeアダプタはviews::all
を自動適用してview
を受け取っています。これはxxx_view
に対してviews::xxx
の名前のRangeアダプタを使用した時でも同様です(その効果では結局、何かしらのview
型を適用することになるため)。
#include <ranges> #include <vector> auto f() -> std::vector<int>&; auto g() -> std::vector<int>; using namespace std::ranges; int main() { auto tv = take_view{f(), 5}; // decltype(tv) == take_view<ref_view<std::vector<int>>> auto dv = drop_view{g()}, 2; // decltype(dv) == drop_view<owning_view<std::vector<int>>> auto dtv = drop_view{tv, 2}; // decltype(dtv) == drop_view<take_view<ref_view<std::vector<int>>>> auto ddv = dv | views::drop(2); // decltype(ddv) == drop_view<drop_view<owning_view<std::vector<int>>>> auto ddv2 = drop_view{dv, 2}; // decltype(ddv2) == drop_view<drop_view<owning_view<std::vector<int>>>> }
パイプラインで起こること
個別のview
型で起こることはわかったかもしれませんが、実際に使用した時に起こることはイメージしづらいものがあります。
#include <ranges> #include <vector> auto f() -> std::vector<int>; auto even = [](int n) { return 0 < n; }; auto sq = [](int n) { return n * n; }; using namespace std::views; int main() { // pipesの型は?構造は?? auto pipes = f() | drop(2) | filter(even) | transform(sq) | take(5); // 安全、f()の戻り値はowning_viewによって寿命延長されている for (int m : pipes) { std::cout << n << ','; } }
例えばこのようなRangeアダプタによるパイプラインの結果として得られたpipes
は、どんな型を持ちどんな構造になっているのでしょうか?また、f()
の結果(右辺値)はowning_view
によって安全に取り回されているはずですが、pipes
のどこにそれは保持されているのでしょうか?
先程のviews::all/views::all_t
の標準Rangeアダプタでの使われ方を思い出すと、pipes
の型はわかりそうです。
1行目のf() | drop(2)
ではdrop_view
(views::drop(2)
による)の構築が行われ、f()
の戻り値をr
とするとdrop_view{r, 2}
が構築されます。前述の通り、そこではviews::all
が自動適用され、r
は右辺値std::vector<int>
なのでその結果はowning_view{r}
が帰ります。したがって、この行で生成されるオブジェクトの型はdrop_view<owning_view<std::vector<int>>>
となります。
その結果をv1
として、次の行v1 | filter(even)
ではfilter_view
が、filter_view{v1, even}
のように構築されます。ここでもviews::all
が自動適用されていますが、views::all(v1)
はv1
が既にview
であるため、それがそのまま(decay-copyされて)帰ります。したがって、この行で生成されるオブジェクトの型はfilter_view<drop_view<owning_view<std::vector<int>>>, even_t>
となります(述語even
のクロージャ型をeven_t
としています)。
パイプラインの2段目以降ではviews::all
の適用はほぼ恒等変換となるため、views::all_t
の型を気にする必要があるのはパイプラインの一番最初だけです。後の行およびその他のRangeアダプタの適用時に起きることも同じようになるため、この2行目で起きている事がわかれば後は簡単です。ただし、Rangeアダプタオブジェクトの返す型に注意が必要ではあります。
auto pipes = f() | drop(2) // V1 = drop_view<owning_view<std::vector<int>>> | filter(even) // V2 = filter_view<V1, even_t> | transform(sq) // V3 = transform_view<V2, sq_t> | take(5); // V4 = take_view<V3>
略さずに書くとdecltype(pipes) == take_view<transform_view<filter_view<drop_view<owning_view<std::vector<int>>>, even_t>, sq_t>>
となります。標準view
型は入力のview
をテンプレートの1つ目の引数として取るので、パイプライン前段のview
型が、次の段のview
型の第一テンプレート引数としてはまっていきます。
型がわかれば、そのオブジェクト構造がなんとなく見えてきます。しかし、標準view
型の個々のクラス構造がわからないとこのパイプライン全体の構造も推し量る事ができません。
標準view
型(主にRangeアダプタ)の型としての構造(第一テンプレート引数に入力view
をとる、推論補助によってviews::all_t
を自動適用する)がある程度一貫していたように、そのクラス構造もまたある程度の一貫性があります。そこでは、入力のview
オブジェクトをコンストラクタで値として受け取って、メンバ変数にムーブして保持しています。
using namespace std::ranges; // 任意のview template<view V, ...> class xxx_view { // 入力viewをメンバとして保持 V base_ = V(); public: // 入力view(と追加の引数)を受け取るコンストラクタ xxx_view(V v, ...) : base_(std::move(v)) {} };
view
コンセプトの定義するview
とは、ムーブ構築がO(1)
で行えて、ムーブされた回数N
と要素数M
から(ムーブ後view
を含む)N
個のオブジェクトの破棄がO(N+M)
で行えて、ムーブ代入の計算量は構築と破棄を超えない程度、であるような型です。owning_view
のような例外を除けば、これは範囲を所有せずにrange
となるような型を指定しており、ムーブ構築のコストは範囲の要素数と無関係に行える事を示しています(ここではview
のコピーについては触れないことにします)。
owning_view
は範囲を所有しますが、ムーブオンリーであるためview
コンセプトの要件を満たすことができる、少し特殊なview
型です。
views::all_t<R>
はR
がview
である時にR
の素の型(prvalueとしての型)を返します。それは右辺値R&&
と左辺値R&
およびconst R
に対して、R
となる型です。このようなCV修飾なし参照なしの型がview
型の入力V
となるため、V
のオブジェクトrv
(これはパイプライン内では右辺値)はコンストラクタ引数v
に対してまずムーブされ、メンバbase_
として保持するためにもう一度ムーブされます。V
がref_view
をはじめとする範囲を所有しないタイプのview
である時、その参照を含むview
オブジェクトごとムーブ(コピー)されメンバとして保存されます。V
がowning_view
のように範囲を所有するview
の場合、その所有権ごとview
オブジェクトをムーブしてメンバとして保存します。その後、そうして構築されたview
オブジェクトは、パイプラインの次の段で同様に次のview
オブジェクト内部にムーブして保持されます。
パイプラインの格段でこのような一時view
オブジェクトのムーブが起きているため、最初に構築されたref_view or owning_view
オブジェクトは最後まで捨てられることなく、パイプラインの一番最後に作成されたオブジェクト内に保持されます。そして、パイプラインの段が重なるごとに、それを包むようにRangeアダプタのview
の層が積み重なっていきます。
イメージとしてはマトリョーシカとか玉ねぎとかそんな感じで、一番中心にパイプラインの起点となった入力range
を参照or所有するview
オブジェクトが居て、それは通常ref_view
かowning_view
のどちらかとなります。
#include <ranges> #include <vector> auto f() -> std::vector<int>; auto even = [](int n) { return 0 < n; }; auto sq = [](int n) { return n * n; }; using namespace std::views; int main() { // f()の戻り値はpipesの奥深くにしまわれている・・・ auto pipes = f() | drop(2) | filter(even) | transform(sq) | take(5); // 安全、f()の戻り値は生存期間内 for (int m : pipes) { std::cout << n << ','; } }
構造を簡単に書いてみると次のようになっています
pipes : take_view
base_ : transform_view
base_ : filter_view
base_ : drop_view
base_ : owning_view
r_ : std::vector<int>
pred_ : even_t
fun_ : sq_t
(変数名は規格書のものを参考にしていますが、この名前で存在するわけではありません)
このようにして、f()
の戻り値である右辺値のstd::vector
オブジェクトの寿命は、パイプラインを通しても延長されています。views::filter
が受け取る述語オブジェクトなども対応する層(view
オブジェクト内部)に保存されており、同様に安全に取り回し、使用する事ができます。
ref_view
の場合
先ほどの例のf()
が左辺値を返している場合、パイプライン最初のdrop_view
構築時のviews::all
適用時には、ref_view
が適用されます。
#include <ranges> #include <vector> auto f() -> std::vector<int>&; auto even = [](int n) { return 0 < n; }; auto sq = [](int n) { return n * n; }; using namespace std::views; int main() { // f()の戻り値は参照されている auto pipes = f() | drop(2) | filter(even) | transform(sq) | take(5); // f()で返されるvectorの元が生きていれば安全 for (int m : pipes) { std::cout << n << ','; } }
この時のpipes
の型は先ほどowning_view<std::vector<int>>
だったところがref_view<std::vector<int>>
に代わるだけで、他起こることは同じです。
ref_view
は次のように定義されています。
namespace std::ranges { // コンストラクタ制約の説明専用の関数 void FUN(R&); void FUN(R&&) = delete; template<range R> requires is_object_v<R> class ref_view : public view_interface<ref_view<R>> { private: // 参照はポインタで保持する R* r_; // exposition only public: // 左辺値を受け取るコンストラクタ template<different-from<ref_view> T> requires convertible_to<T, R&> && // T(右辺値or左辺値参照)がR&(左辺値参照)へ変換可能であること requires { FUN(declval<T>()); } // tが右辺値ならFUN(R&&)が選択され制約を満たさない constexpr ref_view(T&& t) : r_(addressof(static_cast<R&>(std<200b>::<200b>forward<T>(t)))) {} constexpr R& base() const { return *r_; } constexpr iterator_t<R> begin() const { return ranges::begin(*r_); } constexpr sentinel_t<R> end() const { return ranges::end(*r_); } constexpr bool empty() const requires requires { ranges::empty(*r_); } { return ranges::empty(*r_); } constexpr auto size() const requires sized_range<R> { return ranges::size(*r_); } constexpr auto data() const requires contiguous_range<R> { return ranges::data(*r_); } }; // 推論補助、左辺値参照からしか推論できない template<class R> ref_view(R&) -> ref_view<R>; }
コンストラクタはかなりややこしいですが、推論補助と組み合わさって、確実に左辺値のオブジェクトだけを受け取るようになっています。そして、ref_view
は参照する範囲へのポインタを保持してラップすることでrange
型R
をview
へと変換します。また、あえて定義されてはいませんが、ref_view
のコピー/ムーブ・コンストラクタ/代入演算子は暗黙定義されています。
パイプラインへの入力が左辺値である場合、パイプラインによって生成されたマトリョーシカの中心にはref_view
がおり、そこからは元の範囲をポインタによって参照しているわけです。
ref_view
はデフォルト構築不可能であるので、メンバのポインタr_
がnullptr
となることを考慮する必要はないですが、参照先のrange
オブジェクトが先に寿命を迎えれば容易にダングリングとなります。また、ポインタの関節参照のコストも(おそらく最適化で除去可能であるとはいえ)かかることになります。owning_view
が好まれたのは、これらの問題と無縁であることも理由の一つです。