Temporary Lifetime Extension: Mistakes and Solutions

cover
3 Jun 2024

What Is a Temporary Lifetime Extension?

Temporary lifetime extension (TLE) is a C++ language feature that allows you to extend the lifetime of a temporary if it is bound to the constant lvalue, constant rvalue, or modifiable rvalue reference. Specifically, this feature makes the following code correct:

#include <utility>

using T = std::pair<int, int>;

T func(int x) { return T{x, x}; }

int main() {
   const T& cv = func(7);
   T&& rv = func(5);
   const T&& crv = func(3);
   return cv.first + rv.first + crv.first;
}

In this code, with each func call, we create a new temporary instance of type T, which we bind to some kind of reference. The feature guarantees that because of this binding, the created temporaries will not be destroyed at the end of the creating expression, but their lifetime will also be extended to match the lifetime of the bound reference.

In other words, because of this binding, temporary will be destroyed when the bound reference goes out of the scope. Effectively, TLE implements owning semantics for the reference, when the situation satisfies the requirements of the feature.

Note that in terms of temporary lifetime extension, all the reference kinds, presented here, behave in the same way. Therefore, further in the text, we will consider only a const T& case.

Why Do We Need It?

TLE comes up with an “owning” role for references when they were not designed for that. By definition, reference is just a “link” to the corresponding object, and guaranteeing that the object is still there is a programmer’s headache. So why were a bunch of rules broken and a handful of new corner cases created? Because of performance.

It is typical to think that binding to the reference allows you to drop the redundant copy which can be made to store the returned value from the function to the local variable. But it does not work that way!

#include <cstdio>

struct S {
    S() { std::puts("S()"); }
    S(const S&) { std::puts("S(const S&)"); }
    S& operator=(const S&) {
        std::puts("=(const S&)");
        return *this;
    }
    ~S() { std::puts("~S()"); }
};

S func(int x) {
    S s;
    if (x == 0) {
        return S(); // Just some branches in order to turn off NRVO
    }
    return s;
}

int main() {
    std::puts("Bind to reference");
    const S& l1 = func(4); // expect absence of copy constructor call
    std::puts("Store in value");
    S l2 = func(3);  // expect to create object inside func and copy it to l2
    return 0;
}

The output of the code is:

Bind to reference
S()
S(const S&)
~S()
Store in value
S()
S(const S&)
~S()
~S()
~S()

So, clearly binding to the reference does not save you from copying in such situations. But this feature does allow us to gain performance in some generic template code. The code is where you need to write a call that will return something, but you have no idea if the call returns by value or by reference. So, you need to properly support both cases.

Of course, you can just store the result by value all the time. But if you chose this path, every time the user code returns const T&, your code will make a redundant copy.

A case in point is a range-based for loop. According to the documentation, range-based for loop is converted by the compiler into the following code:

{
    auto && __range = range-expression ;   // Reference lifetime extension here
    auto __begin = begin-expr ;
    auto __end = end-expr ;
    for ( ; __begin != __end; ++__begin)
    {
        range-declaration = *__begin;
        loop-statement
    }
}

Here, the for loop cannot make any assumptions about what will be returned by range_expression. Therefore, its result is bound to the deduced type of the reference and the lifetime of received temporary is extended.

Main Rule

The real world contains a large number of different contexts in which TLE can be applied and C++ specifies behavior for all of them. We will not go through all of the corner cases here. Instead, I will show the simplest, most generic rule about when TLE is enabled and suggest concentrating on it and assuming that the feature does not work in other cases.

At least in the beginning of learning C++, until you will actually need those other peculiar corner cases. I think that by concentrating on this one rule you barely lose anything, since:

  1. The rule by itself covers the majority of real-world cases.

  2. Most of those cases, which it does not cover, can be simply refactored into the form where the rule works or where you do not need it anymore.

  3. The rest of the cases are just peculiar.

So, the main rule: Temporary lifetime extension works if we directly bind freshly created standalone temporary object to a reference. Quite simple, isn’t it? Now, let’s look at some examples. Note that all the examples below can be compiled and executed, regardless of whether they work correctly or not.

Example 1 - Directly Bound Freshly Created Standalone Temporary

#include <utility>

using T = std::pair<int, int>;

int main() {
   const T& local = T{1, 2}; // GOOD, everything as rule says - lifetime extended
   return local.first;
}

Example 2 - What Is “Not Directly” Bound

#include <utility>

using T = std::pair<int, int>;

class S {
  public:
   S(const T& l, const T& r) : top{l}, bottom{r} {}

   T getVal() const { return top; }

   const T& getRef() const { return bottom; }

  private:
   T top;
   T bottom;
};

S func(int l, int r) { return S{T{l, r}, T{r, l}}; }

int main() {
   const T& val = func(4, 6).getVal();     // GOOD, RLE works
   const T& err = func(3, 5).getRef();     // BUG, dangling reference
   return val.first;
}

In this example, the temporaries are created by two different expressions. The first one ends with getVal() call. An important part here is that getVal() returns object by value. Thus, every time we make this call, a new object of type T is created. Therefore, when we bound this newly created temporary object to val reference, its lifetime is extended by TLE.

Note that in this case, the created object of type S is destroyed at the end of the expression, but we do not care, since the object we bound to reference is a newly created copy of the field in S temporary object.

