Skip to content

C++ • Temporary lifetime extension and RVO

We all know that temporary objects are quite common in C++. They materialize in several scenarios, which will not be listed here and that can be easily found in any good C++ reference book or site. Generally speaking, a temporary object is anything that cannot be referenced by name, historically referred to as r-values in C++-ish legal jargon (and more precisely a “materialized prvalue” in modern C++). Typical examples seen in everyday programming are literal constants and return values.

int foo() {
    return 1;   // Literal '1' generates a temporary
}

int r = foo();  // foo() returns a temporary
int s = 3;      // '3' generates a temporary

The lifetime of a temporary is determined by the expression in which they are generated. More precisely, citing the C++ standard, “… temporary objects are destroyed as the last step in evaluating the full-expression that (lexically) contains the point where they were created…”. So, in the above example the integer created by foo() is destroyed after the function returns and the value is copied in ‘r’, and the same goes for the literal integer ‘3’. As a consequence, the following code is invalid

int foo() {
    return 1;
}

int& r = foo();  // ERROR: dangling reference
int& s = 3;      // ERROR: dangling reference

Trying to compile the above code will result in a compilation error, with some compilers (such as GCC) complaining that we “cannot bind a non-const reference to an r-value“, that is we can’t bind a reference that’s non-const qualified to a temporary object. Doing so, we’ll get a “dangling” reference, which is a reference to an undefined thing. This makes sense since temporaries are destroyed once the expressions where they live are fully executed, therefore the program would exhibit undefined behavior.

But, there are exceptions to the lifetime rule cited above. One of these exceptions allows extending the lifetime of a temporary object by binding to a const l-value reference or to an r-value reference. That is, if we qualify the references with const or use an r-value reference (&&) the code becomes valid

int foo() {
    return 1;
}

const int& r = foo();  // OK: lifetime of foo()'s temporary is extended
const int& s = 3;      // OK: lifetime of '3's temporary is extended

// or

int&& r = foo();  // OK: as above
int&& s = 3;      // OK: as above

In the above example, both ‘r’ and ‘s’ are bound to the temporaries, whose lifetime have been extended to match those of the references. There is a substantial difference between extending the lifetime by using a const-reference and an r-value reference. With a const-reference the object becomes immutable and can’t be modified, unless the constness is explicitly dropped with a cast, while with an r-value reference the object can be modified without explicit casts (unless it’s explicitly marked as const).

That’s not it, however. In the good spirit of C++, things can get even more murky. Consider, for example, the following code

const int& foo() {
    return 1;
}

int main() {
   const auto& x = foo();  // ???
   return x;
}

One may think that, due to the lifetime extension rule, the above code will return ‘1’. That is actually not the case. The code compiles but the result is undefined (depending on the compiler, it may even be ‘1’!). The reason being that the Standard has exceptions to the lifetime extension rule’s exception, and one of them states that “…the lifetime of a temporary bound to the returned value in a function return statement is not extended; the temporary is destroyed at the end of the full-expression in the return statement…”. So, even though we have bound the return value to a const int& in foo(), its lifetime is not extended. It is destroyed after the return expression is executed and we get a dangling reference again!

But there is more. Consider now the following code

const int& foo(const int& x) {
    return x;
}

int main() {
   const auto& x = foo(5);  // ???
   return x;
}

A temporary literal (5) is created and then passed to foo() as an argument. This temporary is then passed back by const-reference in the return value of the function and then bound to a const-reference in the calling code. Is the lifetime of the temporary extended? Again, one may think it is, but actually it is not. Another exception rule from the Standard states that “…a temporary bound to a reference parameter in a function call persists until the completion of the full-expression containing the call…”.

So, despite binding the temporary to a const-reference, it is actually destroyed after the full statement is executed, leaving us with a dangling pointer. What these two exception rules are telling us basically is that lifetime extension is not transitive, that is it is not possible to keep extending the lifetime of a temporary by passing it around and binding it to other const-references. It only works if the binding is directly done to the temporary object.

How about struct/class type objects? Consider the following example

struct S {
   int a;
};

S foo() {
   return S{5};
}

int main() {
   const auto& a = foo().a;
   return a;
}

A const-reference is bound to a struct member and not directly to the struct object. A question arises: is it only the lifetime of the member that’s extended or of the whole object? In this case, the Standard guarantees that binding a reference to a temporary sub-object will also extend the lifetime of the containing object. However, the following code will not work

struct S {
   const int& get_a() const {
      return a;
   }
 private:
   int a;
};

S foo() {
   return S{5};
}

int main() {
   const auto& a = foo().get_a();  // ???
   return a;
}

Again, the binding must be done directly to the object (or subobject) and not transitively thru a reference. In the above case we’ll get a dangling reference, and thus undefined behavior. The subobject rule should also apply to hierarchical objects, that is derived classes. If a const-reference is bound to a base class then the lifetime of the derived is also extended.

While all of these rules “just work”, nowadays they find very little (if any) application. In fact, it is worth noting that modern C++ assures that code is optimized in specific circumstances, which include the above-mentioned cases. That is, the following code

void bar(T t) {...}

T foo() {
  return T{};
}

auto f = foo();
auto g = T{};
bar(T{});

is guaranteed in all cases (since C++17) to undergo copy/move elision (aka Return Value Optimization or RVO in case of values returned by functions) and in-place construction, meaning that we can return, initialize and pass by (pr)value and no copies, moves or temporaries are ever made.

Conclusion

I have always questioned the utility of this feature. It appears that many developers consider it a way to avoid making copies. And this was probably also the rationale behind its introduction in legacy C++. It is also a way to prevent accidentally producing dangling references. However, while this may have been a valid point in the past, things have changed substantially with the latest Standards.

Modern C++ has introduced powerful mechanisms that guarantee copy and move elision in specific cases and offer compilers the opportunity to extend its application. Move semantics makes sure that no copies are made where RVO is not possible, if the objects support move operations. Furthermore, most modern compilers have become smart enough to apply copy/move elision as an optimization in many cases that are not guaranteed by the Standard. So, all in all, considering the murkiness of this feature and the consequences of its potential abuse, it is safe to say that avoiding its use is good practice.

Published inModern C++