Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C++  Module 6 – References, pointers and memory

C++ references: lifetime extension and dangling

What a C++ reference can bind to, when binding extends a temporary's life, how returned references dangle, and the C++23 range-for fix, run on two compilers.

  • Intermediate
  • 25 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free: 25 minutes

What you will learn

  • Explain what a reference is and what each kind of reference can bind to
  • Predict when binding a temporary to a reference extends its lifetime
  • Find and fix dangling references with compiler warnings and AddressSanitizer

A reference is another name for an object that already exists. int& alias = marks; creates no new integer: from then on alias and marks are two names for one object, and whatever you do through one name you see through the other. A reference must be initialised when it is declared, it can never be made to refer to a different object later, and there are no null references, because a reference may only be bound to a valid object.

A reference owns nothing, so it is only as good as the object behind it. A reference that outlives its object is dangling, and reading through it is undefined behaviour. One rule stretches an object’s life to match a reference, lifetime extension: a temporary bound directly to a const reference or to an rvalue reference lives as long as that reference. This lesson shows exactly when that rule applies, the common ways references dangle, and how compiler warnings and AddressSanitizer find them. Every program runs with Apple clang 21 and GCC 16.

A reference is another name for an object

alias.cpp C++ · alias.cpp
#include <print>

int main() {
    int marks = 70;
    int& alias = marks;              // alias is another name for marks
    alias += 5;
    std::println("marks {}", marks);
    std::println("same object: {}", &alias == &marks);

    int bonus = 10;
    alias = bonus;                   // copies 10 into marks: alias still names marks
    bonus = 99;
    std::println("marks {}, bonus {}", marks, bonus);
}
Run with both compilers Python · alias_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra alias.cpp -o alias && ./alias
g++-16 -std=c++23 -Wall -Wextra alias.cpp -o alias && ./alias
""")

Output

$ clang++ -std=c++23 -Wall -Wextra alias.cpp -o alias && ./alias
marks 75
same object: true
marks 10, bonus 99
$ g++-16 -std=c++23 -Wall -Wextra alias.cpp -o alias && ./alias
marks 75
same object: true
marks 10, bonus 99

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 alias_session.py

alias += 5 changed marks, and the two addresses compare equal: one object, two names. The second assignment is the line that surprises people. alias = bonus does not make alias refer to bonus; it copies the value 10 into the object that alias already names, which is marks, so changing bonus afterwards changes nothing else. A reference is bound once, when it is initialised, and it stays bound to that object until the reference itself goes out of scope.

The standard’s section on references, [dcl.ref], adds rules that follow from “another name”: there are no references to references, no arrays of references and no pointers to references, and a reference variable must be declared with an initialiser. References are used most often as function parameters, const std::string& to read a large argument without copying it and int& to let a function change the caller’s variable; the same rules apply there, with the argument as the initialiser.

What each kind of reference can bind to

There are three kinds you will meet: T&, const T& and T&&. What each one accepts follows from what it allows you to do with the object.

What each reference can bind to: marks is an int, pass_mark a const int
Criterion int&const int&int&&
Binds to marks YesYesNo
Binds to pass_mark NoYesNo
Binds to a temporary NoYes, and extends itYes, and extends it
Can change it YesNoYes
When to choose Changing the caller's objectReading without a copyMoving from temporaries (later in this track)
binding.cpp C++ · binding.cpp
// What each kind of reference can bind to. The last four declarations do not compile.
int make_marks() { return 88; }

int main() {
    int marks = 70;
    const int pass_mark = 40;

    int& a = marks;                  // a modifiable object
    const int& b = pass_mark;        // a const object
    const int& c = make_marks();     // a temporary: c keeps it alive
    int&& d = make_marks();          // an rvalue reference binds to temporaries only

    int& e = pass_mark;              // error: e could change a const int
    int& f = make_marks();           // error: a temporary needs a const or an rvalue reference
    int&& g = marks;                 // error: marks is not a temporary
    int& h;                          // error: a reference must be initialised
}
The last four declarations, rejected by both compilers Python · binding_session.py
from session import run

run("""
clang++ -std=c++23 -c binding.cpp -o binding.o
g++-16 -std=c++23 -c binding.cpp -o binding.o
""", max_lines=40)

Output

$ clang++ -std=c++23 -c binding.cpp -o binding.o
binding.cpp:13:10: error: binding reference of type 'int' to value of type 'const int' drops 'const' qualifier
   13 |     int& e = pass_mark;              // error: e could change a const int
      |          ^   ~~~~~~~~~
binding.cpp:14:10: error: non-const lvalue reference to type 'int' cannot bind to a temporary of type 'int'
   14 |     int& f = make_marks();           // error: a temporary needs a const or an rvalue reference
      |          ^   ~~~~~~~~~~~~
binding.cpp:15:11: error: rvalue reference to type 'int' cannot bind to lvalue of type 'int'
   15 |     int&& g = marks;                 // error: marks is not a temporary
      |           ^   ~~~~~
binding.cpp:16:10: error: declaration of reference variable 'h' requires an initializer
   16 |     int& h;                          // error: a reference must be initialised
      |          ^
4 errors generated.
[exit status 1]
$ g++-16 -std=c++23 -c binding.cpp -o binding.o
binding.cpp: In function 'int main()':
binding.cpp:13:14: error: binding reference of type 'int&' to 'const int' discards qualifiers
   13 |     int& e = pass_mark;              // error: e could change a const int
      |              ^~~~~~~~~
binding.cpp:14:24: error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'
   14 |     int& f = make_marks();           // error: a temporary needs a const or an rvalue reference
      |              ~~~~~~~~~~^~
binding.cpp:15:15: error: cannot bind rvalue reference of type 'int&&' to lvalue of type 'int'
   15 |     int&& g = marks;                 // error: marks is not a temporary
      |               ^~~~~
binding.cpp:16:10: error: 'h' declared as reference but not initialized
   16 |     int& h;                          // error: a reference must be initialised
      |          ^
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 binding_session.py

Both compilers reject the same four lines in different words. A plain int& cannot bind to pass_mark, because it would let you change a const object, and it cannot bind to the temporary that make_marks() returns: only a const reference or an rvalue reference may bind to a temporary. An rvalue reference binds only to temporaries, so it refuses the named variable marks. And int& h; has nothing to refer to.

When a temporary lives as long as its reference

A temporary normally lives until the end of the full-expression that created it, which in practice is the end of the statement it appears in. Binding it to a reference changes that in one situation only: the reference must be bound directly to the temporary. The section on temporaries, [class.temporary], lists what counts as direct: the temporary itself, a data member of it reached with ., an element of a temporary array, and a few casts of these.

The Tracer type below prints a line when an object starts and when it ends, so the output shows each lifetime:

lifetime.cpp C++ · lifetime.cpp
#include <print>
#include <string>

// Prints a line when it starts and when it ends, so the output shows the lifetime of each object.
struct Tracer {
    std::string name;
    explicit Tracer(std::string n) : name(std::move(n)) { std::println("  start {}", name); }
    ~Tracer() { std::println("  end   {}", name); }
};

Tracer make(std::string name) { return Tracer(std::move(name)); }
const Tracer& same(const Tracer& t) { return t; }

int main() {
    std::println("1. a const reference bound to the temporary");
    {
        const Tracer& kept = make("A");
        std::println("  using {}", kept.name);
    }
    std::println("2. an rvalue reference bound to the temporary");
    {
        Tracer&& kept = make("B");
        std::println("  using {}", kept.name);
    }
    std::println("3. a reference to a data member of the temporary");
    {
        const std::string& kept = make("C").name;
        std::println("  using {}", kept);
    }
    std::println("4. the temporary passed through a function");
    {
        const Tracer& lost = same(make("D"));
        std::println("  the statement that bound 'lost' has ended");
        (void)lost;                  // reading lost here would read a destroyed object
    }
}
Four bindings, two compilers Python · lifetime_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra lifetime.cpp -o lifetime && ./lifetime
g++-16 -std=c++23 -Wall -Wextra lifetime.cpp -o lifetime && ./lifetime
""")