The second expression ends with getRef(), the call which returns a reference to the S field. This reference will be the result of the expression, and it will be assigned to err. Indeed, in this expression, we create a new standalone object of type S. However, its lifetime is not extended, since we do not bound it directly to the reference. We compute a reference to its field and store that into err.

In this case, the created S object will be destroyed at the end of the expression and err will continue pointing to a part of it.

Example 3 - The One With Conversions

#include <utility>

using T = std::pair<int, int>;

struct S {
    S(const T& t) : x{t.first + t.second} {}

    int x;
};

struct P {
    explicit P(const S& s) : x{s} {}

    operator S&() { return x; }

    S x;
};

T getT(int x) { return T{x, 2 * x}; }

P getP(int x) { return P{T{x, x}}; }

int main() {
    const S& val = getT(4);  // GOOD, RLE  works
    const S& err = getP(5);  // BUG, dangling reference
    return val.x + err.x;
}

Here, we also have 2 cases. The first one is with getT call. The function returns an object of type T, which is implicitly convertible in an object of type S. Compiler sees that we want to bind T to the S reference and performs this implicit conversion. During this implicit conversion, a new standalone object of type S is created. It is bound directly to the reference and its lifetime is extended.

The second case is with getP call. This function returns an object of type P, which is implicitly convertible to S&. Compiler sees that we want to bind this object to the S reference and performs this implicit conversion. But this time, conversion does not create any new objects. It just returns a reference to the inner field of P. As a result, the criteria for extending lifetime is not fulfilled and err stores a dangling reference.

Example 4 - The One With wrapper-eraser

#include <utility>

using T = std::pair<int, int>;

struct Summator {
    const T& notice(const T& val) {
        sum += val.first + val.second;
        return val;
    }

    int sum{};
};

T func(int x) { return T{x, x}; }

int main() {
    Summator sum;
    const T& cv = sum.notice(func(7));   //BUG, dangling reference
    return cv.first;
}

In this example, we do not have any conversion. A new standalone object is created by func(7) call. But then this goes through the layer of sum.notice which reads from the object and forwards it back to the caller by constant reference.

However, after such forwarding, we end up assigning that returned reference to cv. Not a newly created standalone temporary. Compiler does not check if that returned reference points to a temporary one and does not extend anybody’s lifetime.

Common Mistakes and Solutions

Range-based for Loop

As it was mentioned above, TLE is used in the range-based for loop implementation. This opens doors for the common mistake which looks as follows:

#include <iostream>
#include <vector>

struct S {
   std::vector<int> data{1, 2, 3, 4, 5};
   const auto& getData() const { return data; }
};

S getS() { return S{}; }

int main() {
   for (const auto& el : getS().getData()) {  // BUG, dangling reference
       std::cout << el;
   }
   return 0;
}

This example is borrowed from Jason Turner's talk, which I recommended you to watch. In place of range-based for loop, compiler will generate code that will look like this:

auto && __range = getS().getData();
......

But getData() does not return newly created standalone objects. It returns a reference to the field in the temporary object. Thus, nobody’s lifetime will be extended and __range will be a dangling reference to the vector over which we will try to iterate.

There’s a couple of ways you can avoid this bug.

  1. You can just come up with a rule to never create an object you want to iterate on in the range-based for loop range expression. Just always pass their object with a valid lifetime.

  2. If you really want to initialize the range in the range expression in the range-based for loop, for example in order to reduce the scope of the object you plan to iterate on. And you have access to C++20. Then you can use range-based for loop init statement. You can rewrite the for loop as follows:

    for (const auto s = getS(); const auto& el : s.getData()) {
           std::cout << el;
    }
    

    This init statement was created specifically to solve this problem. And here, you as a caller specify a type of initialized variable and can take care of the object lifetime explicitly.

  3. If you still want to initialize the range in the range expression and you have access to C++23, you can leave the initially buggy for loop as it is. In C++23, it will be correct! In C++23, the lifetimes of all temporaries within range-expression are extended if they would otherwise be destroyed at the end of range-expression.

Just Bad Interface

The next example comes from the Arno Schoedl talk. It looks as follows:

#include <algorithm>

struct S {
    int x;
};

bool operator<(const S& l, const S& r) { return l.x < r.x; }

int main() {
    const S& res = std::min(S{4}, S{5});  // BUG, dangling reference
    return res.x;
}

We have some temporaries, which we create and choose one of them with the smaller value. Then we try to extend the lifetime of that temporary by binding it to the reference. However, if we check std::min signature we will see:

template< class T >
const T& min( const T& a, const T& b );

Thus, in the example above, we assign to res a reference to one of the S temporaries. But as we already know, such binding does not extend a temporary lifetime, and we end up with a dangling reference.

How can you avoid this type of bug? Just do not write functions with such an interface. It is just a bad interface, specifically because it can lead to this type of problem.

Conclusions

As it is commonly known, C++ has a lot of different rules with a huge amount of corner cases. Keeping all of them in mind while writing code is difficult. What makes it even more difficult - a lot of the mistakes caused by those rules are runtime errors, not compile-time errors. All the buggy examples above can be compiled and executed. But all of those bugs can be caught by address sanitizer or Valgrind.

Maybe some of them can also be caught by static analyzers. Use these instruments when you write in C++. It is a “must do” rule.