[C++]indirectly_writableコンセプトの謎の制約式の謎

std::indirectly_writableコンセプトはイテレータによる出力操作を定義するコンセプトで、std::output_iteratorコンセプトの中核部分を成しています。

template<class Out, class T>
concept indirectly_writable = 
  requires(Out&& o, T&& t) {
    *o = std::forward<T>(t);
    *std::forward<Out>(o) = std::forward<T>(t);
    const_cast<const iter_reference_t<Out>&&>(*o) = std::forward<T>(t);
    const_cast<const iter_reference_t<Out>&&>(*std::forward<Out>(o)) = std::forward<T>(t);
  };

定義を見てみると、見慣れない構文を用いた良く分からない制約式が入ってるのが分かります。

const_cast<const iter_reference_t<Out>&&>(*o) = std::forward<T>(t);
const_cast<const iter_reference_t<Out>&&>(*std::forward<Out>(o)) = std::forward<T>(t);

常人ならばおおよそ使うことの無いであろうconst_castをあろうことかC++20のコンセプト定義で見ることになろうとは・・・

cpprefjpには

const_castを用いる制約式は、右辺値に対しても代入できるがconstな右辺値では代入できなくなる非プロキシイテレータprvalue(例えばstd::stringそのものなど)を返すイテレータを弾くためにある。これによって、間接参照がprvalueを返すようなイテレータ型はindirectly_writableのモデルとならないが、出力可能なプロキシオブジェクトを返すイテレータindirectly_writableのモデルとなる事ができる。

とあり、規格書にも似たようなことが書いてありますが、なんだかわかったような分からないような・・・

これは一体何を表しているのでしょうか、またどういう意図を持っているのでしょう?

prvalueを返すようなイテレータ

どうやらこれはrange-v3において発見された問題に端を発するようです。

struct C {
  explicit C(std::string a) : bar(a) {}

  std::string bar;
};

int main() {
  std::vector<C> cs = { C("z"), C("d"), C("b"), C("c") };

  ranges::sort(cs | ranges::view::transform([](const C& x) {return x.bar;}));

  for (const auto& c : cs) {
    std::cout << c.bar << std::endl;
  }
}

クラスCstd::vectorCの要素のstd::stringの順序によって並び変えたいコードです。コンパイルは通りますし実行もできますが、順番が並び変わることはありません。

なぜかといえば、sortに渡しているvectortransformしているラムダ式の戻り値型がstd::stringの参照ではなくprvalueを返しているからです。

割とよくありがちなバグで、戻り値型をきちんと指定してあげれば意図通りになります。

ranges::sort(cs | ranges::view::transform([](const C& x) -> std::string& {return x.bar;}));

しかし、ranges::sortはrange-v3にあるindirectly_writableコンセプトで制約されているはずで、この様なものは出力可能とは言えず、indirectly_writableを満たしてほしくは無いしコンパイルエラーになってほしいものです。

prvalueの区別

この問題は突き詰めると

std::string() = std::string();

の様な代入が可能となっているという点に行きつきます。

この様な代入操作は代入演算子の左辺値修飾で禁止できるのですが、標準ライブラリの多くの型の代入演算子は左辺値修飾された代入演算子を持っていません。メンバ関数の参照修飾はC++11からの仕様であり、C++11以前から存在する型に対して追加することは出来ず、それらの型に倣う形で他の型でも参照修飾されてはいません。

これを禁止する為の方法は、単純には間接参照の結果が常に真に参照を返すことを要求することです。

その時に問題となるのが、イテレータの間接参照でプロキシオブジェクトが得られるようなイテレータです。当然そのようなプロキシオブジェクトはprvlaueなので、出力可能であるはずでもindirectly_writableを満たさなくなってしまいます。

そうなると、プロキシオブジェクトを識別してそのprvalueへの出力は許可する必要があります。

プロキシオブジェクトはその内部に要素への参照を秘めているオブジェクトであって、自身のconst性と参照先のconst性は無関係です。従って、constであるときでも出力(代入)が可能となります。

