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.
#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 referencememoryArea
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.
First,
reference3
is destroyed, which means the temporaryDataSource
is destroyed (its destructor doesn’t print anything).Next,
reference2
is destroyed, which means temporary 2 is destroyed, andFreed memory area 2
is printed.Finally,
reference1
is destroyed, which means temporary 1 is destroyed, andFreed 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:
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()
overconst 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.