[C++]地に足のついた範囲for文

この記事は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::vectorprvalueを返す関数です。その戻り値は一時オブジェクトであるので、値として受けるかauto&&で受けるなどして寿命を延長する必要があります。範囲for文でもそれは行われるので、最初のfor文は問題ありません。

ところが、2つ目のfor文はf()の戻り値からその要素を引き出しています。ここで問題なのは、要素数が不明なことではありません。f().at()の戻り値はlvaluestd::string&)であり、範囲forはこの結果のオブジェクトだけを保存してループを廻してくれます。その結果、f()の直接の戻り値はf().at(0)の後で捨てられ、当然ここから取得したstd::string&の参照はダングリング参照となります。そして、ダングリング参照のあらゆる利用は未定義動作です。

なぜ?

範囲for文はシンタックスシュガーであり、その実態は通常のfor文によるコードへ展開される形で実行されます。

例えば、規格においては範囲forの構文はつぎのように規定されています

for ( init-statement(opt) for-range-declaration : for-range-initializer ) statement

init-statementforの初期化式(C++20 初期化式をともなう範囲for文)で(opt)は省略可能であることを表します。

for-range-declarationfor(auto&& v : r)auto&& vの部分で、for-range-initializerrの部分です。

残ったstatementfor文の本体です。

そして、これは次のように展開されて実行されます

{
    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さんという人がメインの著者です。そして記載されているメールアドレスから察するにこの人はドイツの方のようです。

参考文献

この記事のMarkdownソース