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.
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
What you’ll learn · Before you start
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
On this page
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 aconstobject.
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:
#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()); 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
staticones, 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 matchingdelete. - Thread:
thread_localvariables, 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:
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); }
}; 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:
#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");
} 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.
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:
#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
} 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:
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 writemarks[first]when you need the element. - Take the reference after the last insertion, not before.
- Call
reservewith the final size first, when you know it, so that no reallocation happens. This is fragile: one extrapush_backbrings the bug back. - Use a container whose references survive insertions, such as
std::dequefor insertions at either end, orstd::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()], ora[10]forint 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_viewof a temporarystd::string. Found by AddressSanitizer, and by Clang’s-Wdangling-gslwarning, 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:
*pwhenpisnullptr. Usually a crash; UBSan names it. - Two threads writing one variable without a lock, such as a shared
intincremented by two threads. Found by ThreadSanitizer. - Reading an object through the wrong type, such as a
floatread through astd::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.
#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:
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:
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:
#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);
} 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.
An uninitialised variable is even less predictable. total below is never given a starting value:
#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));
} 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:
#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]);
} 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.
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_backon 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 < balancedisappears at-O2in 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
- Working Draft, Programming Languages: C++ (N4950) (ISO/IEC JTC1/SC22/WG21)
- P2795R5: Erroneous behaviour for uninitialized reads (ISO/IEC JTC1/SC22/WG21)
- P3471R4: Standard library hardening (ISO/IEC JTC1/SC22/WG21)
- C++ Standards Support in GCC (GNU Project)
- GCC 16 Release Series, Changes, New Features, and Fixes (GNU Project)
- Options That Control Optimization (-ftrivial-auto-var-init) (GNU Project)
- C++ Support in Clang (LLVM Project)
- Hardening Modes (libc++) (LLVM Project)
- The GNU C++ Library Manual: Macros (GNU Project)
- AddressSanitizer (Clang documentation) (LLVM Project)
- UndefinedBehaviorSanitizer (Clang documentation) (LLVM Project)
- Program Instrumentation Options (GCC) (GNU Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress