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

Object lifetime and undefined behaviour in C++

What undefined behaviour means in C++, how dangling references and overflows cause it, and why a build that works at -O0 can fail at -O2.

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

What you will learn

  • Explain what undefined behaviour means and why it matters
  • List the most common causes of undefined behaviour
  • Show why "it worked on my machine" proves nothing

Before you start

Every object in a C++ program has a lifetime. It begins once the object has its memory and has been initialised, and it ends when its destructor starts or its memory is given back. A pointer or reference that outlives its object is dangling, and using it is one way to get undefined behaviour: the C++ standard sets no requirements at all for what the program does next. It may crash, print a wrong answer or appear to work, and the result can change with the compiler, the optimisation level or the run. That is why “it worked on my machine” proves nothing in C++.

Undefined behaviour matters because it is invisible to tests that happen to pass. The compiler is allowed to assume it never happens, so a bug can hide in a debug build and appear in the release build, or the other way round. This lesson shows when lifetimes begin and end, the usual causes of undefined behaviour, programs whose output changes between -O0 and -O2 on GCC 16 and Apple clang 21, and the two places where C++26 makes such bugs less dangerous.

What “undefined” means in the standard

The standard sorts the behaviour it does not pin down into three kinds:

  • Implementation-defined: each compiler picks a behaviour and documents it. Example: sizeof(int).
  • Unspecified: the compiler picks one of the allowed behaviours and need not say which. Example: the order in which a function’s arguments are evaluated.
  • Undefined: no requirements at all. Examples: reading past the end of an array, overflowing an int, changing a const object.

The first two are safe to live with: your program has a meaning, even if it varies between compilers. Undefined behaviour is different in two ways. It covers the whole run, not only the faulty line: the standard says that once an execution contains an undefined operation, nothing is required of it, “not even with regard to operations preceding” it. And nobody has to tell you. The compiler need not warn, the program need not crash, and the wrong result may only appear years later with another compiler.

Why would a language allow this? Because checking costs time. C++ does not check every v[i] against the size or every a + b for overflow; it makes the bad cases undefined, so the fast code is the correct code for every valid program. Optimisers go one step further and use the rules as facts: if overflow cannot happen in a valid program, code that only makes sense after an overflow can be deleted.

There is one place where the compiler must catch undefined behaviour for you: a constant expression, which is evaluated while compiling. Here one_more is called at compile time with the largest int:

constexpr_ub.cpp C++ · constexpr_ub.cpp
#include <limits>

// The same overflow as in deposit.cpp, evaluated by the compiler instead of at run time.
constexpr int one_more(int x) { return x + 1; }

constexpr int fits = one_more(41);
constexpr int too_big = one_more(std::numeric_limits<int>::max());
Both compilers refuse the overflow at compile time Python · constexpr_session.py
from session import run

# A constant expression may not contain undefined behaviour, so both compilers refuse to compile this file.
run("""
clang++ -std=c++23 -c constexpr_ub.cpp -o constexpr_ub.o
g++-16 -std=c++23 -c constexpr_ub.cpp -o constexpr_ub.o
""")

Output

$ clang++ -std=c++23 -c constexpr_ub.cpp -o constexpr_ub.o
constexpr_ub.cpp:7:15: error: constexpr variable 'too_big' must be initialized by a constant expression
    7 | constexpr int too_big = one_more(std::numeric_limits<int>::max());
      |               ^         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
constexpr_ub.cpp:4:42: note: value 2147483648 is outside the range of representable values of type 'int'
    4 | constexpr int one_more(int x) { return x + 1; }
      |                                          ^
constexpr_ub.cpp:7:25: note: in call to 'one_more(2147483647)'
    7 | constexpr int too_big = one_more(std::numeric_limits<int>::max());
      |                         ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -c constexpr_ub.cpp -o constexpr_ub.o
constexpr_ub.cpp:7:33:   in 'constexpr' expansion of 'one_more(std::numeric_limits<int>::max())'
    7 | constexpr int too_big = one_more(std::numeric_limits<int>::max());
      |                         ~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
constexpr_ub.cpp:4:42: error: overflow in constant expression [-fpermissive]
    4 | constexpr int one_more(int x) { return x + 1; }
      |                                        ~~^~~
[exit status 1]

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

Apple clang names the value that does not fit, 2,147,483,648, and GCC calls it an overflow in a constant expression. The same function called while the program runs would compile without a word.

When an object’s lifetime begins and ends