Output

$ clang++ -std=c++23 -Wall -Wextra lifetime.cpp -o lifetime && ./lifetime
1. a const reference bound to the temporary
  start A
  using A
  end   A
2. an rvalue reference bound to the temporary
  start B
  using B
  end   B
3. a reference to a data member of the temporary
  start C
  using C
  end   C
4. the temporary passed through a function
  start D
  end   D
  the statement that bound 'lost' has ended
$ g++-16 -std=c++23 -Wall -Wextra lifetime.cpp -o lifetime && ./lifetime
lifetime.cpp: In function 'int main()':
lifetime.cpp:32:23: warning: possibly dangling reference to a temporary [-Wdangling-reference]
   32 |         const Tracer& lost = same(make("D"));
      |                       ^~~~
lifetime.cpp:32:39: note: 'Tracer' temporary created here
   32 |         const Tracer& lost = same(make("D"));
      |                                   ~~~~^~~~~
1. a const reference bound to the temporary
  start A
  using A
  end   A
2. an rvalue reference bound to the temporary
  start B
  using B
  end   B
3. a reference to a data member of the temporary
  start C
  using C
  end   C
4. the temporary passed through a function
  start D
  end   D
  the statement that bound 'lost' has ended

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lifetime_session.py

In the first three blocks the temporary ends when its block ends, after it was used: const Tracer&, Tracer&& and a reference to the member name all keep it alive. In the fourth block, same() takes the temporary as a parameter and returns a reference to it. The compiler does not look inside the function to see where its result points, so nothing is extended: D ends before the next line runs, and lost names a destroyed object. GCC’s -Wdangling-reference, which -Wextra turns on, flags that line; Clang compiles it without a word.

