Discussion: What’s the Time of Death?

Execute the code to understand the output and gain insights into the concept of temporary materialization and lifetime extension.

Run the code

Now, it’s time to execute the code and observe the output.

Press + to interact
#include <iostream>
struct MemoryArea
{
MemoryArea(int number) : number_(number) {}
~MemoryArea() { std::cout << "Freed memory area " << number_ << "\n"; }
int number_;
};
MemoryArea getMemory(int number) { return MemoryArea{number}; }
struct DataSource
{
DataSource(const MemoryArea &memoryArea)
: memoryArea_(memoryArea) {}
const MemoryArea &memoryArea_;
};
int main()
{
const auto &reference1 = getMemory(1);
std::cout << "Bound reference 1\n";
const auto &reference2 = getMemory(2).number_;
std::cout << "Bound reference 2\n";
const auto &reference3 = DataSource(getMemory(3));
std::cout << "Bound reference 3\n";
}

As we’ve seen a few times by now, a function call is a prvalue, and no object exists until that prvalue is used to initialize one. In the A Strange Assignment puzzle, we also saw an exception to this rule: when we didn’t use the prvalue to initialize an object, we called that a discarded-value expression and learned that a temporary is then materialized.

Understanding the output

In this puzzle, the results of the function call expressions aren’t assigned to objects as in the Counting Copies puzzle, but neither are they discarded as in the “A Strange Assignment” puzzle. Instead, we’re binding references to them in various ways. What happens then?

  • For the first call, we bind the reference reference1 directly to the prvalue. A temporary is materialized, as we briefly touched upon in the “A Strange Assignment” puzzle.

  • For the second call, we access the member number_ on a class prvalue. This is another case where a temporary needs to be materialized. Otherwise, no object would be there for us to access the member of.

  • For the third call, we pass the prvalue to the DataSource constructor. This is actually the same case as for the first call; the constructor parameter reference memoryArea binds to the prvalue, and a temporary is materialized.

So, a temporary is materialized for each of the three calls to getMemory. But for how long do they stay alive?

Lifetime of temporaries

Normally, temporaries get destroyed at the end of the full expressions in which they were created. In the cases in this puzzle, that means they would be destroyed at the end of the line. However, the lifetime of temporaries is extended in certain cases. This is somewhat of a relic of the past, which is no longer very useful. For instance, for const auto &reference1 = getMemory(1);, we’d be much better off just writing const auto object1 = getMemory(1);. Thanks to the new rules for prvalues in C++17, there would still be no extra copy, and we wouldn’t be asking all these questions. Fewer questions mean better code!

Rules for lifetime extension

So, what exactly are the rules for lifetime extension? Although others exist, by far the most common, and the ones that matter here, are these:

  • If we bind a reference to an object that we obtained through temporary materialization, that temporary object persists for the lifetime of the reference.

  • If we bind a reference to a member of an object we obtained through temporary materialization, the full temporary object (not just the member) persists for the lifetime of the reference.

Code explanation

Let’s examine each line:

const auto &reference1 = getMemory(1);

We bind reference1 to the temporary object we got from getMemory(1). Temporary 1 persists for the lifetime of reference1—that is, to the end of main().

std::cout << "Bound reference 1\n";

We print Bound reference 1.

const auto &reference2 = getMemory(2).number_;

We bind reference2 to the member number_ of the temporary object we got from getMemory(2). The full temporary object 2 (not just the member) persists for the lifetime of reference2—that is, to the end of main().

std::cout << "Bound reference 2\n";

We print Bound reference 2.

const auto &reference3 = DataSource(getMemory(3));

We bind the constructor parameter reference memoryArea to the temporary object we got from getMemory(3). This is used to create a new object of type DataSource. Then, we bind reference3 to the temporary DataSource which gets materialized when we call its constructor. The temporary DataSource persists for the lifetime of reference3—that is, to the end of main().

Wait, it’s a trap!

As soon as the DataSource object has been constructed, the memoryArea parameter is gone. It doesn’t help that we assign it to the memoryArea_ member; that’s a different reference! So, the temporary MemoryArea 3 does not get its lifetime extended, and the memory is freed at the end of the line. The string Freed memory area 3 is printed, and the MemoryArea that the DataSource was planning to read from has already been freed and is now a dangling reference.

std::cout << "Bound reference 3\n";

We print Bound reference 3.

}

Ahh, }, the best feature of C++! At the end of the scope, local variables are destroyed in the reverse order of their construction.

  1. First, reference3 is destroyed, which means the temporary DataSource is destroyed (its destructor doesn’t print anything).

  2. Next, reference2 is destroyed, which means temporary 2 is destroyed, and Freed memory area 2 is printed.

  3. Finally, reference1 is destroyed, which means temporary 1 is destroyed, and Freed memory area 1 is printed.

Related issues

Similar issues as for temporary 3 are not uncommon in C++. Here’s a famous example that didn’t get fixed until C++23. At the time of creating this course, common compilers have yet to implement this fix. The following code will generate an error:

Press + to interact
class MemoryAreaContainer
{
public:
MemoryAreaContainer();
std::vector<MemoryArea> &getMemoryAreas()
{
return memoryAreas_;
}
private:
std::vector<MemoryArea> memoryAreas_;
};
int main()
{
for (const auto &lp : MemoryAreaContainer{}.getMemoryAreas())
{
std::cout << lp.number_ << std::endl;
}
}

This looks harmless, but for the temporary MemoryAreaContainer to persist to the end of the for loop, we need to bind a const reference to it. Our const reference here isn’t bound to the MemoryAreaContainer but rather to the return value from getMemoryAreas(). So, the MemoryAreaContainer temporary gets destroyed before we even start iterating. The reference we got from getMemoryAreas() refers to the already destroyed memoryAreas_ member and is dangling.

Recommendations

Here are some recommendations to avoid dangling references and ensure safe and efficient code.

  • Avoid relying on lifetime extensions: Prefer const auto obj = get() over const auto& ref = get() to avoid potential dangling references.

  • Be cautious with lifetime extensions across function calls: Don’t rely on a lifetime extension surviving through function calls, as this can lead to subtle bugs.

  • Stay updated with C++ standards and tools: Keep up with new C++ standards, compilers, and tools because they often include important bug fixes and improvements.

  • Use sanitizers: Employ sanitizers like AddressSanitizer to catch issues related to using destroyed objects, helping to prevent dangling references and other memory-related errors.

Level up your interview prep. Join Educative to access 70+ hands-on prep courses.