[C++]owning_viewによるパイプライン安全性

この記事は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_); }
  };
}

ムーブコンストラクタを除くとコンストラクタは一つしかなく、そこではRrangeかつmovable)の右辺値(これはフォワーディングリファレンスではありません)を受け取り、それをメンバ変数r_にムーブして保持します。このようにして入力の右辺値範囲の寿命を延長しており、それ以外の部分は見てわかるように元のRの薄いラッパです。

views::allviews::all_t

owning_viewを生成するためのRangeアダプタとしてviews::allが用意されていますが、views::allowning_viewだけでなくref_viewも返します。

Rのオブジェクトrに対して、views::all(r)のように呼ばれた時の効果は

  1. Rviewのモデルであるなら、rをdecay-copyして返す
    • decay-copyはrをコピーorムーブしてその型の新しいオブジェクトを作ってそれを返すこと
  2. rが左辺値ならref_view(r)
  3. rが右辺値ならowning_view(std::move(r))

このように、views::allrangeを入力としてviewを返すもので、別の言い方をするとrangeviewに変換するものです。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_viewowning_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_tviews::allの戻り値型を求めるもので、次のように定義されます。

namespace std::ranges::views {

  template<viewable_range R>
  using all_t = decltype(all(declval<R>()));
}

このxxx_viewxxx_view{r}のように使用した時、クラステンプレートの実引数推定が起こることによって1つだけ定義されている推論補助が使用され、rの型Rviews::all_t<R>のように通して、views::all(r)の戻り値型をxxx_viewのテンプレートパラメータVとして取得します。views::allの戻り値型は、rviewならその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_viewviews::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>Rviewである時にRの素の型(prvalueとしての型)を返します。それは右辺値R&&と左辺値R&およびconst Rに対して、Rとなる型です。このようなCV修飾なし参照なしの型がview型の入力Vとなるため、Vのオブジェクトrv(これはパイプライン内では右辺値)はコンストラクタ引数vに対してまずムーブされ、メンバbase_として保持するためにもう一度ムーブされます。Vref_viewをはじめとする範囲を所有しないタイプのviewである時、その参照を含むviewオブジェクトごとムーブ(コピー)されメンバとして保存されます。Vowning_viewのように範囲を所有するviewの場合、その所有権ごとviewオブジェクトをムーブしてメンバとして保存します。その後、そうして構築されたviewオブジェクトは、パイプラインの次の段で同様に次のviewオブジェクト内部にムーブして保持されます。

パイプラインの格段でこのような一時viewオブジェクトのムーブが起きているため、最初に構築されたref_view or owning_viewオブジェクトは最後まで捨てられることなく、パイプラインの一番最後に作成されたオブジェクト内に保持されます。そして、パイプラインの段が重なるごとに、それを包むようにRangeアダプタのviewの層が積み重なっていきます。

イメージとしてはマトリョーシカとか玉ねぎとかそんな感じで、一番中心にパイプラインの起点となった入力rangeを参照or所有するviewオブジェクトが居て、それは通常ref_viewowning_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は参照する範囲へのポインタを保持してラップすることでrangeRviewへと変換します。また、あえて定義されてはいませんが、ref_viewのコピー/ムーブ・コンストラクタ/代入演算子は暗黙定義されています。

パイプラインへの入力が左辺値である場合、パイプラインによって生成されたマトリョーシカの中心にはref_viewがおり、そこからは元の範囲をポインタによって参照しているわけです。

ref_viewはデフォルト構築不可能であるので、メンバのポインタr_nullptrとなることを考慮する必要はないですが、参照先のrangeオブジェクトが先に寿命を迎えれば容易にダングリングとなります。また、ポインタの関節参照のコストも(おそらく最適化で除去可能であるとはいえ)かかることになります。owning_viewが好まれたのは、これらの問題と無縁であることも理由の一つです。

参考文献

この記事のMarkdownソース