c++for-loopgeneratorundefined-behaviorc++23

C++23 range-based for loop lifetime fixes interraction with `std::generator` arguments


C++23 was supposed to fix issues around range-based for loops. The C++ standard now has wording that I can trace to p2718r0, including the following example at the end of the section "The range-based for statement [stmt.ranged]" (note the undefined behavior comment:

using T = std::list<int>;
const T& f1(const T& t) { return t; }
const T& f2(T t) { return t; }
T g();

void foo() {
  for (auto e : f1(g())) {} // OK, lifetime of return value of g() extended
  for (auto e : f2(g())) {} // undefined behavior
}

Let's say I write a coroutine using the C++23 std::generator that takes an argument by value i.e. following C++ core guidelines rule CP.53: Parameters to coroutines should not be passed by reference:

#include <generator>
#include <string>
#include <iostream>

std::generator<std::string> generator_f(std::string x) {
  co_yield x;
}

std::string sample_g() { return "abc"; }

int main() {
  for(const auto& elem: generator_f(sample_g())) {
    std::cout << elem << "\n";
  }
  return 0;
}

I assume that based on the example in the C++ standard, the code in the for loop in main() above is undefined behavior.

What's the rationale behind the undefined behavior rule illustrated by the example in the C++ standard? The downside is that it makes error prone the usage of a std::generator with arguments in a for loop (which is where you would want to use a generator, i.e. not really fixing lifetime problems with range-based for statements).


Solution

  • The undefined behavior in this code:

      for (auto e : f2(g())) {} // undefined behavior
    

    comes from f2() itself:

    const T& f2(T t) { return t; }
    

    This function is returning a reference to its parameter t, which is destroyed at the end of the function call. That's not at all tied in to the expression f2(g()).

    The difference in this code:

    for (auto e : f1(g())) {} // OK
    

    is that f1() returns a reference to the temporary object returned by g(), which lasts until the end of the full-expression that creates it. But previously, that full-expression was the declaration of the compiler-generated range variable, not the actual loop. The change for C++23 is that now the lifetime of that temporary is extended through the whole loop.

    That's not the situation with f2() at all. f2() isn't returning a reference to the temporary object returned by g(), it is returning a reference to its own parameter t. Extending the lifetime of the temporary returned by g() through the whole loop doesn't fix the problem.


    On the other hand, this code:

    int main() {
      for(const auto& elem: generator_f(sample_g())) {
        std::cout << elem << "\n";
      }
      return 0;
    }
    

    has always been fine. Even before P2718. There's not even any references binding to temporaries in this example.