AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

19 Performance and undefined behaviour / Lesson 1 of 5

The undefined behaviour catalogue — what the compiler is allowed to assume

15 min · +10 XP · 2 exercises · 7 questions

Read the text15 min
Exercises0/2
Quiz7 q

Quick answer

What is undefined behaviour in C++?

Undefined behaviour (UB) in C++ is an operation the standard imposes no requirements on — signed integer overflow, reading past an array, dereferencing a null or dangling pointer, a data race. The compiler may assume it never happens and optimise on that assumption, so a program with UB can print the right answer, crash, or silently lose a check, and behave differently at -O2 or on another machine.

Undefined behaviour is the contract at the centre of C++: the standard lists operations it "imposes no requirements" on, and in exchange the compiler may assume they never happen. That assumption is what makes -O2 fast — a loop over a signed index needs no wrap-around check because signed overflow cannot occur — and it is what makes a program with undefined behaviour unpredictable rather than merely wrong. This lesson catalogues the operations that are undefined, shows how the optimiser turns each into a deleted check, and settles the well-defined tools for detecting them: checked arithmetic, bounds checkers, sanitizers and brace-initialisation.

Three kinds of "the standard does not say"

CategoryMeaningExample
Implementation-definedThe implementation chooses and documentssizeof(int), whether char is signed
UnspecifiedAny of several outcomes, not necessarily consistentthe order f(g(), h()) evaluates its arguments
UndefinedNo requirements at all — anything may happen, even before the operationsigned overflow, reading past an array

The first two are portability concerns. The third is a correctness concern: a program with undefined behaviour has no meaning, and the compiler produces whatever code follows from assuming the undefined operation is unreachable.

The catalogue

Signed integer overflow. INT_MAX + 1, INT_MIN - 1, INT_MIN / -1, INT_MIN % -1, 100000 * 100000 in int. Unsigned arithmetic wraps modulo 2ⁿ by definition; signed arithmetic has no defined result at all (Module 2, lesson 2).

Out-of-bounds access. a[n] on an array of n, v[v.size()], *end. The one-past-the-end pointer may be formed and compared, never dereferenced.

Uninitialised reads. int x; if (x > 0) reads an indeterminate value; the optimiser may treat x as whatever is convenient — sometimes as different values in two places.

Null and dangling dereference. *static_cast<int*>(nullptr); a reference to a local after its function returned; an iterator into a std::vector after a push_back reallocated (Module 13, lesson 6); a std::string_view of a temporary (Module 5, lesson 6).

Use after free and double free. Touching memory after delete, deleting twice, new[] paired with delete (Module 7).

Data races. Two threads touching one object without synchronisation, at least one writing (Module 17, lesson 2).

One-definition-rule violations. Two translation units defining the same inline function or class differently — undefined, no diagnostic required: the linker keeps one.

Strict aliasing. Reading a float through an int* (*reinterpret_cast<int*>(&f)); the compiler assumes an int* and a float* never alias. Use std::memcpy or C++20 std::bit_cast.

Unsequenced modification. i++ + i++ modifies i twice with no ordering — undefined. C++17 tightened the rules: function arguments are indeterminately sequenced (so f(i++, i++) is unspecified in order, no longer undefined) and an assignment's right side is sequenced before its left (so a[i] = i++ is defined). Neither is code to write.

Shifts. x << n with n negative or at least the width of x is undefined in every standard. Before C++20 a left shift of a negative value, or one whose result did not fit (1 << 31 in int), was undefined too; C++20 defines both modulo 2ⁿ, so 1 << 31 is now INT_MIN. 1 << 32 never was defined.

Integer division by zero. / 0 and % 0. Floating-point division by zero is defined — infinity or NaN.

Also on the list: modifying a string literal, writing through a const_cast to an object declared const, an infinite loop with no side effects, and using an object after its destructor ran.

Why the optimiser "deletes your check"

Undefined behaviour reaches backwards: a check written before the undefined operation can be removed because of it.

int first(int* p) {
    int v = *p;                  // dereferences p, so p cannot be null …
    if (p == nullptr) return -1; // … and this test is provably false: deleted
    return v;
}

The same reasoning removes an overflow test written in terms of the overflow:

bool willOverflow(int x) {
    return x + 1 < x;            // signed overflow cannot happen, so: always false
}

GCC and Clang both compile willOverflow to return false. The intent is fine; the test is phrased in an operation the compiler is allowed to assume never overflows. The fix is not a flag (-fwrapv and -ftrapv are diagnostic tools, not portable semantics) — it is to test before the operation, in terms that are themselves defined.

