Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 7 of 7

You have met the phrase repeatedly. Now the definition, because it is stronger and stranger than most people assume.

Undefined behaviour means the C++ standard imposes no requirements whatsoever on what the program does. Not "it returns a garbage value." Not "it probably crashes." No requirements at all.

A program containing UB on some execution path has no defined meaning on that path. The compiler is permitted to produce anything.

The three kinds

The standard separates them, and the distinction matters:

| | Meaning | Example | |---|---|---| | Implementation-defined | Must pick a behaviour and document it | sizeof(int) | | Unspecified | Must pick from a set, need not document | order of evaluation of arguments | | Undefined | No requirements at all | dereferencing a null pointer |

Implementation-defined is safe to rely on if you accept being tied to that compiler. Unspecified means do not depend on it. Undefined means your program is broken, even if it appears to run.

What you have already seen

Every one of these is UB:

  • Dereferencing a null or dangling pointer (lesson 8.5)
  • Reading or writing outside an array (lesson 8.6)
  • Signed integer overflow (lesson 3.3)
  • Reading an uninitialised variable (lesson 3.1)
  • deleteing the same pointer twice (lesson 8.3)
  • Pointer arithmetic outside an array (lesson 7.4)
  • Falling off the end of a non-void function (lesson 5.1)

Why "it worked on my machine" proves nothing

Compilers optimise by assuming UB never happens. That assumption is what makes fast code possible, and it produces results that look absurd out of context.

Consider a function that dereferences a pointer and then checks it for null:

int deref_then_check(int* p) {
    int value = *p;          // if p were null, this is UB
    if (p == nullptr) {      // so the compiler may conclude p is never null
        return -1;           // ...and delete this branch entirely
    }
    return value;
}

The reasoning is airtight from the compiler's point of view: *p already happened, so if p were null the program would have had no defined meaning. Therefore p is not null. Therefore the check is dead code, and removing it is a legal optimisation.

Your null check disappears. Not because the compiler is hostile, but because you promised something you did not keep.

This is why UB behaves differently between -O0 and -O2, between GCC and Clang, and between last month's compiler and this month's. The behaviour is not a property of your code; it is a property of your code plus every optimisation decision that touched it.

Defined behaviour is testable; UB is not

#include <iostream>
#include <vector>
#include <stdexcept>

// Defined: at() throws on a bad index, on every machine, every build.
int safe_get(const std::vector<int>& v, std::size_t i) {
    return v.at(i);
}

int main() {
    std::vector<int> v = {10, 20, 30};

    std::cout << safe_get(v, 1) << "\n";

    try {
        safe_get(v, 99);
    } catch (const std::out_of_range&) {
        std::cout << "out of range, caught\n";
    }

    // The same mistake with v[99] would be undefined: no exception, no
    // diagnostic, and possibly a plausible-looking wrong answer.
    std::cout << "this outcome is guaranteed by the standard\n";
    return 0;
}
20
out of range, caught
this outcome is guaranteed by the standard

You can write a test for the at() version and it will pass or fail consistently. You cannot meaningfully test the [] version, because "it printed the right thing this time" is not evidence of anything.

How to actually find it

Since the compiler mostly cannot, use the run-time tools:

g++ -std=c++20 -fsanitize=address,undefined -g main.cpp -o main
  • AddressSanitizer — use-after-free, buffer overruns, leaks.
  • UndefinedBehaviorSanitizer — signed overflow, bad shifts, null dereference, misaligned access.

They cost roughly 2× run time, which is irrelevant for a test suite and unacceptable in production. Run your tests under them; ship without them. Module 11 covers the setup.

Also worth having on: -Wall -Wextra -Wpedantic, and -Werror so warnings cannot accumulate.

The practical posture

You cannot memorise every rule in the standard. You do not need to. Three habits cover most of it:

Prefer constructs that cannot express the bug. Range-based for has no index to get wrong. std::vector knows its size. std::unique_ptr cannot be double-deleted. Choosing these is not about discipline — it removes the possibility.

Treat every warning as a defect. Most UB the compiler can see, it warns about. A build with 200 accepted warnings is a build where the one that matters is invisible.

Never conclude from a successful run. If you find yourself saying "it works, so it must be fine," you have not tested the thing you think you tested. Reach for a sanitizer instead.

Try it

Take a small program with a deliberate signed-integer overflow. Run it at -O0 and at -O2 and compare. Then build it with -fsanitize=undefined and read the report.

Checkpoint

  • Undefined behaviour means the standard imposes no requirements at all on the program.
  • It differs from implementation-defined (documented) and unspecified (chosen, undocumented).
  • Compilers optimise by assuming UB cannot happen, which can delete checks you wrote.
  • The same UB may behave differently at -O0 and -O2, or between compilers.
  • A successful run is not evidence of correctness — UB frequently looks like it works.
  • Sanitizers find at run time what the compiler cannot find at compile time.
  • Signed overflow is undefined; unsigned overflow is defined to wrap.

Questions about this lesson

Log in to ask a question.