この記事はC++ Advent Calendar 2022の5日目の記事です。
問題です。次のコードには未定義動作が少なくとも1つ含まれています。それは何でしょう?
#include <vector> #include <string> // どこかで定義されているとして auto f() -> std::vector<std::string>; int main() { for (auto&& str : f()) { std::cout << str << '\n'; } for (auto&& c : f().at(0)) { std::cout << c << ' '; } }
以下、この記事ではここのf()
をたびたび再利用しますが、宣言は再掲しません。
答え
#include <vector> #include <string> auto f() -> std::vector<std::string>; int main() { for (auto&& str : f()) { std::cout << str << '\n'; } for (auto&& c : f().at(0)) { // 👈 この行 // ^^^^^^^^^ std::cout << c << ' '; } }
f()
はstd::string
を要素に持つstd::vector
のprvalueを返す関数です。その戻り値は一時オブジェクトであるので、値として受けるかauto&&
で受けるなどして寿命を延長する必要があります。範囲for
文でもそれは行われるので、最初のfor
文は問題ありません。
ところが、2つ目のfor
文はf()
の戻り値からその要素を引き出しています。ここで問題なのは、要素数が不明なことではありません。f().at()
の戻り値はlvalue(std::string&
)であり、範囲for
はこの結果のオブジェクトだけを保存してループを廻してくれます。その結果、f()
の直接の戻り値はf().at(0)
の後で捨てられ、当然ここから取得したstd::string&
の参照はダングリング参照となります。そして、ダングリング参照のあらゆる利用は未定義動作です。
なぜ?
範囲for
文はシンタックスシュガーであり、その実態は通常のfor
文によるコードへ展開される形で実行されます。
例えば、規格においては範囲for
の構文はつぎのように規定されています
for ( init-statement(opt) for-range-declaration : for-range-initializer ) statement
init-statement
はfor
の初期化式(C++20 初期化式をともなう範囲for文)で(opt)
は省略可能であることを表します。
for-range-declaration
はfor(auto&& v : r)
のauto&& v
の部分で、for-range-initializer
はr
の部分です。
残ったstatement
はfor
文の本体です。
そして、これは次のように展開されて実行されます
{ init-statement(opt) auto &&range = for-range-initializer ; // イテレート対象オブジェクトの保持 auto begin = begin-expr ; // std::begin(range)相当 auto end = end-expr ; // std::end(range)相当 for ( ; begin != end; ++begin ) { for-range-declaration = * begin ; statement } }
つまりはうまい事イテレータを使ったループに書き換えているわけです。そして、問題は展開後ブロック内の3行目にあります。
auto &&range = for-range-initializer ;
この式では、auto&&
で範囲for
のイテレート対象オブジェクトを受けており、これによって左辺値も右辺値も同じ構文で受けられ、なおかつ右辺値に対しては寿命延長がなされます。ここに先程のfor
文から実際の式をあてはめてみてみましょう。
// 1つ目のforから auto &&range = f() ; // ✅ ok // 2つ目のforから auto &&range = f().at(0) ; // 💀 UB
2つ目の初期化式の何が問題なのかというと、変数range
に受けられているのはf().at(0)
の戻り値(std::string&
)であって、f()
の直接の戻り値であり.at(0)
で取り出したstd::string
の本体を所有するオブジェクト(std::vector<std::string>
)はどこにも受けられていないからです。
このような一時オブジェクトの寿命(lifetime)はその完全式の終わりに尽きる、と規定されていて、それはとても簡単にはその式を閉じる;
です。すなわち、この2つ目の初期化式ではf()
の戻り値の寿命はこの行で尽き、そこから取り出されたすべての参照はダングリング参照となります。
これを回避するにはf()
の戻り値を直接受けてからその要素を参照すればいいので、例えば上記初期化式を次のようにすればいいわけです
auto &&range0 = f(); // ✅ ok auto &&range = range0.at(0) ; // ✅ ok
ただし、ユーザーコードからでは展開後のコードをこのようにすることはできないので、範囲for
の構文でできる範囲の事をしなければなりません。
int main() { { // 範囲forの外で受けておく auto tmp = f(); for (auto&& c : tmp.at(0)) { // ✅ ok ... } } { // 初期化式を利用する for (auto tmp = f(); auto&& c : tmp.at(0)) { // ✅ ok ... } } }
C++20で追加された範囲for
文における初期化式は、この問題の回避策として導入されたものでもあります。
その他の例
これだけならめでたしめでたしで終わりそうですので、さらに変な例を置いておきます。
struct Person { std::vector<int> values; const auto& getValues() const { return values; } }; // prvalueを返す auto createPerson() -> Person; int main() { for (auto elem : createPerson().values) { // ✅ ok ... } for (auto elem : createPerson().getValues()) { // 💀 UB ... } }
なんでこれ1つ目のfor
文がokになるんでしょうね。
#include <optional> #include <string> auto f() -> std::optional<std::string>; int main() { for (auto c : f().value()) { // 💀 UB ... } }
#include <optional> #include <string> struct S { std::string str; auto& value() && { return str; } auto&& rvalue() && { return std::move(str); } }; auto f() -> S; auto g() -> std::optional<std::string>; int main() { for (auto c : f().value()) { // ✅ ok ... } for (auto c : f().rvalue()) { // 💀 UB ... } for (auto c : g().value()) { // 💀 UB ... } }
この差が何で生まれるんでしょうか・・・
#include <vector> #include <span> auto f() -> std::vector<int>; int main() { for (auto n : std::span{f().data(), 2}) { // 💀 UB ... } }
#include <variant> #include <string> auto f() -> std::variant<std::string, int>; int main() { for (auto c : std::get<std::string>(f())) { // 💀 UB ... } }
#include <tuple> #include <string> auto f() -> std::tuple<std::string, int>; int main() { for (auto c : std::get<0>(f())) { // 💀 UB ... } }
#include <map> #include <string> auto f() -> std::map<int, std::string>; int main() { for (auto c : f()[0]) { // 💀 UB ... } }
#include <coroutine> #include <string> // std::lazyはC++26予定 auto f() -> std::lazy<std::string&>; std::lazy<> g() { for (auto c : co_await f()) { // 💀 UB(コルーチンローカルのstd::stringへの参照を返す場合) ... } }
さて、これらの例を見て、これらの問題のあるコードを絶対書かないと断言できるでしょうか?私はやってしまいそうです・・・
初学者やC++言語そのものにさほど興味のないプログラマなど、範囲for
の仕様を知らない場合はこの問題に気付くことはできないでしょう。この問題を把握するほど詳しい人でも、この問題の起こる場所が範囲for
に隠蔽されていることによって、ぱっと見て気づくことが難しい場合があるでしょう。
この問題は範囲for
に初期化子を指定できるようにした程度で解決できるようなものではなく、より確実な解決策が必要な問題です。
C++23における解決
この問題はP2644R0の採択によって、C++23にてようやく解決されます。
解決は単純で、範囲for
の初期化式(構文定義上のfor-range-initializer
)内で作成されたすべての一時オブジェクトの寿命は範囲for
文の完了(ループ終了)まで延長される、と規定されるようになります。
展開後のコードに何かアドホックなものを加えるわけではなく、この規定によってこれを実装したコンパイラでは範囲for
文は完全に安全になり、ここまでに紹介したようなUBの例の問題はすべて解消(UBではなくなる)されます。
実際にどのようにこれがなされるのかは実装定義です。Cの複合リテラルのようにするかもしれないし、展開後コードが初期化式を分解しているかもしれません。いずれにせよ、この変更によって既存のプログラムの動作が壊れることはないはずです。
なお、これはC++23に対する修正であり、C++20以前のバージョンに対する欠陥報告ではありません。少なくとも今のところは
紆余曲折
ここからは余談です。
この問題が把握されたのは近年かというとそんなわけはなく、少なくとも13年前(2009年)には把握されていました(CWG Issue 900)。そう、C++11策定よりも前です。また、その後もたびたび同様のIssueが提出されていたようです。
なぜかは知りませんがなかなか解決がされないまま、ようやくこの解決のための提案(P2012R0)が提出されたのが2020年の11月、もはやC++20に間に合わせるのもつらい時期でした。
P2012はEWGの議論においてその解決の必要性が確認されたものの、なぜかその後C++23に向けてP2012を進めるところでコンセンサスが得られず、提案の追求は停止されました。
その後1年ほど動きが無く、もはや忘れられたのかと誰もが思っていた頃、2022年10月後半にドイツのWG21 NB(national body)からのC++23 CD(committee draft)に対するNBコメントと共に、P2644R0が提出されました。
P2644はP2012を踏襲したもので、そこで提案されていた解決策の一つ(範囲for
の初期化式内で生成された一時オブジェクトの寿命を延長するように規定する)を再提案するものでした。これがそのまま2022年11月にKona(ハワイ)で行われたWG21全体会議においてスピード採択され、C++23に適用されることになりました。
P2644によれば、P2012が合意を得られなかったのは本質的な一時オブジェクトの寿命問題について、範囲for
だけにとどまらないより広範な解決策がのぞまれたため、だったようです。つまり、範囲for
の展開後のコードに対するアドホックな対応は忌避され、かといって標準文言による規定も将来の広範な解決策を妨げてしまうかも・・・と考えられたようです。
おそらくそのような解決策とはP2623のようなものをいうのでしょうが、これはC++23に間に合うものでもなく、範囲for
のこの問題を解決するための施策は結局何も取られていませんでした。ドイツからのNBコメント及びP2644はそのような状況にしびれを切らして提出されたようです。P2644の提案の内容は、どうやって寿命を延長するだとかいう部分は何も言っていないため、将来的なソリューションを妨げないようにされています。
ところで、P2012もP2644も同じNicolai Josuttisさんという人がメインの著者です。そして記載されているメールアドレスから察するにこの人はドイツの方のようです。
参考文献
- P2644R0 Get Fix of Broken Range-based for Loop Finally Done
- P2644 Get Fix of Broken Range-based for Loop Finally Done - cplusplus/papers
- P2012R0 Fix the range-based for loop, Rev0ix the range-based for loop - WG21月次提案文書を眺める(2020年11月)
- P2644R0 Get Fix of Broken Range-based for Loop Finally Done - WG21月次提案文書を眺める(2022年10月)