Detecting overflow in well-defined code

GCC and Clang provide checked-arithmetic builtins that compute the exact result when it fits and report when it does not, without performing the undefined operation:

int a = 2147483647;
int result = 0;
if (__builtin_add_overflow(a, 1, &result)) std::cout << "overflow\n";
else std::cout << result << '\n';

__builtin_sub_overflow and __builtin_mul_overflow are its siblings; each returns true on overflow. The portable alternative tests against std::numeric_limits first:

#include <limits>

bool checkedAdd(int a, int b, int& out) {
    constexpr int hi = std::numeric_limits<int>::max();
    constexpr int lo = std::numeric_limits<int>::min();
    if ((b > 0 && a > hi - b) || (b < 0 && a < lo - b)) return false;
    out = a + b;
    return true;
}

Every comparison there is on values that fit. For int operands a third route is to compute in long long, which holds any sum, difference or product of two ints, and compare with the int limits; it does not scale to long long operands, which is where the builtins earn their place. Division has one dangerous case besides zero — min / -1 — tested by hand.

Detecting out-of-bounds access

at() throws std::out_of_range (Module 15); operator[] checks nothing. A wrapper that returns std::optional reports a violation without exceptions and, by taking the index as a signed type, catches the negative index that would otherwise convert to a huge std::size_t:

std::optional<int> get(const std::vector<int>& v, long long i) {
    if (i < 0 || i >= static_cast<long long>(v.size())) return std::nullopt;
    return v[static_cast<std::size_t>(i)];
}

On libstdc++, compiling with -D_GLIBCXX_ASSERTIONS makes operator[] on every container check its bounds and abort with a message — cheap enough to leave on in debug builds.

Finding what you did not detect

  • -Wall -Wextra catch the mechanical cases: a possibly uninitialised local, size_t compared with int, a constant shift that is too wide, an always-true >= 0 on an unsigned.
  • UBSan (-fsanitize=undefined) prints runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' with the file and line, as it happens.
  • ASan (-fsanitize=address) reports out-of-bounds, use-after-free and double free with a stack trace (Module 7, lesson 6).
  • Brace-initialisation rejects narrowing at compile time, and int x{}; is never indeterminate.

The judge compiles with -O2 and no sanitizer: a solution with undefined behaviour may print the right answer on your machine and a different one there, which is why nothing in this track relies on it.

Pitfalls

WrittenWhat happens at -O2
if (x + 1 < x)Folded to false; the detection never runs
*p then if (p == nullptr)The null test is deleted
int total; then total += v[i]Indeterminate value; any result
for (std::size_t i = n - 1; i >= 0; --i)Wraps at 0; reads out of range
1 << 32Undefined whatever the standard
Testing for overflow after computing itToo late — the assumption already shaped the code

Key takeaways

  • Undefined behaviour is an operation with no requirements; the optimiser assumes it is unreachable and reasons backwards from that.
  • The catalogue: signed overflow, out-of-bounds, uninitialised reads, null and dangling dereference, use after free, data races, ODR violations, strict aliasing, unsequenced modifications, bad shifts, integer division by zero.
  • Test before the operation, in defined terms: __builtin_add_overflow and friends, std::numeric_limits comparisons, a wider type, an index checked against size().
  • Take an index from input as a signed value so a negative one is reported, not converted.
  • -Wall -Wextra, UBSan, ASan, _GLIBCXX_ASSERTIONS and brace-init find what your tests did not.

Common questions

Is signed integer overflow undefined behaviour in C++?

Yes. INT_MAX + 1, INT_MIN / -1 and 100000 * 100000 in int are undefined, so the compiler may assume they never happen; that is why if (x + 1 < x) is folded to false at -O2. Unsigned arithmetic is different: it wraps modulo 2ⁿ by definition.

How do I detect integer overflow in C++?

Test before the operation, in terms that cannot overflow themselves: compare against std::numeric_limits<int>::max() - b before adding, compute in a wider type such as long long, or use GCC and Clang's __builtin_add_overflow, __builtin_sub_overflow and __builtin_mul_overflow, which return true when the result does not fit.

Why does the compiler delete my null check?

If the code dereferences p before testing it, that dereference would be undefined behaviour for a null pointer, so the compiler concludes p cannot be null and removes the later if (p == nullptr) as always false. Undefined behaviour reaches backwards: put the check before the first use.