一方、std::string等の型は当然constであるときに代入可能ではありません。

そして、イテレータoについて、decltype(*o)が真に参照を返すとき、そこにconstを追加しても効果はありません。

これらの事から、間接参照がprvalueを返すときにプロキシオブジェクト以外の出力操作を弾くためには、const_castdecltype(*o)に対して適用してconstを付加してから、出力操作をテストすれば良いでしょう。

この結果得られたのが、indirectly_writableにある謎の制約式です。

template<class Out, class T>
concept indirectly_writable = 
  requires(Out&& o, T&& t) {
    *o = std::forward<T>(t);
    *std::forward<Out>(o) = std::forward<T>(t);
    // ↓これ!
    const_cast<const iter_reference_t<Out>&&>(*o) = std::forward<T>(t);
    const_cast<const iter_reference_t<Out>&&>(*std::forward<Out>(o)) = std::forward<T>(t);
  };

std::forward<Out>の差で制約式が2本あるのは、Outに対する出力操作がその値カテゴリによらない事を示すためです。つまり、lvalueは当然として、イテレータそのものがprvalueであっても出力操作は可能であり、そうでなければなりません。これは今回の事とはあまり関係ありません。

iter_reference_tOutからその間接参照の直接の結果型(reference)を取得します。

それが真に参照ならば(その型をT&あるいはT&&とすれば)、そこにconstを追加しても何も起こらず、型はT&あるいはT&&のままとなります。しかし、iter_reference_tprvalueならば(Tとすれば)素直に追加されてconst Tとなります。

ここで起きていることはusing U = T&に対するconst Uのようなことで、これはT& const(参照そのものに対するconst修飾)となって、これは参照型には意味を持たないのでスルーされています。

最後にそこに&&を付加するわけですが、参照が得られているときはT&&& -> T&T&&&& -> T&&となります。*oprvalueを返すときはconst T&&となり、const右辺値参照が生成されます。

最後にこの得られた型を用いて*oconst_castしそこに対する代入をテストするわけですが、この過程をよく見てみれば*oが参照を返している場合は実質的に何もしておらず、すぐ上にある制約式と等価となっています。

つまり、このconst_castを用いる制約式は*oprvalueを返しているときにしか意味を持ちません。そして、const T&&なオブジェクトへの出力(代入)ができるのはTがプロキシオブジェクト型の時だけとみなすことができます。

この様にして、冒頭のコード例の様に意図せずprvalueを返すケースをコンパイルエラーにしつつ、意図してプロキシオブジェクトのprvalueを返す場合は許可するという、絶妙に難しい識別を可能にしています。

そして、これこそが問題の制約式の存在意義です。

std::vector<bool>::reference

イテレータの間接参照がプロキシオブジェクトを返すようなイテレータには、std::vector<bool>イテレータがあります。そのreferenceは1ビットで保存されたbool値への参照となるプロキシオブジェクトのprvlaueであり、まさに先ほどの議論で保護すべき対象としてあげられていたものです。

が、実際にはstd::vector<bool>イテレータstd::indirectly_writableコンセプトを構文的にすら満たしません。まさにこのconst_castを用いる制約式に引っかかります。

int main() {
  // 失敗する・・・
  static_assert(std::indirectly_writable<std::vector<bool>::iterator, bool>);
}

エラーメッセージを見ると、まさにそこを満たしていないと指摘されているのが分かります。

なぜかというと、std::vector<bool>::referenceのプロキシオブジェクトには代入演算子はあってもconst修飾されていないためです。const化してしまうと代入できなくなってしまいます。自身のconst性と参照先のそれとは無関係のはずなのに・・・

C++23に向けてここを修正する動きはあるようですが、この様なプロキシオブジェクトを用いるイテレータを作成するときは、プロキシオブジェクトの代入演算子const修飾に思いを馳せる必要があります。

参考文献

謝辞

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

この記事のMarkdownソース