A temporary bound directly to a reference lives as long as the reference; bound through a function, it dies and the reference dangles.A reference is boundto a temporary objectIs it bound directly?The temporary itself, a datamember of it, or a cast of itYes: the temporary livesas long as the referenceNo: it went through a function,as a parameter or a returnedreference, like same() or items()The temporary ends with thefull-expression: the referencenow danglesExcept in C++23 in theinitializer of a range-for loop:there it lives until the loop endsyesno

When a temporary lives as long as its reference

Text description of the diagram

The diagram is a decision chart, read from top to bottom.

  1. It starts with a reference that is bound to a temporary object.
  2. The question is whether the reference is bound directly: to the temporary itself, to a data member of it reached with a dot, or to a cast of it.
  3. If yes, the temporary lives as long as the reference does.
  4. If no, the temporary reached the reference through a function, as a parameter or as a returned reference, like same() or items() in this lesson's examples. Then the temporary ends with the full-expression that created it, and the reference dangles.
  5. The last box names the one exception to that case: in the initializer of a range-based for loop compiled as C++23, the temporary lives until the loop ends.

The exceptions listed in [class.temporary] are all cases of a temporary reaching a reference through something else:

  • A temporary bound to a reference parameter lives until the end of the full-expression that contains the call, so a function can never keep it.
  • A temporary bound to the returned reference in a return statement is destroyed at the end of that statement.
  • A temporary bound to a reference inside the initialiser of a new-expression lives only until the end of that full-expression.

Dangling, case 1: returning a reference to a local

The most direct way to dangle is to return a reference to a local variable. The variable is destroyed when the function returns, so the caller receives a name for an object that no longer exists:

next_ticket.cpp C++ · next_ticket.cpp
#include <cstdio>

// Returns a reference to a local variable, which is destroyed when the function returns.
int& next_ticket() {
    int ticket = 100;
    ++ticket;
    return ticket;
}

int main() {
    int& t = next_ticket();
    std::printf("ticket %d\n", t);
}
C++20 mode: a warning, then three different results Python · next_ticket_cpp20_session.py
from session import run

# In C++20 mode the code compiles with a warning, and each build does something different.
run("""
clang++ -std=c++20 next_ticket.cpp -o next_ticket && ./next_ticket
g++-16 -std=c++20 next_ticket.cpp -o next_ticket && ./next_ticket
g++-16 -std=c++20 -S next_ticket.cpp -o - 2>/dev/null | grep -E "next_ticketv:|mov.x0, 0"
clang++ -std=c++20 -g -fsanitize=address next_ticket.cpp -o next_ticket 2>/dev/null && ./next_ticket
ASAN_OPTIONS=detect_stack_use_after_return=1 ./next_ticket
""")

Output

$ clang++ -std=c++20 next_ticket.cpp -o next_ticket && ./next_ticket
next_ticket.cpp:7:12: warning: reference to stack memory associated with local variable 'ticket' returned [-Wreturn-stack-address]
    7 |     return ticket;
      |            ^~~~~~
1 warning generated.
ticket 101
$ g++-16 -std=c++20 next_ticket.cpp -o next_ticket && ./next_ticket
next_ticket.cpp: In function 'int& next_ticket()':
next_ticket.cpp:7:12: warning: reference to local variable 'ticket' returned [-Wreturn-local-addr]
    7 |     return ticket;
      |            ^~~~~~
next_ticket.cpp:5:9: note: declared here
    5 |     int ticket = 100;
      |         ^~~~~~
/bin/sh: line 1: … Segmentation fault: 11  ./next_ticket
[exit status 139]
$ g++-16 -std=c++20 -S next_ticket.cpp -o - 2>/dev/null | grep -E "next_ticketv:|mov.x0, 0"
__Z11next_ticketv:
	mov	x0, 0
$ clang++ -std=c++20 -g -fsanitize=address next_ticket.cpp -o next_ticket 2>/dev/null && ./next_ticket
ticket 101
$ ASAN_OPTIONS=detect_stack_use_after_return=1 ./next_ticket
==…==ERROR: AddressSanitizer: stack-use-after-return on address 0x…
READ of size 4 at 0x… thread T0
    #0 in main next_ticket.cpp:12
Address 0x… is located in stack of thread T0 at offset 32 in frame
    #0 in next_ticket() next_ticket.cpp:4
    [32, 36) 'ticket' (line 5) <== Memory access at offset 32 is inside this variable
SUMMARY: AddressSanitizer: stack-use-after-return next_ticket.cpp:12 in main
[39 more lines of the report left out]
[exit status 134]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 next_ticket_cpp20_session.py

