Understanding & Avoiding Undefined Behaviour
Learn what undefined behaviour is, why it exists, how compilers exploit it for optimisation, and how to detect it with sanitisers and compiler warnings.
What Is Undefined Behaviour and Why Does It Exist?
Undefined behaviour (UB) means the C++ standard places no requirements on the program's behaviour. The program might crash, produce wrong results, appear to work correctly, or format your hard drive — all are "valid" outcomes.
UB exists because it enables aggressive optimisation. When the compiler knows that certain situations "cannot happen" (because they would be UB), it can generate faster code by omitting checks for those situations. For example:
- Signed integer overflow is UB allows the compiler to assume x + 1 > x is always true, enabling loop optimisations.
- Dereferencing a null pointer is UB allows the compiler to eliminate null checks after a dereference.
- Accessing out-of-bounds memory is UB allows the compiler to omit bounds checks, making array access as fast as raw memory.
The fundamental bargain: C++ trusts the programmer to avoid UB in exchange for maximum performance. When that trust is violated, all bets are off. It is rather like lending someone your car on the understanding that they will be sensible.
Common Sources of Undefined Behaviour
Here are the most frequent sources of UB in real-world C++ code. Every C++ programmer encounters these, usually at the least convenient moment.
#include <vector>
#include <iostream>
#include <cstdint>
// 1. SIGNED INTEGER OVERFLOW
void signed_overflow() {
int x = INT32_MAX;
int y = x + 1; // UB! Signed overflow is undefined
// unsigned overflow is well-defined (wraps around)
unsigned u = UINT32_MAX;
unsigned v = u + 1; // OK: v == 0 (well-defined wrap)
}
// 2. NULL POINTER DEREFERENCE
void null_deref(int* ptr) {
int val = *ptr; // UB if ptr is null
if (ptr == nullptr) { // Compiler may remove this check!
// The compiler reasons: "ptr was already dereferenced above,
// which would be UB if null. Since UB cannot happen in a
// correct program, ptr must be non-null. This check is dead code."
std::cout << "null!\n";
}
}
// 3. OUT-OF-BOUNDS ACCESS
void out_of_bounds() {
int arr[5] = {1, 2, 3, 4, 5};
int bad = arr[10]; // UB! No bounds checking on raw arrays
// std::vector::operator[] is also UB if out of bounds
// Use .at() for bounds-checked access (throws std::out_of_range)
}
// 4. USE-AFTER-FREE / DANGLING REFERENCES
int& dangling_reference() {
int local = 42;
return local; // UB! Reference to destroyed local
}
// 5. DATA RACES
// int shared = 0;
// Thread 1: shared++; // UB if concurrent with Thread 2
// Thread 2: shared++; // UB! Data race on non-atomic variableThe "Time Travel" Problem
One of the most counterintuitive aspects of UB is that it can affect code before the UB point. Compilers optimise based on the assumption that UB never happens in a correct program, and these optimisations can reorder or eliminate code that appears before the UB.
Consider:
bool checked = false;
void process(int* p) {
int value = *p; // UB if p is null
checked = true; // This line is BEFORE the use of 'value'
if (p == nullptr) { // Compiler: "p was dereferenced, so it's not null"
abort(); // Compiler: "This is dead code, remove it"
}
use(value);
}The compiler may set checked = true before the dereference (reordering for the pipeline), and then remove the null check entirely. The result: checked is set to true even when p is null, and the null dereference proceeds without the abort. The UB "travelled back in time" to corrupt the checked flag.
This is not a compiler bug — it is a direct consequence of the optimisation freedoms that UB grants. Rather unsporting of it, but entirely legal.
Detecting UB with Sanitisers
Sanitisers are compiler-provided tools that instrument your code to detect UB at runtime. They add runtime checks that catch UB with clear error messages. Always run your test suite with sanitisers enabled.
# AddressSanitizer (ASan) — detects:
# - Buffer overflow (stack, heap, global)
# - Use-after-free, use-after-return
# - Double-free, memory leaks
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 program.cpp -o program
# UndefinedBehaviorSanitizer (UBSan) — detects:
# - Signed integer overflow
# - Null pointer dereference
# - Out-of-bounds array access
# - Misaligned memory access
# - Type punning violations
g++ -fsanitize=undefined -fno-omit-frame-pointer -g program.cpp -o program
# ThreadSanitizer (TSan) — detects:
# - Data races between threads
# - Lock order inversions (potential deadlocks)
g++ -fsanitize=thread -g program.cpp -o program
# MemorySanitizer (MSan) — detects:
# - Reads of uninitialized memory
# (Clang-only, not available in GCC)
clang++ -fsanitize=memory -fno-omit-frame-pointer -g program.cpp -o program
# Combine ASan + UBSan (common in CI pipelines)
g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 \
-Wall -Wextra -Werror program.cpp -o program
# CMake integration
# In CMakeLists.txt:
# target_compile_options(myapp PRIVATE -fsanitize=address,undefined)
# target_link_options(myapp PRIVATE -fsanitize=address,undefined)Compiler Warnings as a Safety Net
Modern compilers can detect many potential UB situations at compile time. Treat warnings as errors (-Werror) in CI to ensure no warning is ignored. A warning politely ignored is just a bug waiting in the queue.
# Recommended warning flags for all C++ projects
g++ -std=c++23 \
-Wall # Enable most warnings \
-Wextra # Enable additional warnings \
-Wpedantic # Strict ISO C++ compliance \
-Werror # Treat warnings as errors \
-Wconversion # Warn on implicit narrowing conversions \
-Wshadow # Warn when a variable shadows another \
-Wnon-virtual-dtor # Warn on classes with virtual functions but non-virtual dtor \
-Wold-style-cast # Warn on C-style casts \
-Wcast-align # Warn on potentially misaligned casts \
-Wunused # Warn on unused variables/functions \
-Woverloaded-virtual # Warn when a derived class hides a base virtual function \
-Wnull-dereference # Warn on potential null dereferences (GCC) \
-Wdouble-promotion # Warn when float is implicitly promoted to double \
-Wformat=2 # Warn on printf/scanf format string issues \
program.cpp -o program
# For Clang, add:
# -Weverything # Enable ALL warnings (then selectively disable)
# -Wno-c++98-compat # Disable C++98 compatibility warningsFollow these guidelines to minimise UB in your codebase.
- Enable all warnings and treat them as errors:
-Wall -Wextra -Wpedantic -Werror - Run sanitisers in CI: Combine ASan + UBSan for every test run; TSan for multithreaded code
- Use
.at()instead of[]for bounds-checked container access during development - Use smart pointers (
unique_ptr,shared_ptr) instead of rawnew/deleteto eliminate use-after-free - Use
std::atomicfor variables shared between threads — never use plain types for cross-thread data - Prefer unsigned types for bit manipulation but signed types for arithmetic (unsigned arithmetic wraps, which can cause subtle bugs in loop bounds)
The most dangerous UB is code that appears to work perfectly in testing but fails in production. Here is why. (Production, naturally, always picks the worst possible moment.)
- UB can "work" for years and then break when you upgrade the compiler, change optimisation levels, or run on a different CPU
- Signed overflow in loops:
for (int i = 0; i < n; i++)— ifnisINT_MAX,i++overflows. The compiler may assume the loop terminates and optimise accordingly - Reading uninitialised variables: The value is not "random" — the compiler may optimise away the variable entirely or propagate whatever bits happen to be in the register
- Strict aliasing violations: Casting
int*tofloat*and dereferencing is UB (except throughchar*orstd::byte*). Usestd::bit_cast(C++20) for type punning std::string_viewoutliving the string: Returns a dangling view that appears to work until the memory is reused
- Undefined behaviour exists to enable aggressive compiler optimisations — C++ trades safety for performance
- UB can "time travel" — the compiler may reorder or eliminate code that precedes the UB point
- The most common UB sources are signed overflow, null dereference, out-of-bounds access, use-after-free, and data races
- Sanitisers (ASan, UBSan, TSan, MSan) detect UB at runtime — run them in every CI pipeline
- Compiler warnings (
-Wall -Wextra -Werror) catch many potential UB situations at compile time - UB that "works" today can break silently with a new compiler, optimisation level, or platform
Quiz — Test Your Knowledge
(15 XP)1. Why is signed integer overflow undefined behaviour in C++?
2. What does AddressSanitizer (ASan) detect?
3. What is the 'time travel' problem with undefined behaviour?