The standard’s rule, in plain words: an object’s lifetime begins when memory of the right size and alignment has been obtained for it and its initialisation is complete; for a class type it ends when the destructor call starts, and for any object when its memory is released or reused. Where that memory comes from is the object’s storage duration:

  • Static: global variables and static ones, which live until the program ends.
  • Automatic: local variables, which end at the closing brace of their block, in reverse order of creation.
  • Dynamic: objects made with new, which end at the matching delete.
  • Thread: thread_local variables, one per thread, which end with their thread.

Temporaries, such as a function’s return value that nobody stores, end at the end of the full expression that created them, unless a reference is bound directly to them. The type in tracer.h prints a line when an object’s lifetime begins and when it ends:

Watching lifetimes

lifetime.cpp

#include "tracer.h"

Tracer global{"global"};  // static storage: lives until the program ends

Tracer make(std::string name) { return Tracer{std::move(name)}; }

int main() {
    std::println("main starts");
    {
        Tracer local{"local"};  // automatic storage: lives until the closing brace
        std::println("  inside the block");
    }
    std::println("after the block");

    make("temporary");  // a temporary: lives until the end of this statement
    std::println("after the temporary");

    const Tracer& kept = make("bound to a reference");  // lives as long as the reference
    Tracer* heap = new Tracer{"dynamic"};               // dynamic storage: lives until delete
    delete heap;
    std::println("main ends, still holding {}", kept.name);
}

tracer.h

#pragma once
#include <print>
#include <string>
#include <utility>

// An object that says when its lifetime begins and when it ends.
struct Tracer {
    std::string name;

    explicit Tracer(std::string n) : name(std::move(n)) { std::println("  begin {}", name); }
    Tracer(Tracer&& other) noexcept : name(other.name + " (moved)") { std::println("  begin {}", name); }
    ~Tracer() { std::println("  end   {}", name); }
};
When each object begins and ends Python · lifetime_session.py
from session import run

# When each object's lifetime begins and ends, built with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra lifetime.cpp -o lifetime && ./lifetime
g++-16 -std=c++23 -Wall -Wextra lifetime.cpp -o lifetime-gcc && ./lifetime-gcc > gcc.txt && ./lifetime > clang.txt && cmp gcc.txt clang.txt && echo "GCC's build prints the same lines"
""")

Output

$ clang++ -std=c++23 -Wall -Wextra lifetime.cpp -o lifetime && ./lifetime
  begin global
main starts
  begin local
  inside the block
  end   local
after the block
  begin temporary
  end   temporary
after the temporary
  begin bound to a reference
  begin dynamic
  end   dynamic
main ends, still holding bound to a reference
  end   bound to a reference
  end   global
$ g++-16 -std=c++23 -Wall -Wextra lifetime.cpp -o lifetime-gcc && ./lifetime-gcc > gcc.txt && ./lifetime > clang.txt && cmp gcc.txt clang.txt && echo "GCC's build prints the same lines"
GCC's build prints the same lines

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

The global begins before main and ends after it. The temporary ends on its own line, before “after the temporary” is printed, while the one bound to const Tracer& kept lives as long as the reference, until main ends. Every object is fine while it is alive. The trouble starts when a pointer or a reference is still around after its object’s lifetime has ended.

A reference that a vector left behind

A std::vector keeps its elements in one array. When push_back needs more room than the array has, the vector allocates a bigger array, moves the elements across and frees the old one. The elements you had a reference to have ended. Watch it with Tracer elements and a capacity of 2:

relocate.cpp C++ · relocate.cpp
#include <vector>
#include "tracer.h"

int main() {
    std::vector<Tracer> queue;
    queue.reserve(2);
    queue.emplace_back("A");
    queue.emplace_back("B");
    std::println("size {}, capacity {}", queue.size(), queue.capacity());

    std::println("push C:");
    queue.emplace_back("C");  // no room left: a bigger array, then A and B move into it
    std::println("size {}, capacity {}", queue.size(), queue.capacity());
    std::println("main ends");
}
The old elements end when the vector grows Python · relocate_session.py
from session import run

# A vector that runs out of room: watch the old elements end. libc++ (Apple clang) first, then libstdc++ (GCC).
run("""
clang++ -std=c++23 -Wall -Wextra relocate.cpp -o relocate && ./relocate
g++-16 -std=c++23 -Wall -Wextra relocate.cpp -o relocate-gcc && ./relocate-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra relocate.cpp -o relocate && ./relocate
  begin A
  begin B
size 2, capacity 2
push C:
  begin C
  begin A (moved)
  begin B (moved)
  end   A
  end   B
size 3, capacity 4
main ends
  end   C
  end   B (moved)
  end   A (moved)
$ g++-16 -std=c++23 -Wall -Wextra relocate.cpp -o relocate-gcc && ./relocate-gcc
  begin A
  begin B
