Undefined behaviour

C++ · Advanced · 15 min

UB is the source of C++'s worst bugs and security holes; experienced developers recognise the patterns on sight.

The C++ standard sorts program behaviour into categories. Implementation-defined behaviour, like sizeof(long), varies by platform but is documented. Unspecified behaviour, like the evaluation order of function arguments, is one of several allowed outcomes. Undefined behaviour (UB) means the standard places no requirements at all: the program may crash, appear to work, or do something bizarre.

The danger is the optimiser. Compilers assume UB never happens and optimise on that assumption. If x + 1 < x can only be true by overflowing a signed int, the compiler may fold it to false. If you dereference a pointer and later check it for null, the check may be deleted. UB can also appear to "travel back in time" and affect code that ran before it.

Common sources:

  • Signed integer overflow, division by zero, shifting by the type's width or more.
  • Out-of-bounds access (v[i], raw arrays, pointer arithmetic beyond the array).
  • Null, dangling or freed pointers and references, including iterators invalidated by push_back and string_views into dead strings.
  • Reading an uninitialized variable. (C++26 makes this "erroneous behaviour" with defined consequences, but it's still a bug.)
  • Data races between threads.
  • Breaking strict aliasing (reading a float through a uint32_t*) and other object-lifetime violations.
  • Modifying a const object, or infinite loops without side effects.

Your defences: compile with -Wall -Wextra; test with -fsanitize=address,undefined (and thread for races); run static analysers like clang-tidy; use checked accessors (at()) in non-critical paths and hardened standard library modes; and prefer RAII and value types that make lifetime errors hard to write. constexpr evaluation also rejects UB, so compile-time tests catch it.

Example

// build: g++ -std=c++20 -O2 -g -fsanitize=address,undefined ub.cpp
#include <climits>
#include <iostream>
#include <string>
#include <string_view>
#include <vector>

bool will_overflow(int x) {
    return x + 1 < x;           // UB when x == INT_MAX; -O2 may compile it to `false`
}
bool will_overflow_ok(int x) { return x == INT_MAX; }   // check before computing

std::string_view first_word(const std::string& s) {
    return std::string_view(s).substr(0, s.find(' '));
}

int main() {
    std::cout << will_overflow(INT_MAX) << will_overflow_ok(INT_MAX) << '\n';  // often "01"

    std::vector<int> v{1, 2, 3};
    int& first = v[0];
    v.push_back(4);             // may reallocate: `first` now dangles
    // std::cout << first;      // UB: ASan reports heap-use-after-free

    auto w = first_word(std::string("hello world"));   // view into a dead temporary
    // std::cout << w;          // UB: dangling string_view
    (void)first; (void)w;
}

Remember

  • UB means no guarantees; the optimiser assumes it never happens
  • Usual suspects: overflow, out of bounds, dangling pointers/refs, uninitialised reads, data races
  • Sanitizers (-fsanitize=address,undefined,thread) and warnings find most of it

Watch out for

  • Concluding code is fine because it "works on my machine" — UB can change behaviour with a new compiler, flag or input.

Check yourself

  1. `int* p = get(); int v = *p; if (p == nullptr) return;` — what may an optimising compiler do?
  2. Which of these is implementation-defined rather than undefined?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: en.cppreference.com/w/cpp/language/ub