Both compilers warn with no warning option at all: Clang with -Wreturn-stack-address, GCC with -Wreturn-local-addr. Then each build behaves differently, which is what undefined behaviour means in practice:

  • Clang’s build printed ticket 101, the expected number, only because nothing had overwritten the dead variable yet. Nothing promises that: another compiler version, option or call before the read may print anything.
  • GCC’s build crashed with a segmentation fault, exit status 139 (128 plus signal 11). The grep line shows why: GCC compiled next_ticket to return 0, a null pointer, in place of the dead variable’s address.
  • The AddressSanitizer build also printed the expected number. Clang’s documentation says that the check for use after return is switched on by default only on Linux; with ASAN_OPTIONS=detect_stack_use_after_return=1 the same program stops with stack-use-after-return, naming the variable ticket from line 5 and the read on line 12.

Compiled as C++23, the function is no longer accepted by either compiler:

C++23 mode: an error Python · next_ticket_cpp23_session.py
from session import run

# In C++23 mode the same function no longer compiles.
run("""
clang++ -std=c++23 next_ticket.cpp -o next_ticket
g++-16 -std=c++23 next_ticket.cpp -o next_ticket
""")

Output

$ clang++ -std=c++23 next_ticket.cpp -o next_ticket
next_ticket.cpp:7:12: error: non-const lvalue reference to type 'int' cannot bind to a temporary of type 'int'
    7 |     return ticket;
      |            ^~~~~~
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 next_ticket.cpp -o next_ticket
next_ticket.cpp: In function 'int& next_ticket()':
next_ticket.cpp:7:12: error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'
    7 |     return ticket;
      |            ^~~~~~
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 next_ticket_cpp23_session.py

C++23 adopted P2266R3, “Simpler implicit move”: in return ticket; a local variable is now treated as an object about to be moved from (an xvalue), and a non-const int& cannot bind to that, so the dangling version stops compiling. Clang has implemented it since Clang 13 and GCC since GCC 13. It is not a cure: a function that returns a const reference to a local still compiles, with the same warning, as the exercise at the end of this lesson shows.

Dangling, case 2: a reference into a temporary argument

A function that returns one of its reference parameters is safe when the caller’s arguments outlive the result, and dangerous when one of them is a temporary:

longer.cpp C++ · longer.cpp
#include <print>
#include <string>

// Returns whichever argument is longer: a reference to one of the caller's strings.
const std::string& longer(const std::string& a, const std::string& b) {
    return a.size() >= b.size() ? a : b;
}

int main() {
    std::string city = "Pune";
    std::string other = "Ahmedabad";
    const std::string& safe = longer(city, other);        // city and other outlive safe
    std::println("{}", safe);

    const std::string& risky = longer(city, "Chennai");   // "Chennai" becomes a temporary string
    std::println("{}", risky);
}
longer_marked.cpp C++ · longer_marked.cpp
#include <print>
#include <string>

// The same function, with each parameter marked: the result may refer to it.
const std::string& longer(const std::string& a [[clang::lifetimebound]],
                          const std::string& b [[clang::lifetimebound]]) {
    return a.size() >= b.size() ? a : b;
}

int main() {
    std::string city = "Pune";
    std::string other = "Ahmedabad";
    const std::string& safe = longer(city, other);        // city and other outlive safe
    std::println("{}", safe);

    const std::string& risky = longer(city, "Chennai");   // "Chennai" becomes a temporary string
    std::println("{}", risky);
}
Two compilers, a sanitizer and an attribute Python · longer_session.py
from session import run

run("""
g++-16 -std=c++23 -Wall -Wextra longer.cpp -o longer && ./longer
clang++ -std=c++23 -Wall -Wextra longer.cpp -o longer && ./longer
clang++ -std=c++23 -g -fsanitize=address longer.cpp -o longer && ./longer
clang++ -std=c++23 -Wall -Wextra -c longer_marked.cpp -o longer_marked.o
g++-16 -std=c++23 -Wall -Wextra -c longer_marked.cpp -o longer_marked.o 2>&1 | grep Wattributes
""")

Output

$ g++-16 -std=c++23 -Wall -Wextra longer.cpp -o longer && ./longer
longer.cpp: In function 'int main()':
longer.cpp:15:24: warning: possibly dangling reference to a temporary [-Wdangling-reference]
   15 |     const std::string& risky = longer(city, "Chennai");   // "Chennai" becomes a temporary string
      |                        ^~~~~
longer.cpp:15:45: note: 'const std::string' {aka 'const std::__cxx11::basic_string<char>'} temporary created here
   15 |     const std::string& risky = longer(city, "Chennai");   // "Chennai" becomes a temporary string
      |                                             ^~~~~~~~~
Ahmedabad
Chennai
$ clang++ -std=c++23 -Wall -Wextra longer.cpp -o longer && ./longer
Ahmedabad
Chennai
$ clang++ -std=c++23 -g -fsanitize=address longer.cpp -o longer && ./longer
Ahmedabad
==…==ERROR: AddressSanitizer: stack-use-after-scope on address 0x…
READ of size 1 at 0x… thread T0
    #11 in main longer.cpp:16
Address 0x… is located in stack of thread T0 at offset 183 in frame
    #0 in main longer.cpp:9
    [160, 184) 'ref.tmp' (line 15) <== Memory access at offset 183 is inside this variable
SUMMARY: AddressSanitizer: stack-use-after-scope
[52 more lines of the report left out]
/bin/sh: line 1: … Abort trap: 6           ./longer
[exit status 134]
$ clang++ -std=c++23 -Wall -Wextra -c longer_marked.cpp -o longer_marked.o
longer_marked.cpp:16:45: warning: temporary bound to local reference 'risky' will be destroyed at the end of the full-expression [-Wdangling]
   16 |     const std::string& risky = longer(city, "Chennai");   // "Chennai" becomes a temporary string
      |                                             ^~~~~~~~~
1 warning generated.
$ g++-16 -std=c++23 -Wall -Wextra -c longer_marked.cpp -o longer_marked.o 2>&1 | grep Wattributes
longer_marked.cpp:5:71: warning: 'clang::lifetimebound' scoped attribute directive ignored [-Wattributes]
longer_marked.cpp:6:71: warning: 'clang::lifetimebound' scoped attribute directive ignored [-Wattributes]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 longer_session.py

The literal "Chennai" is not a std::string, so the call creates a temporary std::string for it. That temporary is a parameter’s argument, so it is destroyed at the end of the statement, and risky may then name it. GCC warns, again with -Wdangling-reference. Both ordinary builds printed Chennai, which looks right and is not: AddressSanitizer stops at line 16 with stack-use-after-scope and names the temporary (ref.tmp, created on line 15).

Clang can warn here too when the function declares that its result may refer to its parameters. The attribute [[clang::lifetimebound]] on each parameter of longer_marked.cpp does that, and Clang then reports that the temporary bound to risky will be destroyed at the end of the full-expression. GCC does not know the attribute and says so with -Wattributes, which is harmless, but in code shared between compilers the attribute is usually hidden behind a macro.

Dangling, case 3: a range-for loop over a temporary, fixed in C++23

A range-based for loop keeps the result of its range expression in a hidden reference, and loops over that. When the expression calls a member function on a temporary, the member function returns a reference into the temporary, and the temporary itself is not extended:

batch_loop.cpp C++ · batch_loop.cpp
#include <iostream>
#include <vector>

struct Batch {
    std::vector<int> marks;
    const std::vector<int>& items() const { return marks; }
};

Batch make_batch() { return Batch{{72, 85, 91}}; }

int main() {
    int total = 0;
    for (int m : make_batch().items()) total += m;    // the loop runs over a member of a temporary
    std::cout << "total " << total << '\n';
}
The same loop in C++20 and in C++23 Python · batch_loop_session.py
from session import run

run("""
clang++ -std=c++20 -g -fsanitize=address batch_loop.cpp -o batch_loop && ./batch_loop
g++-16 -std=c++20 -Wall -Wextra -g -fsanitize=address batch_loop.cpp -o batch_loop && ./batch_loop
clang++ -std=c++23 -g -fsanitize=address batch_loop.cpp -o batch_loop && ./batch_loop
g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address batch_loop.cpp -o batch_loop && ./batch_loop
""")

Output

$ clang++ -std=c++20 -g -fsanitize=address batch_loop.cpp -o batch_loop && ./batch_loop
==…==ERROR: AddressSanitizer: stack-use-after-scope on address 0x…
READ of size 8 at 0x… thread T0
    #1 in main batch_loop.cpp:13
Address 0x… is located in stack of thread T0 at offset 32 in frame
    #0 in main batch_loop.cpp:11
    [32, 56) 'ref.tmp' (line 13) <== Memory access at offset 32 is inside this variable
SUMMARY: AddressSanitizer: stack-use-after-scope
[42 more lines of the report left out]
/bin/sh: line 1: … Abort trap: 6           ./batch_loop
[exit status 134]
$ g++-16 -std=c++20 -Wall -Wextra -g -fsanitize=address batch_loop.cpp -o batch_loop && ./batch_loop
batch_loop.cpp: In function 'int main()':
batch_loop.cpp:13:37: warning: possibly dangling reference to a temporary [-Wdangling-reference]
   13 |     for (int m : make_batch().items()) total += m;    // the loop runs over a member of a temporary
      |                                     ^
batch_loop.cpp:13:28: note: 'Batch' temporary created here
   13 |     for (int m : make_batch().items()) total += m;    // the loop runs over a member of a temporary
      |                  ~~~~~~~~~~^~
==…==ERROR: AddressSanitizer: stack-use-after-scope on address 0x…
READ of size 8 at 0x… thread T0
    #1 in main batch_loop.cpp:13
Address 0x… is located in stack of thread T0 at offset 96 in frame
    #0 in main batch_loop.cpp:11
    [96, 120) '<unknown>' <== Memory access at offset 96 is inside this variable
SUMMARY: AddressSanitizer: stack-use-after-scope
[42 more lines of the report left out]
/bin/sh: line 1: … Abort trap: 6           ./batch_loop
[exit status 134]
$ clang++ -std=c++23 -g -fsanitize=address batch_loop.cpp -o batch_loop && ./batch_loop
total 248
$ g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address batch_loop.cpp -o batch_loop && ./batch_loop
total 248

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 batch_loop_session.py

In C++20 mode, the Batch returned by make_batch() is destroyed as soon as the hidden reference is initialised, so the loop walks a destroyed vector, and both sanitizer builds stop at line 13. C++23 changed the rule with P2718R0: every temporary created in the range expression of a range-based for, apart from function parameters passed by value, now lives until the loop ends. The same source compiled as C++23 prints total 248 with both compilers. Support arrived in Clang 20 and GCC 15, and code can check that the feature-test macro __cpp_range_based_for is at least 202211L.

Two cautions. The fix applies only in C++23 mode, so the same file still dangles when someone builds it as C++20. And it covers only the range expression of a for loop: const auto& v = make_batch().items(); on its own line dangles in every version.

Reference members, and std::reference_wrapper

A reference can be a data member, but it makes the class awkward. A reference cannot be re-seated, so the compiler cannot write the usual copy assignment, and [class.copy.assign] defines it as deleted:

ticket_reference.cpp C++ · ticket_reference.cpp
#include <string>

struct Ticket {
    const std::string& passenger;   // a reference member
    int seat;
};

int main() {
    std::string asha = "Asha";
    std::string ravi = "Ravi";
    Ticket a{asha, 12};
    Ticket b{ravi, 14};
    a = b;                          // error: Ticket cannot be assigned
}
ticket_wrapper.cpp C++ · ticket_wrapper.cpp
#include <functional>
#include <print>
#include <string>

struct Ticket {
    std::reference_wrapper<const std::string> passenger;   // refers to a string, and can be re-pointed
    int seat;
};

int main() {
    std::string asha = "Asha";
    std::string ravi = "Ravi";
    Ticket a{asha, 12};
    Ticket b{ravi, 14};
    a = b;                          // a now refers to ravi
    std::println("{} {}", a.passenger.get(), a.seat);
    ravi += " Kumar";               // a still refers to the same string, not a copy
    std::println("{} {}", a.passenger.get(), a.seat);
}
A reference member, then std::reference_wrapper Python · ticket_session.py
from session import run

run("""
clang++ -std=c++23 -c ticket_reference.cpp -o ticket_reference.o
g++-16 -std=c++23 -c ticket_reference.cpp -o ticket_reference.o
clang++ -std=c++23 -Wall -Wextra ticket_wrapper.cpp -o ticket_wrapper && ./ticket_wrapper
""")

Output

$ clang++ -std=c++23 -c ticket_reference.cpp -o ticket_reference.o
ticket_reference.cpp:13:7: error: object of type 'Ticket' cannot be assigned because its copy assignment operator is implicitly deleted
   13 |     a = b;                          // error: Ticket cannot be assigned
      |       ^
ticket_reference.cpp:4:24: note: copy assignment operator of 'Ticket' is implicitly deleted because field 'passenger' is of reference type 'const std::string &' (aka 'const basic_string<char> &')
    4 |     const std::string& passenger;   // a reference member
      |                        ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -c ticket_reference.cpp -o ticket_reference.o
ticket_reference.cpp: In function 'int main()':
ticket_reference.cpp:13:9: error: use of deleted function 'Ticket& Ticket::operator=(const Ticket&)'
   13 |     a = b;                          // error: Ticket cannot be assigned
      |         ^
