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; } }
クラスC
のstd::vector
をC
の要素のstd::string
の順序によって並び変えたいコードです。コンパイルは通りますし実行もできますが、順番が並び変わることはありません。
なぜかといえば、sort
に渡しているvector
をtransform
しているラムダ式の戻り値型が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_cast
をdecltype(*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_t
はOut
からその間接参照の直接の結果型(reference
)を取得します。
それが真に参照ならば(その型をT&
あるいはT&&
とすれば)、そこにconst
を追加しても何も起こらず、型はT&
あるいはT&&
のままとなります。しかし、iter_reference_t
がprvalueならば(T
とすれば)素直に追加されてconst T
となります。
ここで起きていることはusing U = T&
に対するconst U
のようなことで、これはT& const
(参照そのものに対するconst
修飾)となって、これは参照型には意味を持たないのでスルーされています。
最後にそこに&&
を付加するわけですが、参照が得られているときはT&&& -> T&
、T&&&& -> T&&
となります。*o
がprvalueを返すときはconst T&&
となり、const
右辺値参照が生成されます。
最後にこの得られた型を用いて*o
をconst_cast
しそこに対する代入をテストするわけですが、この過程をよく見てみれば*o
が参照を返している場合は実質的に何もしておらず、すぐ上にある制約式と等価となっています。
つまり、このconst_cast
を用いる制約式は*o
がprvalueを返しているときにしか意味を持ちません。そして、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
修飾に思いを馳せる必要があります。
参考文献
std::indirectly_writable
- cpprefjp- ericniebler/range-v3 Readable types with prvalue reference types erroneously model IndirectlyMovable - Github
- ericniebler/stl2 Readable types with prvalue reference types erroneously model Writable - Github
- P2214R0 A Plan for C++23 Ranges
std::vector<bool>::reference
- cppreference
謝辞
この記事の99割は以下の方々のご指摘によって成り立っています