C++ Temporary Objects: Lifetime, const&, RVO, and Pitfalls

Key takeaways

Temporaries are the unnamed objects C++ creates while evaluating expressions. They die at the end of the full expression unless bound directly to a reference. This post shows the destruction order with real output, which bindings extend lifetime and which silently dangle, what C++17 guaranteed elision changed, and where temporaries cost performance.

What a temporary is

A temporary is an object that exists only while an expression is being evaluated and has no name you could use to refer to it later. They appear constantly, usually without you writing anything that looks like an object:

std::string getName() { return "Alice"; }
void greet(const std::string& s);

greet("Hello");                                  // const char* -> std::string temporary
greet(getName());                                // the returned string is a temporary
std::string s = std::string("Hello") + " World"; // operator+ result is a temporary
std::cout << Widget(10).value();                 // explicit temporary Widget

Temporaries matter for two opposite reasons. For correctness, anything that points into a temporary — a reference, a pointer, an iterator, a std::string_view — becomes invalid when the temporary dies, and C++ lets you keep such a pointer without complaint. For performance, a temporary may mean an extra allocation and copy, although modern C++ removes most of them through moves and guaranteed copy elision.

The rule: destroyed at the end of the full expression

A full expression is, roughly, an expression that is not part of a larger expression — typically everything up to the semicolon of a statement, or the condition of an if. A temporary lives until that full expression has been completely evaluated, and then it is destroyed. Temporaries from the same full expression are destroyed in reverse order of construction.

#include <iostream>

struct Temp {
    int value;
    explicit Temp(int v) : value(v) { std::cout << "ctor " << value << '\n'; }
    ~Temp() { std::cout << "dtor " << value << '\n'; }
    int get() const { return value; }
};

Temp make(int v) { return Temp(v); }

int main() {
    Temp(1);
    std::cout << "next statement\n";

    std::cout << "sum " << (make(2).get() + make(3).get()) << '\n';
    std::cout << "after\n";
}

Output with g++ 10.3:

ctor 1
dtor 1
next statement
sum ctor 2
ctor 3
5
dtor 3
dtor 2
after

Several details are visible here. Temp(1); is constructed and destroyed before the next statement even starts. In the second statement, both temporaries survive until the entire std::cout << ... chain has run — including printing 5 and the newline — and only then die, in reverse order. "sum " is printed before either temporary is created because C++17 sequences the operands of << left to right. The order in which make(2) and make(3) are evaluated as operands of + is not specified, though; another compiler could construct 3 first.

This rule is what makes common code safe. greet(getName()) is fine: the returned string lives until greet returns, because the call is part of the same full expression. So is std::strlen(getName().c_str()). The problems start only when a pointer or reference outlives the statement.

A statement that looks like a lock but is not

The full-expression rule produces one of the best-known C++ bugs:

std::mutex m;

void update() {
    std::lock_guard<std::mutex>{m};   // temporary: locks and unlocks right here
    shared_counter++;                  // NOT protected
}

Forgetting the variable name turns the RAII guard into a temporary that locks the mutex and unlocks it at the semicolon. GCC 10 compiles this with -Wall -Wextra without a warning, and the code works in every single-threaded test. With parentheses instead of braces — std::lock_guard<std::mutex>(m); — the same line is parsed as a declaration of a new variable named m, and fails with error: no matching function for call to 'std::lock_guard<std::mutex>::lock_guard()', which at least stops the build, if confusingly.

This is the pattern I check for first when a race shows up in code that “obviously” takes a lock. Always name guards (std::lock_guard lock{m}; with C++17 CTAD), and treat any RAII type constructed without a name as a red flag in review. Some linters and newer compilers flag unused RAII temporaries for known guard types; the naming habit works everywhere.

Lifetime extension: what it covers

Binding a temporary to a const T& or a T&& extends its lifetime to that of the reference:

{
    const Temp& r = make(4);        // the temporary now lives as long as r
    std::cout << "using " << r.get() << '\n';
}                                   // destroyed here
ctor 4
using 4
dtor 4

auto&& x = make(4); works the same way, and is what a range-based for loop does internally with its range expression. A non-const lvalue reference cannot bind to a temporary at all: Temp& r = make(4); fails with cannot bind non-const lvalue reference of type 'Temp&' to an rvalue of type 'Temp'.

Extension also applies when you bind to a member reached by direct member access, and it keeps the whole object alive:

struct Inner { int value = 42; };
struct Outer {
    Inner inner;
    const Inner& getInner() const { return inner; }
};
Outer getOuter() { return Outer{}; }

const Inner& ok = getOuter().inner;         // extended: Outer lives as long as ok
const Inner& bad = getOuter().getInner();   // dangling after this line

Instrumenting the destructors shows the difference directly. For ok, ~Outer runs at the end of the enclosing scope, after the value has been read. For bad, ~Outer runs at the end of the declaration itself, before the next line executes.

