C++ / ERRORS, EXCEPTIONS, AND UNDEFINED BEHAVIOUR
Undefined behaviour and how to recognise it
Spot the operations in C++ that carry preconditions, guard them before they run, and tell undefined behaviour apart from unspecified behaviour.
What you will learn
- Name the precondition of each operation: overflow, shift width, index, lifetime
- Write guards that run before the operation: a <= INT_MAX - b, not a + b < a
- Separate undefined from unspecified and implementation-defined behaviour
- Build a second config with -fsanitize=address,undefined and run the tests there
Understanding Undefined behaviour and how to recognise it
The standard describes what a program does only for operations whose preconditions hold: a + b on ints requires the true result to fit in an int, v[i] requires i < v.size(), *p requires p to point at a live object of that type. Violate one and you do not get a wrong value for that one operation, you get an execution with no described behaviour at all, including the parts that already ran. That asymmetry is why undefined behaviour is so hard to recognise from symptoms: the crash, the garbled string, or the missing output can sit arbitrarily far from the line that broke the rule.
The compiler is not being spiteful when UB turns into strange results; it is using the one thing the standard hands it, permission to assume every operation is valid. That assumption is what lets it keep a 32-bit loop counter in a 64-bit register, hoist a load out of a loop, or delete a branch reachable only after signed overflow. So the practical effect of UB is not a diagnostic but a program that means one thing at -O0 and another at -O2, and a check written after the fact, such as if (a + b < a), can be removed precisely because it tests for something the compiler already assumed cannot occur.
Recognising UB is a reading habit built on a short list rather than a wait for a warning. Signed overflow, shifting by a count outside [0, width), division or remainder by zero and INT_MIN / -1, indexing past the end, indirection through a null or dangling pointer, reading an uninitialised scalar, accessing an object through an incompatible type, unsynchronised access from two threads, and falling off the end of a value-returning function cover nearly everything you will meet in ordinary code. Keep that distinct from unspecified behaviour, where the implementation picks one of several valid options such as the order in which the operands of + are evaluated, and from implementation-defined behaviour, which is a documented choice such as sizeof(int) or whether char is signed: those two hand you a value you may not have predicted, while UB leaves you nothing to reason from.
<climits>
<iostream>
<stdexcept>
<vector>
// Every operation below has a precondition. Each check is written so that it
// never performs the operation it is guarding.
bool safe_to_add(int a, int b) {
if (b > 0) return a <= INT_MAX - b; // INT_MAX - b cannot overflow
if (b < 0) return a >= INT_MIN - b; // INT_MIN - b cannot overflow
return true;
}
bool safe_to_shift(int value_bits, int amount) {
return amount >= 0 && amount < value_bits;
}
int main() {
const int int_bits = static_cast<int>(sizeof(int) * CHAR_BIT);
const std::vector<int> v{10, 20, 30};
std::cout << std::boolalpha;
std::cout << "2000000000 + 2000000000 defined? " << safe_to_add(2000000000, 2000000000) << '\n';
std::cout << "2000000000 + -2000000000 defined? " << safe_to_add(2000000000, -2000000000) << '\n';
std::cout << "1 << 32 defined? " << safe_to_shift(int_bits, 32) << '\n';
std::cout << "v[3] with size 3 defined? " << (3u < v.size()) << '\n';
try {
std::cout << v.at(3) << '\n';
} catch (const std::out_of_range&) {
std::cout << "at(3) reported the violation; v[3] would not have\n";
}
}
Undefined behaviour withdraws every guarantee about the whole program, so it must be prevented by checking an operation's preconditions before performing it, never detected afterwards.
Worked examples
Lifetime UB you can see coming
Shows that a pointer into a vector is valid only until the next reallocation, which is a precondition you can check by watching capacity.
<cstdint>
<iostream>
<vector>
int main() {
std::vector<int> v{1, 2, 3};
// Freeze the address as an integer while the buffer is still alive: once the
// old buffer is freed, even inspecting the stale pointer value is not
// well defined, but an integer copied out of it stays inspectable.
const std::uintptr_t old_buffer = reinterpret_cast<std::uintptr_t>(v.data());
const std::size_t old_capacity = v.capacity();
while (v.capacity() == old_capacity) {
v.push_back(0); // reallocates once size would exceed capacity
}
std::cout << std::boolalpha;
std::cout << "buffer moved: "
<< (reinterpret_cast<std::uintptr_t>(v.data()) != old_buffer) << '\n';
std::cout << "capacity grew: " << (v.capacity() > old_capacity) << '\n';
std::cout << "values preserved: " << (v[0] == 1 && v[1] == 2 && v[2] == 3) << '\n';
}
Example explained
Line 1v.data() names a buffer whose lifetime ends at the next reallocation, so the address is copied into a uintptr_t rather than kept as a pointer.
Line 2push_back reallocates when size would pass capacity: it allocates a new buffer, moves the elements, then frees the old one, which is why the two addresses must differ.
Line 3The values survive but a saved pointer does not follow them, so reading through a pointer taken before the loop would be UB with no visible symptom on most runs.
Line 4This is the reason the rule is phrased as "reallocation invalidates all pointers, references, and iterators" instead of "sometimes crashes".
Reading the bits of a float without punning
Contrasts a defined byte-level reinterpretation with the reinterpret_cast version that violates the type-access rule and produces no runtime symptom.
<cstdint>
<cstring>
<iostream>
static_assert(sizeof(float) == sizeof(std::uint32_t), "needs a 32-bit float");
int main() {
const float f = 1.5f;
std::uint32_t bits = 0;
std::memcpy(&bits, &f, sizeof bits); // defined: copies the object representation
std::cout << std::hex << std::showbase << bits << '\n';
float back = 0.0f;
std::memcpy(&back, &bits, sizeof back);
std::cout << std::dec << back << '\n';
// std::uint32_t bad = *reinterpret_cast<const std::uint32_t*>(&f);
// Undefined: that reads a float object through a uint32_t lvalue. It
// compiles, it usually prints the same number, and the optimiser is still
// free to move the read across unrelated float stores.
}
Example explained
Line 1std::memcpy is specified in terms of bytes, so it never claims the float object is a uint32_t and no type-access rule is broken.
Line 21.5f is 0x3fc00000 in IEEE-754 binary32: sign 0, biased exponent 127, mantissa bit for the 0.5.
Line 3Copying back proves the round trip is exact; the punned read would usually print the same value, which is exactly what makes it survive testing.
Line 4In C++20, std::bit_cast<std::uint32_t>(f) expresses the same conversion and works in constant expressions.
Important notes
Sanitizers only report UB on code paths a run actually executes, so a branch your tests never take stays silent; -fsanitize supplements reading the code, it does not replace it.
Unsigned arithmetic wraps by definition, so i - 1 with unsigned i == 0 is not UB, but the enormous value it produces makes v[i - 1] on the next line undefined.
Common mistakes
Detecting overflow after it happens with if (a + b < a) or if (i + 1 < i). The overflow is itself UB, so an optimising build may fold the test to false and delete the branch, and the guard silently stops existing at -O2.
Treating a plausible printed value as proof of correctness. v[3] on a three-element vector normally prints a number and exits with status 0, so the out-of-range read passes review and only corrupts memory later when the allocation layout changes.
Assuming int wraps like unsigned, or that shifting a 32-bit int by 32 yields 0. Those are habits from one CPU; the standard promises no result, and GCC and Clang optimise loop bounds on the assumption that neither ever occurs.
Try it yourself
Change, predict, then run
Write int midpoint(int low, int high) that computes the average without letting any intermediate value leave the range of int, then print midpoint(2000000000, 2100000000) and confirm it is 2050000000, which the naive (low + high) / 2 cannot deliver.
Open the C++ workspaceCheck your understanding
A function contains int i = read_value(); if (i + 1 < i) return handle_overflow();. At -O0 the branch is sometimes taken, at -O2 it never is. What explains this?
- -O2 keeps i in a 64-bit register, so the addition no longer overflows and the check becomes unnecessary.
- Signed overflow has no defined result, so the optimiser may assume i + 1 > i always holds and delete the branch.
- The comparison is evaluated before the addition, and that evaluation order changed between optimisation levels.
- i + 1 wraps to INT_MIN, and comparing INT_MIN with a positive int is implementation-defined.
Show answer
Signed addition carries the precondition that its true result is representable; once that is violated the standard imposes no requirements, so the compiler may treat i + 1 < i as impossible and drop the branch as dead code. The check has to be written before the addition, as i == INT_MAX. The 64-bit register answer is tempting because register width really does change what wraparound looks like in practice, but width is a code-generation detail: the branch vanishes because of the assumption the optimiser is entitled to make, not because the arithmetic got wider.