Skip to content

C++ • Temporary lifetime extension and RVO

One of the concepts that C++ programmers have to deal with is that of a “temporary object”. They are quite common in C++ and materialize in several scenarios, which will not be listed here as these 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 – ignoring any optimization that compilers may perform – are return values and literal constants. Prior to the latest Standards (i.e. C++17 and above) developers had to be careful as of when such objects may pose performance issues. Consider the following example

T foo() {
    return T{};  // T{} generates a temporary
}

T t1 = foo();    // foo() returns a temporary
T t2 = T{};      // T{} generates a temporary

In legacy C++ and even in the early releases of its modern versions, initializing the objects t1 and t2 as done in the above code would generally create a temporary object T returned by a function or directly instantiated. This object would then be copied or at best moved (from C++11) in the destination variables and then destroyed. Smart compilers have usually been able to avoid generating such temporary objects in specific cases but there was no guarantee from the Standard.

These potential extra copies or moves might affect the performance of the application, depending on the context, so C++ programmers have traditionally found ways to get around this issue. One of the techniques that could be used is that of extending the lifetime of the temporary objects so that they could be used further before being destroyed. Temporary lifetime extension is the name given to such technique but before discussing that let’s make clear what determines a temporary object’s lifetime.

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 T created by foo() is destroyed after the function returns and the value is copied in t1, and the same goes for the direct instantiation case for t2. As a consequence, the following code is invalid

T foo() {
    return T{};
}

T& t1 = foo();   // ERROR: dangling reference
T& t2 = T{};     // ERROR: dangling reference

Trying to compile the above code will result in compilation errors spitting out messages along the line of “cannot bind a non-const reference to an r-value“, which in more human terms simply means that we can’t bind a reference that’s not 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 if C++ allowed that.

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

T foo() {
    return T{};
}

const T& t1 = foo();  // OK: lifetime of foo()'s temporary is extended
const T& t2 = T{};    // OK: lifetime of T{}'s temporary is extended

// or

T&& t1 = foo();  // OK: as above
T&& t2 = T{};    // OK: as above

In the above example, both t1 and t2 are bound to the temporaries and this causes their lifetime to be extended to match those of the references. In other words, the temporary objects are destroyed when the references go out of scope and not when the expressions that generate them are fully executed.

However, 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 with 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 T& foo() {
    return T{};
}

const T& t = foo();  // ???

One may think that, due to the lifetime extension rule, the above code is just fine. That is actually not the case. The code compiles but the result is undefined (most compilers will issue warnings anyways). 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 T& 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 T& foo(const T& t) {
    return t;
}

const T& t = foo(T{});  // ???

A temporary T 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 reference once again. Even though it seems that some compilers allow the extension, 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 T {
   int a;
};

T foo() {
   return T{};
}

const auto& a = foo().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 is incorrect

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

T foo() {
   return T{};
}

const auto& a = foo().get_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 shall 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.

Now, while all of these rules “just work”, nowadays they find very little application. In fact, modern C++ provides more robust support to avoid binding temporaries to references and pointers (which may dangle) and assures that no unnecessary copies and/or moves are made in specific circumstances, including the above-mentioned cases. Specifically, the following code

void bar(T t) {...}

T foo() {
  return T{};
}

auto t1 = foo();   // Return value of foo() created in t1
auto t2 = T{};     // Instance of T created in t2
bar(T{});          // Instance of T created in parameter t

is guaranteed in all cases since C++17 to undergo what’s historically been known as copy elision – including move elision since C++11 – even though in modern C++ a more correct definition would be in-place construction. In fact, there is no more temporary object being created and copied/moved but only objects that “materialize” right in the destination variable only when required (remember that temporary objects may be discarded).

This feature is also known as Return Value Optimization (RVO) in the case of functions and compilers were allowed to perform these actions as optimization steps during compilation. In modern C++ it is no longer an optimization but a feature of the language. What this means is that there is no more the need to dabble with references or hope for the compilers to do RVO as we can now return, initialize and pass by (pr)value and the language guarantees that no copies, moves or temporaries are ever made.

Note that RVO is only guaranteed for r-values (more correctly, pr-values), that is “disposable” objects created on-the-fly with T{}. The following code is not guaranteed to undergo RVO

T foo() {
  T moo{};
  // ...
  return moo;
}

auto t1 = foo();

Here what’s being returned is not a pr-value but an l-value, that is a named object. A copy or move may take place – depending on how T is defined – but most modern compilers are able to perform RVO in many circumstances, which in this case is known as Named Return Value Optimization (NRVO). It is important to remember, however, that this is not guaranteed by the language and is considered an optimization not a feature.

Conclusion

I have always questioned the utility of temporary lifetime extension. 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 its murkiness and the consequences of its potential abuse, temporary lifetime extension is certainly not among the best practices of modern C++.

Published inModern C++