ticket_reference.cpp:3:8: note: 'Ticket& Ticket::operator=(const Ticket&)' is implicitly deleted because the default definition would be ill-formed:
    3 | struct Ticket {
      |        ^~~~~~
ticket_reference.cpp: At global scope:
ticket_reference.cpp:3:8: error: non-static reference member 'const std::string& Ticket::passenger', cannot use default assignment operator
ticket_reference.cpp: In function 'int main()':
ticket_reference.cpp:13:9: note: use '-fdiagnostics-all-candidates' to display considered candidates
   13 |     a = b;                          // error: Ticket cannot be assigned
      |         ^
[exit status 1]
$ clang++ -std=c++23 -Wall -Wextra ticket_wrapper.cpp -o ticket_wrapper && ./ticket_wrapper
Ravi 14
Ravi Kumar 14

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ticket_session.py

Such objects can still be put into a std::vector, but the vector cannot be sorted or have an element erased from the middle, because the standard requires assignable elements for both. That is why the C++ Core Guidelines say, in C.12, not to make data members references in a copyable or movable type. When a member must refer to an object owned elsewhere and must also be re-pointed, use std::reference_wrapper<T> from <functional> as ticket_wrapper.cpp does: assignment re-points it, and .get() returns the reference. A pointer works too and additionally allows “no object” (the next lesson). Neither one changes who owns the string: if ravi were destroyed first, a.passenger would dangle exactly as a reference would.

Your turn: fix four dangling references

Each function in this program hands back or keeps a reference to an object that dies too early. Fix all four so that the program prints Hello, Asha, Asha, Results and 91 on four lines and AddressSanitizer reports nothing:

dangling_exercise.cpp C++ · dangling_exercise.cpp
// Four dangling references. Fix each function so the program prints
//   Hello, Asha / Asha / Results / 91
// on four lines and AddressSanitizer reports nothing.
#include <print>
#include <string>
#include <vector>

// 1. Builds a greeting for a name.
const std::string& greeting(const std::string& name) {
    std::string text = "Hello, " + name;
    return text;
}

// 2. Makes a class list on the spot.
std::vector<std::string> class_list() { return {"Asha", "Ravi", "Meera"}; }

// 3. A label that shows a title.
struct Label {
    const std::string& title;
};

Label results_label() {
    std::string title = "Results";
    return Label{title};
}

// 4. The highest of some marks.
const int& highest(const std::vector<int>& marks) {
    const int* best = &marks[0];
    for (const int& m : marks)
        if (m > *best) best = &m;
    return *best;
}

int main() {
    const std::string& hello = greeting("Asha");
    std::println("{}", hello);

    const std::string& first = class_list().front();
    std::println("{}", first);

    Label label = results_label();
    std::println("{}", label.title);

    const int& top = highest({72, 91, 85});
    std::println("{}", top);
}

As given, Clang warns about two of the bugs and GCC about three, but neither sees the reference member in Label, and the first sanitizer report stops at greeting:

The exercise as given Python · dangling_exercise_session.py
from session import run

# The exercise as given: what the compilers warn about, then the first report of AddressSanitizer.
run("""
clang++ -std=c++23 -Wall -Wextra -c dangling_exercise.cpp -o dangling_exercise.o
g++-16 -std=c++23 -Wall -Wextra -c dangling_exercise.cpp -o dangling_exercise.o
clang++ -std=c++23 -g -fsanitize=address dangling_exercise.cpp -o dangling_exercise 2>/dev/null
ASAN_OPTIONS=detect_stack_use_after_return=1 ./dangling_exercise
""", max_lines=40)

Output

$ clang++ -std=c++23 -Wall -Wextra -c dangling_exercise.cpp -o dangling_exercise.o
dangling_exercise.cpp:11:12: warning: reference to stack memory associated with local variable 'text' returned [-Wreturn-stack-address]
   11 |     return text;
      |            ^~~~
dangling_exercise.cpp:39:32: warning: object backing the pointer will be destroyed at the end of the full-expression [-Wdangling-gsl]
   39 |     const std::string& first = class_list().front();
      |                                ^~~~~~~~~~~~
2 warnings generated.
$ g++-16 -std=c++23 -Wall -Wextra -c dangling_exercise.cpp -o dangling_exercise.o
dangling_exercise.cpp: In function 'const std::string& greeting(const std::string&)':
dangling_exercise.cpp:11:12: warning: reference to local variable 'text' returned [-Wreturn-local-addr]
   11 |     return text;
      |            ^~~~
dangling_exercise.cpp:10:17: note: declared here
   10 |     std::string text = "Hello, " + name;
      |                 ^~~~
dangling_exercise.cpp: In function 'int main()':
dangling_exercise.cpp:36:24: warning: possibly dangling reference to a temporary [-Wdangling-reference]
   36 |     const std::string& hello = greeting("Asha");
      |                        ^~~~~
dangling_exercise.cpp:36:41: note: 'const std::string' {aka 'const std::__cxx11::basic_string<char>'} temporary created here
   36 |     const std::string& hello = greeting("Asha");
      |                                         ^~~~~~
dangling_exercise.cpp:39:24: warning: possibly dangling reference to a temporary [-Wdangling-reference]
   39 |     const std::string& first = class_list().front();
      |                        ^~~~~
dangling_exercise.cpp:39:42: note: 'std::vector<std::__cxx11::basic_string<char> >' temporary created here
   39 |     const std::string& first = class_list().front();
      |                                ~~~~~~~~~~^~
dangling_exercise.cpp:45:16: warning: possibly dangling reference to a temporary [-Wdangling-reference]
   45 |     const int& top = highest({72, 91, 85});
      |                ^~~
dangling_exercise.cpp:45:29: note: 'const std::vector<int>' temporary created here
   45 |     const int& top = highest({72, 91, 85});
      |                      ~~~~~~~^~~~~~~~~~~~~~
$ clang++ -std=c++23 -g -fsanitize=address dangling_exercise.cpp -o dangling_exercise 2>/dev/null
$ ASAN_OPTIONS=detect_stack_use_after_return=1 ./dangling_exercise
==…==ERROR: AddressSanitizer: stack-use-after-return on address 0x…
READ of size 1 at 0x… thread T0
    #11 in main dangling_exercise.cpp:37
Address 0x… is located in stack of thread T0 at offset 55 in frame
    #0 in greeting(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char>> const&) dangling_exercise.cpp:9
    [32, 56) 'text' (line 10) <== Memory access at offset 55 is inside this variable
SUMMARY: AddressSanitizer: stack-use-after-return
[50 more lines of the report left out]
[exit status 134]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dangling_exercise_session.py

  1. Fix the bug the report names, rebuild with the AddressSanitizer command and run it again. AddressSanitizer stops at the first error, so each run shows the next bug. On Linux, detect_stack_use_after_return is already switched on.
  2. Repeat until the program prints its four lines and nothing else.
  3. Build with -Wall -Wextra on both compilers once more: a good fix also leaves no warning.

First hint: for each report, ask which object the reference names and when that object ends. If it ends before the reference is used, either keep the object alive for longer, in a named variable, or stop referring to it and keep a value instead.

Self-check

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 After int a = 1, b = 2; int& r = a; r = b; b = 3;, what are the values of a and b?

    Choose one answer.

    Show the answer to question 1

    Answer: a is 2 and b is 3

    r = b assigns to the object r names, so it copies 2 into a; a reference is never re-seated. Changing b afterwards does not touch a.

  2. Question 2 of 5 Which declarations keep the temporary returned by make() alive for as long as the reference? (same takes a const T& and returns it.)

    Choose every answer that is right.

    Show the answer to question 2

    Answer:

    • const std::string& r = make().name;
    • const T& r = make();
    • T&& r = make();

    A temporary is extended when the reference is bound to it directly, or to a data member of it reached with a dot. Through a function such as same(), the temporary is only a parameter's argument and ends with the full-expression, so r dangles.

  3. Question 3 of 5 Why do both compilers reject int& f() { int x = 1; return x; } in C++23 mode, while C++20 mode only warns?

    Choose one answer.

    Show the answer to question 3

    Answer: In C++23 a returned local variable is an xvalue, and a non-const int& cannot bind to it

    P2266R3, Simpler implicit move, treats x in return x; as an object about to be moved from. A non-const lvalue reference cannot bind to that, so the dangling function no longer compiles. A function returning a const reference to a local still compiles, with a warning.

  4. Question 4 of 5 for (int m : make_batch().items()) reads a destroyed vector in C++20 mode. What changed in C++23?

    Choose one answer.

    Show the answer to question 4

    Answer: Temporaries created in a range-for's range expression now live until the loop ends

    P2718R0 extends the temporaries of the range expression, except by-value function parameters, to the end of the loop. The same expression outside a for loop, such as const auto& v = make_batch().items();, still dangles.

  5. Question 5 of 5 On macOS, how do you make AddressSanitizer report a reference to a local variable that was used after its function returned?

    Choose one answer.

    Show the answer to question 5

    Answer: Set ASAN_OPTIONS to detect_stack_use_after_return=1 for the run

    Clang's documentation says the check for use after return is switched on by default only on Linux. In this lesson's run, the sanitizer build printed the expected number until the option was set, then reported stack-use-after-return.

Interview questions

Fresher · Can a reference be null, and can it be made to refer to another object?

Neither. A reference must be initialised to refer to a valid object, and it stays bound to that object for its whole life: assigning to a reference assigns to the object it names, so r = other; copies other’s value and does not re-point r. A null reference cannot exist in a valid program, because the only way to make one is to dereference a null pointer, which is already undefined behaviour. A reference can still dangle, though, when the object it names is destroyed first, as a reference returned from a function to its local variable does. When you need “no object” or re-pointing, use a pointer, std::optional<std::reference_wrapper<T>> or std::reference_wrapper<T> instead.

Key takeaways

  • A reference is another name for an existing object: it must be initialised, it cannot be re-seated, and assigning through it changes the object it names.
  • T& binds only to modifiable objects; const T& binds to anything, temporaries included; T&& binds only to temporaries.
  • A temporary bound directly to a const T& or T&& lives as long as the reference; bound through a function, as a parameter or a returned reference, it dies at the end of the full-expression.
  • Returning a reference to a local dangles. Compilers warn by default, and C++23 rejects it outright for non-const references; in C++20 mode the runs gave a correct-looking number, a crash, and a report only with the right option.
  • C++23 keeps the temporaries of a range-for’s range expression alive until the loop ends (Clang 20, GCC 15); C++20 code that loops over make().items() reads a destroyed object.
  • Warnings catch some dangling references and AddressSanitizer catches more; on macOS, add ASAN_OPTIONS=detect_stack_use_after_return=1 to find references returned to dead locals.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.