size 2, capacity 2
push C:
  begin C
  begin A (moved)
  end   A
  begin B (moved)
  end   B
size 3, capacity 4
main ends
  end   A (moved)
  end   B (moved)
  end   C

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

Both libraries build C in a new array with room for 4, move A and B across, and end the old A and B. They differ in small ways the standard leaves open, such as the order in which the elements are destroyed at the end of main.

A full vector moves its elements to a bigger array on push_back and frees the old one, while a reference still points at the old array.1. Before push_back(100)2. After the callint& firstarray A70 | 80 | 90markssize 3capacity 3int& firstarray A, freedno object herearray B70 | 80 | 90 | 1002 slots freemarkssize 4capacity 6refers to 70ownsstill refers hereownscopy into B, add 100, free A

Why a reference into a vector dangles after push_back

Text description of the diagram

The diagram has two panels, before and after the call marks.push_back(100).

Before the call, the vector marks has size 3 and capacity 3. It owns array A, which holds 70, 80 and 90. The reference first refers to the element 70 in array A.

The call needs room for a fourth element, so the vector allocates a bigger array B with capacity 6, copies or moves 70, 80 and 90 into it, adds 100, and frees array A.

After the call, marks has size 4 and owns array B. Array A is freed, and no object lives there any more, but first still refers to the place where 70 used to be. Reading first is now undefined behaviour.

Now the bug. first is taken from the array before push_back and read after it:

invalidate.cpp C++ · invalidate.cpp
#include <print>
#include <vector>

int main() {
    std::vector<int> marks{70, 80, 90};
    int& first = marks[0];  // refers to an element inside the vector's array
    std::println("first = {}, capacity {}", first, marks.capacity());

    marks.push_back(100);   // the array is full: the elements move to a bigger one
    std::println("capacity {}", marks.capacity());
    std::println("first = {}", first);  // reads the old array, which push_back freed
}
Three runs of a build without checks Python · invalidate_session.py
from session import run

# The reference that outlived its element, built without any checks and run three times.
run("""
clang++ -std=c++23 invalidate.cpp -o invalidate
./invalidate
./invalidate
./invalidate
""")

Output

$ clang++ -std=c++23 invalidate.cpp -o invalidate
$ ./invalidate
first = 70, capacity 3
capacity 6
first = 2
$ ./invalidate
first = 70, capacity 3
capacity 6
first = 2
$ ./invalidate
first = 70, capacity 3
capacity 6
first = 2

This output changes from run to run: The last line reads freed memory, which holds whatever the memory allocator wrote there, so the number can change from run to run and from machine to machine.

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

No crash and no message: the program reads the freed array and prints a number that is not 70, one that can differ on another day. AddressSanitizer, a checking mode of both compilers that the sanitizers lesson covers in depth, turns the same mistake into a report:

The same program built with AddressSanitizer Python · invalidate_asan_session.py
from session import run

# The same program built with AddressSanitizer, by each compiler: the read on line 11 is stopped and explained.
run("""
clang++ -std=c++23 -g -fsanitize=address -fno-omit-frame-pointer invalidate.cpp -o invalidate
./invalidate
g++-16 -std=c++23 -g -fsanitize=address -fno-omit-frame-pointer invalidate.cpp -o invalidate-gcc
./invalidate-gcc
""")

Output

$ clang++ -std=c++23 -g -fsanitize=address -fno-omit-frame-pointer invalidate.cpp -o invalidate
$ ./invalidate
first = 70, capacity 3
capacity 6
==…==ERROR: AddressSanitizer: heap-use-after-free on address 0x…
READ of size 4 at 0x… thread T0
    #8 in main invalidate.cpp:11
0x… is located 0 bytes inside of 12-byte region [0x…,0x…)
freed by thread T0 here:
    #11 in main invalidate.cpp:9
previously allocated by thread T0 here:
    #10 in main invalidate.cpp:5
SUMMARY: AddressSanitizer: heap-use-after-free
[67 more lines of the report left out]
==…==ABORTING
[exit status 134]
$ g++-16 -std=c++23 -g -fsanitize=address -fno-omit-frame-pointer invalidate.cpp -o invalidate-gcc
$ ./invalidate-gcc
first = 70, capacity 3
capacity 6
==…==ERROR: AddressSanitizer: heap-use-after-free on address 0x…
READ of size 4 at 0x… thread T0
    #6 in main invalidate.cpp:11
0x… is located 0 bytes inside of 12-byte region [0x…,0x…)
freed by thread T0 here:
    #9 in main invalidate.cpp:9
previously allocated by thread T0 here:
    #7 in main invalidate.cpp:5
SUMMARY: AddressSanitizer: heap-use-after-free
[60 more lines of the report left out]
==…==ABORTING
[exit status 134]

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

The report names the error (heap-use-after-free), the read on line 11, the push_back on line 9 that freed the memory, and line 5, where the vector first allocated it. The standard states the rule: when push_back reallocates, every reference, pointer and iterator to the elements becomes invalid. The fixes are about who holds on to what:

  • Keep an index (std::size_t first = 0;) and write marks[first] when you need the element.
  • Take the reference after the last insertion, not before.
  • Call reserve with the final size first, when you know it, so that no reallocation happens. This is fragile: one extra push_back brings the bug back.
  • Use a container whose references survive insertions, such as std::deque for insertions at either end, or std::list.

The usual causes of undefined behaviour

Most undefined behaviour in real programs comes from a short list. After each cause comes the check that finds it; most of them are flags you can add to any build today.

  • Reading or writing outside an array, such as v[v.size()], or a[10] for int a[10]. Found by AddressSanitizer and by a hardened standard library.
  • Using an object after its lifetime: a pointer after delete, a reference into a vector that reallocated. Found by AddressSanitizer.
  • A view of a temporary, such as a std::string_view of a temporary std::string. Found by AddressSanitizer, and by Clang’s -Wdangling-gsl warning, which is on by default.
  • Signed integer overflow, such as INT_MAX + 1. Found by UndefinedBehaviorSanitizer (UBSan).
  • Reading an uninitialised variable, such as int total; total += 1;. Found by -Wall; in C++26 it becomes erroneous behaviour.
  • Dereferencing a null pointer: *p when p is nullptr. Usually a crash; UBSan names it.
  • Two threads writing one variable without a lock, such as a shared int incremented by two threads. Found by ThreadSanitizer.
  • Reading an object through the wrong type, such as a float read through a std::uint32_t*. GCC’s -Wstrict-aliasing, part of -Wall, warns with -O2.
  • Bad shifts and divisions: 1 << 32, x / 0, INT_MIN / -1. Found by UBSan.

Unsigned arithmetic is not on the list: it wraps around by definition, so 4'294'967'295u + 1 is 0 everywhere. Signed arithmetic has no such promise, and that difference is behind the next two programs.

Why “it worked on my machine” proves nothing

This function guards a balance against overflow. The guard looks reasonable: if adding the amount made the balance smaller, the sum must have wrapped around.

deposit.cpp C++ · deposit.cpp
#include <cstdlib>
#include <limits>
#include <print>

// Adds a deposit unless the balance would overflow. The check relies on the sum wrapping
// around to a negative number, and signed arithmetic in C++ never promises that.
bool add_deposit(int& balance, int amount) {
    if (balance + amount < balance) return false;
    balance += amount;
    return true;
}

int main(int argc, char* argv[]) {
    int balance = std::numeric_limits<int>::max() - 5;
    int amount = argc > 1 ? std::atoi(argv[1]) : 10;
    bool accepted = add_deposit(balance, amount);
    std::println("accepted: {}, balance: {}", accepted, balance);
}

Here is the same file built four ways, with a balance 5 below the largest int and a deposit of 10:

One file, four builds Python · deposit_session.py
from session import run

# One source file, four builds: two compilers, without optimisation (-O0) and with it (-O2).
run("""
clang++ -std=c++23 -O0 deposit.cpp -o deposit && ./deposit
clang++ -std=c++23 -O2 deposit.cpp -o deposit && ./deposit
g++-16 -std=c++23 -O0 deposit.cpp -o deposit && ./deposit
g++-16 -std=c++23 -O2 deposit.cpp -o deposit && ./deposit
""")

Output

$ clang++ -std=c++23 -O0 deposit.cpp -o deposit && ./deposit
accepted: false, balance: 2147483642
$ clang++ -std=c++23 -O2 deposit.cpp -o deposit && ./deposit
accepted: true, balance: -2147483644
$ g++-16 -std=c++23 -O0 deposit.cpp -o deposit && ./deposit
accepted: true, balance: -2147483644
$ g++-16 -std=c++23 -O2 deposit.cpp -o deposit && ./deposit
accepted: true, balance: -2147483644

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

Only Apple clang at -O0 rejects the deposit. With -O2, and with GCC even at -O0, the guard is gone and the balance becomes negative. Both compilers reasoned the same way: in a valid program balance + amount cannot overflow, so balance + amount < balance can only be true when amount is negative, and the test becomes amount < 0. For a deposit of 10 that is false, so the overflow the guard was meant to catch happens on the next line. Nothing in the program is “wrong” for the compiler; it kept every promise the standard makes. The machine code shows it:

What Apple clang made of add_deposit at -O2 Python · deposit_asm_session.py
from session import run

# The machine code Apple clang writes for add_deposit at -O2 (arm64), from the label of the function to its return.
run("""
clang++ -std=c++23 -O2 -S deposit.cpp -o deposit.s
sed -n '/^__Z11add_depositRii:/,/ret$/p' deposit.s
""")

Output

$ clang++ -std=c++23 -O2 -S deposit.cpp -o deposit.s
$ sed -n '/^__Z11add_depositRii:/,/ret$/p' deposit.s
__Z11add_depositRii:                    ; @_Z11add_depositRii
	.cfi_startproc
; %bb.0:
	tbnz	w1, #31, LBB0_2
; %bb.1:
	ldr	w8, [x0]
	add	w8, w8, w1
	str	w8, [x0]
LBB0_2:
	mvn	w8, w1
	lsr	w0, w8, #31
	ret

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

On arm64, w1 holds amount and x0 the address of balance. The first instruction, tbnz w1, #31, jumps to the end when bit 31 of amount, its sign bit, is set. Otherwise the next three load the balance, add and store it. The last two compute the return value from the sign of amount alone. The balance is never compared with anything.

UndefinedBehaviorSanitizer points at the real bug, and the fixed function, which checks before it adds, behaves the same in every build:

deposit_fixed.cpp C++ · deposit_fixed.cpp
#include <cstdlib>
#include <limits>
#include <print>

// The same rule, checked before adding, so no expression ever overflows.
bool add_deposit(int& balance, int amount) {
    constexpr int max = std::numeric_limits<int>::max();
    constexpr int min = std::numeric_limits<int>::min();
    if (amount > 0 && balance > max - amount) return false;
    if (amount < 0 && balance < min - amount) return false;
    balance += amount;
    return true;
}

int main(int argc, char* argv[]) {
    int balance = std::numeric_limits<int>::max() - 5;
    int amount = argc > 1 ? std::atoi(argv[1]) : 10;
    bool accepted = add_deposit(balance, amount);
    std::println("accepted: {}, balance: {}", accepted, balance);
}
UBSan, then the fixed version in four builds Python · deposit_check_session.py
from session import run

# UndefinedBehaviorSanitizer names the overflow; the fixed version gives one answer in every build.
run("""
clang++ -std=c++23 -O2 -g -fsanitize=undefined deposit.cpp -o deposit && ./deposit
clang++ -std=c++23 -O0 deposit_fixed.cpp -o fixed && ./fixed
clang++ -std=c++23 -O2 deposit_fixed.cpp -o fixed && ./fixed
g++-16 -std=c++23 -O0 deposit_fixed.cpp -o fixed && ./fixed
g++-16 -std=c++23 -O2 deposit_fixed.cpp -o fixed && ./fixed
""")

Output

$ clang++ -std=c++23 -O2 -g -fsanitize=undefined deposit.cpp -o deposit && ./deposit
deposit.cpp:8:17: runtime error: signed integer overflow: 2147483642 + 10 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior deposit.cpp:8:17
accepted: false, balance: 2147483642
$ clang++ -std=c++23 -O0 deposit_fixed.cpp -o fixed && ./fixed
accepted: false, balance: 2147483642
$ clang++ -std=c++23 -O2 deposit_fixed.cpp -o fixed && ./fixed
accepted: false, balance: 2147483642
$ g++-16 -std=c++23 -O0 deposit_fixed.cpp -o fixed && ./fixed
accepted: false, balance: 2147483642
$ g++-16 -std=c++23 -O2 deposit_fixed.cpp -o fixed && ./fixed
accepted: false, balance: 2147483642

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

Two details are worth noticing. UBSan reported the overflow and let the program go on, so it still exited with status 0: in a test suite, add -fno-sanitize-recover=undefined so that the first report fails the run. And in the instrumented build the guard happened to work again, because the instrumentation changed the code; a sanitizer finds the bug, it does not tell you how the release build behaves.

Programmer Calculator (Bitwise) Add 2147483642 and 10 at 32 bits, signed, to see the wrapped result -2147483644 and the overflow note: what the hardware does, which C++ does not promise for int.

An uninitialised variable is even less predictable. total below is never given a starting value:

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

int total_of(int n) {
    int total;  // never given a value
    for (int i = 1; i <= n; ++i) total += i;
    return total;
}

int main() {
    std::println("total_of(3) = {}", total_of(3));
}
Six builds of one uninitialised variable Python · uninit_session.py
from session import run