The distinction that decides it: lifetime extension only happens when the reference binds directly to the temporary (or a subobject named through . on it). When a function sits in between — getInner(), std::max, operator[], .value() on an optional, any member function returning a reference — the compiler binds your reference to whatever that function returned. The function’s return value is not a temporary, it is a reference to one, and nothing extends anything.

The same trap in standard library calls

// Dangling: std::max returns a reference to one of its two temporary arguments.
const std::string& r = std::max(std::string("a"), std::string("b"));

// Dangling: c_str() points into the temporary string, which dies at the semicolon.
const char* p = getName().c_str();

// Dangling: the view refers to the temporary string's buffer.
std::string_view sv = getName();

All three compile with g++ 10.3 at -Wall -Wextra -O2 with no diagnostic. GCC 13 added -Wdangling-reference, which catches the std::max style of case; the others still need a sanitizer or careful review. The fix in each case is the same: give the owning object a name, or copy the result.

std::string name = getName();          // owner has a name and a scope
const char* p = name.c_str();          // valid while name lives
std::string_view sv = name;            // likewise

std::string biggest = std::max(std::string("a"), std::string("b"));  // copy the result

These bugs are hard to catch in testing because the dangling memory often still contains the right bytes. With std::string, short values may be stored inside the string object itself (the small-string optimization), so the symptom can change depending on the length of the data — a test with "Alice" passes and a production value of 40 characters prints garbage. AddressSanitizer turns the heap case into an immediate heap-use-after-free report and is the most reliable way to confirm a suspicion.

std::string_view deserves special mention, because it was designed to be cheap to create from a std::string, which makes creating one from a temporary string effortless. A function parameter of type std::string_view is safe (the argument’s temporary lives for the call); a string_view stored in a variable, returned from a function or kept in a struct member is where it goes wrong.

Range-based for over a temporary

for (const auto& x : getVector()) { /* fine: the vector is extended */ }
for (const auto& x : getWrapper().items()) { /* before C++23: dangling if items() returns a reference */ }

The loop binds its range expression to a hidden auto&& reference, so a temporary returned by the range expression itself is extended. In the second loop, the extended object is whatever items() returns — a reference into the wrapper — while the wrapper temporary dies before the first iteration. C++23 changed the rule so that all temporaries in the range expression live for the whole loop, but code compiled as C++20 or earlier does not get that fix.

Where temporaries are not created any more

C++17 changed how prvalues work. An expression like Temp(5) or a function returning Temp by value does not create an object on its own; it describes how to initialize one. The object is created only where it is needed — a process the standard calls temporary materialization.

Temp t = Temp(5);   // one object, constructed directly in t; no temporary, no move
Temp u = make(6);   // make's return statement constructs directly into u

This is guaranteed copy elision: it works even if Temp has no copy or move constructor at all. It is why factory functions can return non-movable types such as std::mutex wrappers since C++17.

Named return value optimization is different and still optional:

std::string build() {
    std::string result = "Hello";
    result += " world";
    return result;   // NRVO: usually elided; if not, result is moved (not copied)
}

Here the compiler may construct result directly in the caller’s storage, and GCC and Clang do so in simple cases like this one. If it does not — for example because different branches return different local variables — the return still uses the move constructor automatically. Writing return std::move(result); prevents NRVO and forces a move, so it is never an improvement for a local variable; GCC warns about it with -Wpessimizing-move. The RVO post below covers the conditions in detail.

Temporaries and performance

Most temporaries are cheap: an int, a small struct, or a string that fits in the small-string buffer costs next to nothing. The ones worth noticing are temporaries that allocate, created repeatedly in a hot loop.

std::map<std::string, int> counts;

for (const char* key : keys) {
    auto it = counts.find(key);     // builds a std::string temporary for every lookup
}

std::map<std::string, int>::find takes a const std::string&, so each call converts the const char* into a temporary std::string, which may allocate. Declaring the map with a transparent comparator, std::map<std::string, int, std::less<>>, enables heterogeneous lookup (C++14): find then accepts the const char* or a std::string_view directly and compares without constructing a string.

String concatenation is another place people worry about:

std::string s = a + b + c + d;

Each + produces a temporary, but since C++11 operator+ has overloads taking an rvalue left operand, so (a + b) is a temporary that the next + c appends into and moves along instead of copying. The chain is far cheaper than it looks. When building a string in a loop, s += piece; (optionally after s.reserve(...)) avoids temporaries entirely and is the clearest choice.

Finally, conversions through by-value parameters create temporaries that are easy to miss:

void setName(std::string name);           // takes a copy
void setLabel(const std::string& label);  // binds a reference

setLabel("title");   // still constructs a temporary std::string for the call

Passing a string literal to a const std::string& parameter still constructs a temporary for the duration of the call. For read-only text parameters, std::string_view avoids that and is safe because the argument outlives the call.