What is the difference between undefined, unspecified and implementation-defined behaviour?

Implementation-defined behaviour is chosen and documented by the compiler, such as sizeof(int). Unspecified behaviour is one of several allowed outcomes, not necessarily consistent, such as the order in which function arguments are evaluated. Undefined behaviour has no requirements at all, and a program containing it has no meaning.

How do I find undefined behaviour in C++ code?

Compile with -Wall -Wextra for the mechanical cases, run with UBSan (-fsanitize=undefined) to report overflow and bad shifts at the line where they happen, and with ASan (-fsanitize=address) for out-of-bounds access, use after free and double free. On libstdc++, -D_GLIBCXX_ASSERTIONS makes container operator[] check its bounds.

What is the strict aliasing rule in C++?

Strict aliasing lets the compiler assume that pointers to unrelated types, such as int* and float*, never point at the same object, so reading a float through an int* is undefined behaviour. Copy the bytes with std::memcpy or C++20's std::bit_cast instead.

Mark as readSign in to keep your place.

Undefined Behavior in C++: Examples and How to Detect It

Signed overflow, out-of-bounds reads, dangling pointers and data races are undefined behaviour in C++. Why -O2 deletes checks, and finding UB with sanitizers.

  • Course: C++ study plan
  • Module: Performance and undefined behaviour
  • Kind: Lesson
  • Reading time: 15 min
  • Runtime: Clang 18 · C++20

What is undefined behaviour in C++?

Undefined behaviour (UB) in C++ is an operation the standard imposes no requirements on — signed integer overflow, reading past an array, dereferencing a null or dangling pointer, a data race. The compiler may assume it never happens and optimise on that assumption, so a program with UB can print the right answer, crash, or silently lose a check, and behave differently at -O2 or on another machine.

Lesson

Undefined behaviour is the contract at the centre of C++: the standard lists operations it "imposes no requirements" on, and in exchange the compiler may assume they never happen. That assumption is what makes -O2 fast — a loop over a signed index needs no wrap-around check because signed overflow cannot occur — and it is what makes a program with undefined behaviour unpredictable rather than merely wrong. This lesson catalogues the operations that are undefined, shows how the optimiser turns each into a deleted check, and settles the well-defined tools for detecting them: checked arithmetic, bounds checkers, sanitizers and brace-initialisation.

Three kinds of "the standard does not say"

CategoryMeaningExample
Implementation-definedThe implementation chooses and documentssizeof(int), whether char is signed
UnspecifiedAny of several outcomes, not necessarily consistentthe order f(g(), h()) evaluates its arguments
UndefinedNo requirements at all — anything may happen, even before the operationsigned overflow, reading past an array

The first two are portability concerns. The third is a correctness concern: a program with undefined behaviour has no meaning, and the compiler produces whatever code follows from assuming the undefined operation is unreachable.

The catalogue

Signed integer overflow. INT_MAX + 1, INT_MIN - 1, INT_MIN / -1, INT_MIN % -1, 100000 * 100000 in int. Unsigned arithmetic wraps modulo 2ⁿ by definition; signed arithmetic has no defined result at all (Module 2, lesson 2).

Out-of-bounds access. a[n] on an array of n, v[v.size()], *end. The one-past-the-end pointer may be formed and compared, never dereferenced.

Uninitialised reads. int x; if (x > 0) reads an indeterminate value; the optimiser may treat x as whatever is convenient — sometimes as different values in two places.

Null and dangling dereference. *static_cast<int*>(nullptr); a reference to a local after its function returned; an iterator into a std::vector after a push_back reallocated (Module 13, lesson 6); a std::string_view of a temporary (Module 5, lesson 6).

Use after free and double free. Touching memory after delete, deleting twice, new[] paired with delete (Module 7).

Data races. Two threads touching one object without synchronisation, at least one writing (Module 17, lesson 2).

One-definition-rule violations. Two translation units defining the same inline function or class differently — undefined, no diagnostic required: the linker keeps one.

Strict aliasing. Reading a float through an int* (*reinterpret_cast<int*>(&f)); the compiler assumes an int* and a float* never alias. Use std::memcpy or C++20 std::bit_cast.

Unsequenced modification. i++ + i++ modifies i twice with no ordering — undefined. C++17 tightened the rules: function arguments are indeterminately sequenced (so f(i++, i++) is unspecified in order, no longer undefined) and an assignment's right side is sequenced before its left (so a[i] = i++ is defined). Neither is code to write.