# One uninitialised variable, six builds. The right answer would be 6.
run("""
clang++ -std=c++23 -O0 uninit.cpp -o uninit && ./uninit
clang++ -std=c++23 -O2 uninit.cpp -o uninit && ./uninit
g++-16 -std=c++23 -O0 uninit.cpp -o uninit && ./uninit
g++-16 -std=c++23 -O2 uninit.cpp -o uninit && ./uninit
# C++26 mode: GCC 16 gives the variable a fixed value; Apple clang 21 does not do this yet
g++-16 -std=c++26 -O0 uninit.cpp -o uninit && ./uninit
clang++ -std=c++26 -O0 uninit.cpp -o uninit && ./uninit
# The warning that points at the bug
clang++ -std=c++23 -Wall -c uninit.cpp -o uninit.o
""")

Output

$ clang++ -std=c++23 -O0 uninit.cpp -o uninit && ./uninit
total_of(3) = -285001050
$ clang++ -std=c++23 -O2 uninit.cpp -o uninit && ./uninit
total_of(3) = 0
$ g++-16 -std=c++23 -O0 uninit.cpp -o uninit && ./uninit
total_of(3) = 7
$ g++-16 -std=c++23 -O2 uninit.cpp -o uninit && ./uninit
total_of(3) = 6
# C++26 mode: GCC 16 gives the variable a fixed value; Apple clang 21 does not do this yet
$ g++-16 -std=c++26 -O0 uninit.cpp -o uninit && ./uninit
total_of(3) = 6
$ clang++ -std=c++26 -O0 uninit.cpp -o uninit && ./uninit
total_of(3) = -285001050
# The warning that points at the bug
$ clang++ -std=c++23 -Wall -c uninit.cpp -o uninit.o
uninit.cpp:5:34: warning: variable 'total' is uninitialized when used here [-Wuninitialized]
    5 |     for (int i = 1; i <= n; ++i) total += i;
      |                                  ^~~~~
uninit.cpp:4:14: note: initialize the variable 'total' to silence this warning
    4 |     int total;  // never given a value
      |              ^
      |               = 0
1 warning generated.

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

The right answer is 6. The four C++23 builds print four different values, a large negative number, 0, 7 and 6: whatever was in that register or stack slot, or whatever the optimiser chose once it noticed the read. GCC at -O2 printing 6 is luck, not correctness. The -Wall warning at the end finds the bug in one line, which is a good reason to build with warnings on every time.

What C++26 changes

C++26 does not remove undefined behaviour, but it makes two common kinds of it less dangerous.

Erroneous behaviour for uninitialised reads. Reading a local variable that was never initialised is no longer undefined: the variable holds an erroneous value, the read is well defined but still a bug, and compilers are encouraged to diagnose it. A variable whose indeterminate value is wanted on purpose, for example a large buffer that is filled later, can opt out with the attribute [[indeterminate]].

Version note

GCC 16 implements erroneous behaviour for uninitialised reads: in C++26 mode it zero-initialises such local variables unless -ftrivial-auto-var-init says otherwise, which is why -std=c++26 printed 6 at -O0. Clang’s status page lists the feature as not implemented, and Apple clang 21 printed the same value in C++23 and C++26 mode. Write int total = 0; either way: the new rule makes the bug predictable, not correct.

Standard library hardening. A hardened implementation of the standard library checks some preconditions of its functions while the program runs, mainly the ones that prevent reading or writing out of bounds: the index of operator[] on std::vector, std::span, std::string and std::string_view, front() and back() on an empty container, and the value of an empty std::optional. Breaking one of them becomes a contract violation, which stops the program instead of corrupting memory. How you turn hardening on is left to each toolchain, and both libraries already offer it:

bounds.cpp C++ · bounds.cpp
#include <print>
#include <vector>

int main() {
    std::vector<int> marks{70, 80, 90};
    std::size_t i = marks.size();  // one past the last element
    std::println("marks[{}] = {}", i, marks[i]);
}
One read past the end, with and without the library's checks Python · bounds_session.py
from session import run

# Reading one element past the end, with the standard libraries' checks off and on.
run("""
clang++ -std=c++23 -O2 bounds.cpp -o bounds && ./bounds
g++-16 -std=c++23 -O2 bounds.cpp -o bounds && ./bounds
g++-16 -std=c++23 -O0 bounds.cpp -o bounds && ./bounds
clang++ -std=c++23 -O2 -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST bounds.cpp -o bounds && ./bounds
clang++ -std=c++23 -O2 -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG bounds.cpp -o bounds && ./bounds
""")

Output

$ clang++ -std=c++23 -O2 bounds.cpp -o bounds && ./bounds
marks[3] = 0
$ g++-16 -std=c++23 -O2 bounds.cpp -o bounds && ./bounds
marks[3] = 0
$ g++-16 -std=c++23 -O0 bounds.cpp -o bounds && ./bounds
…/c++/16/bits/stl_vector.h:1253: constexpr std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::reference std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::operator[](size_type) [with _Tp = int; _Alloc = std::allocator<int>; reference = int&; size_type = long unsigned int]: Assertion '__n < this->size()' failed.
Abort trap: 6
[exit status 134]
$ clang++ -std=c++23 -O2 -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST bounds.cpp -o bounds && ./bounds
Trace/BPT trap: 5
[exit status 133]
$ clang++ -std=c++23 -O2 -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG bounds.cpp -o bounds && ./bounds
…/c++/v1/__vector/vector.h:414: libc++ Hardening assertion __n < size() failed: vector[] index out of bounds
Abort trap: 6
[exit status 134]

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

Without checks both libraries return a value for marks[3], here 0, as if it existed. GCC’s library checks operator[] by default when you compile without optimisation, so the -O0 build stops with a message naming the failed condition; the -O2 build drops the check. libc++’s fast mode stops the program with a trap and no message, exit status 133 (128 plus signal 5, the trap signal), and its debug mode prints which check failed. The fast mode is designed to be cheap enough for release builds.

Version note

The libstdc++ manual describes _GLIBCXX_ASSERTIONS as defined by default when compiling with no optimisation; define it yourself, or _GLIBCXX_NO_ASSERTIONS to opt out. In libc++ choose a mode with -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST, _EXTENSIVE or _DEBUG.

Neither change covers raw pointers, use after free, data races or signed overflow. Those stay undefined in C++26, and sanitizers are still the way to find them.

Spot the undefined behaviour

Ten short snippets follow, each with one question. In every one, int is 32 bits wide, as it is on the compilers of this track. Decide before you check, and read the explanation even when you are right.

Spot the undefined behaviour: ten snippets

10 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 10 Does this loop have undefined behaviour?

    Read the code, then choose one answer.

    int scores[5] = {};
    for (int i = 0; i <= 5; ++i) scores[i] = 10;
    Show the answer to question 1

    Answer: Yes. The last pass writes scores[5], one element past the end of the array

    An array of 5 elements has indexes 0 to 4. The condition i <= 5 lets the loop write scores[5], memory that belongs to something else. The compiler does not have to warn, and the program may seem to work. Write the loop as i < 5, or better, as a range-for loop.

  2. Question 2 of 10 Does the addition have undefined behaviour?

    Read the code, then choose one answer.

    unsigned int u = 4'294'967'295u;  // the largest 32-bit unsigned value
    u = u + 1;
    Show the answer to question 2

    Answer: No. Unsigned arithmetic wraps around, so u becomes 0

    Unsigned integer types do arithmetic modulo 2 to the power of their width, so the result is always defined: here it is 0. Only signed overflow is undefined. A wrap can still be a bug in your logic, which is why Clang offers -fsanitize=unsigned-integer-overflow as an extra check outside -fsanitize=undefined.

  3. Question 3 of 10 Does the addition have undefined behaviour?

    Read the code, then choose one answer.

    int big = 2'147'483'647;  // the largest 32-bit int
    big = big + 1;
    Show the answer to question 3

    Answer: Yes. The result does not fit in an int, so this is signed overflow

    C++20 fixed how signed integers are represented, but it did not make overflow defined: an arithmetic result that is out of range for its type is still undefined behaviour. An optimiser may assume it never happens, which is exactly what broke the overflow check in deposit.cpp.

  4. Question 4 of 10 Does the last line have undefined behaviour?

    Read the code, then choose one answer.

    std::vector<int> v{1, 2, 3};   // size 3, capacity 3
    int* p = &v[0];
    v.push_back(4);
    std::println("{}", *p);
    Show the answer to question 4

    Answer: Yes. push_back needed more room, moved the elements to a new array and freed the old one

    With no spare capacity, push_back reallocates, which invalidates every pointer, reference and iterator to the elements. p still holds the old address, where no object lives any more. Keep an index instead, or take the pointer again after the call.

  5. Question 5 of 10 Compiled as C++23, what does reading x mean?

    Read the code, then choose one answer.

    int x;
    std::println("{}", x);
    Show the answer to question 5

    Answer: Undefined behaviour, because x has an indeterminate value

    A local int without an initialiser has an indeterminate value, and reading it is undefined behaviour up to C++23; compilers warn with -Wall. C++26 turns this particular case into erroneous behaviour: the read gives some defined value, but it is still a bug. Write int x = 0; or give x its real value.

  6. Question 6 of 10 Does printing name have undefined behaviour?

    Read the code, then choose one answer.

    std::string_view name = std::string("Asha") + " Rao";
    std::println("{}", name);
    Show the answer to question 6

    Answer: Yes. The std::string built on the first line is a temporary that dies at the end of that line

    A std::string_view only points at characters; it owns nothing. The temporary string is destroyed at the end of the full expression, so name points into freed memory. Lifetime extension applies when a reference is bound directly to a temporary, never to a view. Store a std::string when you need to keep the text.

  7. Question 7 of 10 Does the second line have undefined behaviour?

    Read the code, then choose one answer.

    std::vector<int> v;
    int first = v.front();
    Show the answer to question 7

    Answer: Yes. front() requires a vector that is not empty

    front() has a precondition: the vector must not be empty. Breaking it is undefined behaviour, and nothing is thrown. Hardened builds of the standard libraries check this precondition and stop the program; at() is the member function that checks and throws, but there is no checked front().

  8. Question 8 of 10 In C++20 and later, does this shift have undefined behaviour?

    Read the code, then choose one answer.

    int bits = 1 << 31;
    Show the answer to question 8

    Answer: No. Since C++20 the result is the value congruent modulo 2 to the 32nd, which is -2147483648

    C++20 defines E1 << E2 as the value congruent to E1 times 2 to the E2 modulo 2 to the N, so 1 << 31 is the smallest int. A shift is undefined when the shift count is negative or at least the width of the type: 1 << 32 is still undefined behaviour.

  9. Question 9 of 10 Does the second line have undefined behaviour?

    Read the code, then choose one answer.

    float f = 1.5f;
    std::uint32_t bits = *reinterpret_cast<std::uint32_t*>(&f);
    Show the answer to question 9

    Answer: Yes. It reads a float object through a glvalue of type std::uint32_t, which the aliasing rule forbids

    The standard lets you read an object only through its own type, its signed or unsigned twin, or a character type such as unsigned char or std::byte. Optimisers rely on that rule. std::bit_cast<std::uint32_t>(f) copies the bytes into a new uint32_t and is well defined; the memory layout lesson uses it.

  10. Question 10 of 10 Does this loop have undefined behaviour?

    Read the code, then choose one answer.

    int a[4] = {1, 2, 3, 4};
    int total = 0;
    for (int* p = a; p != a + 4; ++p) total += *p;
    Show the answer to question 10

    Answer: No. A pointer one past the last element may be formed and compared, as long as it is never dereferenced

    Pointer arithmetic may produce any position inside the array or one past its end, and that end pointer can be compared. The loop stops before dereferencing it, so it reads exactly the 4 elements. Forming a + 5, or reading *(a + 4), would be undefined behaviour.

Interview questions

Fresher · What is erroneous behaviour in C++26, and how is it different from undefined behaviour?

Erroneous behaviour is a new category in C++26 for code that is definitely wrong but still has a defined result. Its first use is reading an uninitialised local variable: up to C++23 that was undefined behaviour, so the optimiser could assume it never happened and anything could follow. In C++26 the variable holds an erroneous value instead, the read gives that value, and compilers are encouraged to diagnose it. The difference matters: undefined behaviour takes away the meaning of the whole run, while erroneous behaviour keeps the program predictable until the bug is found. GCC 16 implements it by zero-initialising such variables in -std=c++26 mode; Clang does not yet. It is still a bug, and the attribute [[indeterminate]] marks a variable left uninitialised on purpose.

Key takeaways

  • An object’s lifetime runs from the end of its initialisation to the start of its destructor or the release of its memory. A pointer or reference that outlives its object dangles, and using it is undefined behaviour.
  • Undefined behaviour means the standard places no requirements on the whole run: a crash, a wrong answer and a correct-looking answer are all allowed, and none of them has to be repeatable.
  • push_back on a full vector reallocates and invalidates every reference, pointer and iterator to its elements; keep indexes, take references after insertions, or use a container whose references stay valid.
  • Optimisers treat the rules as facts. An overflow check written as balance + amount < balance disappears at -O2 in Apple clang and in GCC already at -O0; check before you compute instead.
  • Build with -Wall, and run tests with sanitizers and a hardened standard library: they catch what a passing run cannot.
  • C++26 makes uninitialised local reads erroneous behaviour and adds standard library hardening. Signed overflow, dangling pointers and data races remain undefined.

References

Related tools

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.