Shifts. x << n with n negative or at least the width of x is undefined in every standard. Before C++20 a left shift of a negative value, or one whose result did not fit (1 << 31 in int), was undefined too; C++20 defines both modulo 2ⁿ, so 1 << 31 is now INT_MIN. 1 << 32 never was defined.

Integer division by zero. / 0 and % 0. Floating-point division by zero is defined — infinity or NaN.

Also on the list: modifying a string literal, writing through a const_cast to an object declared const, an infinite loop with no side effects, and using an object after its destructor ran.

Why the optimiser "deletes your check"

Undefined behaviour reaches backwards: a check written before the undefined operation can be removed because of it.

int first(int* p) {
    int v = *p;                  // dereferences p, so p cannot be null …
    if (p == nullptr) return -1; // … and this test is provably false: deleted
    return v;
}

The same reasoning removes an overflow test written in terms of the overflow:

bool willOverflow(int x) {
    return x + 1 < x;            // signed overflow cannot happen, so: always false
}

GCC and Clang both compile willOverflow to return false. The intent is fine; the test is phrased in an operation the compiler is allowed to assume never overflows. The fix is not a flag (-fwrapv and -ftrapv are diagnostic tools, not portable semantics) — it is to test before the operation, in terms that are themselves defined.

Detecting overflow in well-defined code

GCC and Clang provide checked-arithmetic builtins that compute the exact result when it fits and report when it does not, without performing the undefined operation:

int a = 2147483647;
int result = 0;
if (__builtin_add_overflow(a, 1, &result)) std::cout << "overflow\n";
else std::cout << result << '\n';

__builtin_sub_overflow and __builtin_mul_overflow are its siblings; each returns true on overflow. The portable alternative tests against std::numeric_limits first:

#include <limits>

bool checkedAdd(int a, int b, int& out) {
    constexpr int hi = std::numeric_limits<int>::max();
    constexpr int lo = std::numeric_limits<int>::min();
    if ((b > 0 && a > hi - b) || (b < 0 && a < lo - b)) return false;
    out = a + b;
    return true;
}

Every comparison there is on values that fit. For int operands a third route is to compute in long long, which holds any sum, difference or product of two ints, and compare with the int limits; it does not scale to long long operands, which is where the builtins earn their place. Division has one dangerous case besides zero — min / -1 — tested by hand.

Detecting out-of-bounds access

at() throws std::out_of_range (Module 15); operator[] checks nothing. A wrapper that returns std::optional reports a violation without exceptions and, by taking the index as a signed type, catches the negative index that would otherwise convert to a huge std::size_t:

std::optional<int> get(const std::vector<int>& v, long long i) {
    if (i < 0 || i >= static_cast<long long>(v.size())) return std::nullopt;
    return v[static_cast<std::size_t>(i)];
}

On libstdc++, compiling with -D_GLIBCXX_ASSERTIONS makes operator[] on every container check its bounds and abort with a message — cheap enough to leave on in debug builds.

Finding what you did not detect

  • -Wall -Wextra catch the mechanical cases: a possibly uninitialised local, size_t compared with int, a constant shift that is too wide, an always-true >= 0 on an unsigned.
  • UBSan (-fsanitize=undefined) prints runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' with the file and line, as it happens.
  • ASan (-fsanitize=address) reports out-of-bounds, use-after-free and double free with a stack trace (Module 7, lesson 6).
  • Brace-initialisation rejects narrowing at compile time, and int x{}; is never indeterminate.

The judge compiles with -O2 and no sanitizer: a solution with undefined behaviour may print the right answer on your machine and a different one there, which is why nothing in this track relies on it.

Pitfalls

WrittenWhat happens at -O2
if (x + 1 < x)Folded to false; the detection never runs
*p then if (p == nullptr)The null test is deleted
int total; then total += v[i]Indeterminate value; any result
for (std::size_t i = n - 1; i >= 0; --i)Wraps at 0; reads out of range
1 << 32Undefined whatever the standard
Testing for overflow after computing itToo late — the assumption already shaped the code

Key takeaways

  • Undefined behaviour is an operation with no requirements; the optimiser assumes it is unreachable and reasons backwards from that.
  • The catalogue: signed overflow, out-of-bounds, uninitialised reads, null and dangling dereference, use after free, data races, ODR violations, strict aliasing, unsequenced modifications, bad shifts, integer division by zero.
  • Test before the operation, in defined terms: __builtin_add_overflow and friends, std::numeric_limits comparisons, a wider type, an index checked against size().
  • Take an index from input as a signed value so a negative one is reported, not converted.
  • -Wall -Wextra, UBSan, ASan, _GLIBCXX_ASSERTIONS and brace-init find what your tests did not.

Common questions

Is signed integer overflow undefined behaviour in C++?

Yes. INT_MAX + 1, INT_MIN / -1 and 100000 * 100000 in int are undefined, so the compiler may assume they never happen; that is why if (x + 1 < x) is folded to false at -O2. Unsigned arithmetic is different: it wraps modulo 2ⁿ by definition.

How do I detect integer overflow in C++?

Test before the operation, in terms that cannot overflow themselves: compare against std::numeric_limits<int>::max() - b before adding, compute in a wider type such as long long, or use GCC and Clang's __builtin_add_overflow, __builtin_sub_overflow and __builtin_mul_overflow, which return true when the result does not fit.

Why does the compiler delete my null check?

If the code dereferences p before testing it, that dereference would be undefined behaviour for a null pointer, so the compiler concludes p cannot be null and removes the later if (p == nullptr) as always false. Undefined behaviour reaches backwards: put the check before the first use.

What is the difference between undefined, unspecified and implementation-defined behaviour?

Implementation-defined behaviour is chosen and documented by the compiler, such as sizeof(int). Unspecified behaviour is one of several allowed outcomes, not necessarily consistent, such as the order in which function arguments are evaluated. Undefined behaviour has no requirements at all, and a program containing it has no meaning.

How do I find undefined behaviour in C++ code?

Compile with -Wall -Wextra for the mechanical cases, run with UBSan (-fsanitize=undefined) to report overflow and bad shifts at the line where they happen, and with ASan (-fsanitize=address) for out-of-bounds access, use after free and double free. On libstdc++, -D_GLIBCXX_ASSERTIONS makes container operator[] check its bounds.

What is the strict aliasing rule in C++?

Strict aliasing lets the compiler assume that pointers to unrelated types, such as int* and float*, never point at the same object, so reading a float through an int* is undefined behaviour. Copy the bytes with std::memcpy or C++20's std::bit_cast instead.

Exercises

A checked-arithmetic evaluator

Evaluate int expressions without ever performing an operation that could overflow. Read n, then n lines of the form a op b, where op is one of + - * / % and both operands fit in an int. Compute the result in int through the checked builtins — __builtin_add_overflow(a, b, &result), __builtin_sub_overflow and __builtin_mul_overflow each store the result through their third argument and return true when it did not fit — and test the two dangerous division cases by hand before dividing: a zero divisor, and std::numeric_limits<int>::min() divided by -1 (which also overflows for %).

Print a op b = result, or a op b = overflow, or a op b = division by zero. After the last line print flagged k of n, where k counts the lines that produced no result. The scaffold reads each operand into a long long and casts it to int — the operands fit, and -2147483648 reads cleanly that way.

Input: n, then n lines a op b with the three tokens separated by spaces. Output: n result lines, then the summary.

4
2147483647 + 1
100000 * 100000
-2147483648 - 1
7 / 2

prints

2147483647 + 1 = overflow
100000 * 100000 = overflow
-2147483648 - 1 = overflow
7 / 2 = 3
flagged 3 of 4

A bounds-checked array

Write Checked, a wrapper around a std::vector<int> that reports an out-of-range index instead of committing it. get(long long i) returns std::optional<int> — empty when i is not in [0, size()) — and set(long long i, int value) returns false on the same test. The index parameter must be a signed long long: an index read from input can be negative or larger than any container, and both must be reported rather than converted to a std::size_t that happens to be huge. Only after the check may you cast the index to std::size_t and subscript.

main reads n and n integers into the wrapper, then q commands: get i prints a[i] = value or a[i] out of range; set i v prints a[i] <- v or a[i] out of range. After the commands print violations k (the out-of-range count) and final: followed by the elements, one space before each.

Input: n, then n integers, then q, then q commands. Output: one line per command, then the two summary lines.

3
10 20 30
5
get 0
get 3
set 1 99
get -1
get 1

prints

a[0] = 10
a[3] out of range
a[1] <- 99
a[-1] out of range
a[1] = 99
violations 2
final: 10 99 30

In this module: Performance and undefined behaviour

← Checkpoint — Streams and files · The cost model — counting copies, allocations and calls →