Foundations · Module 3 · Lesson 2 of 10
Out-of-Bounds Access and Undefined Behavior
See what happens in memory when an index leaves its array, learn what undefined behavior means and why compilers may exploit it, and use warnings and sanitizers to catch these bugs.
By the end you can
- Explain what happens in memory when an index goes past the end of an array
- Define undefined behavior and distinguish it from implementation-defined behavior
- Explain why a program that triggers undefined behavior can change under optimization
- Choose the flags and tools that detect out-of-bounds access
Builds on Arrays: Contiguous Elements and Indexing.
01 · A question first
Write one element past the end of an array and Python raises an error, but C++ stays silent. The program might crash, might print a wrong value three functions later, or might work perfectly until the day you show it to someone. Why does the language allow this, and how do you catch it?
Make a guess, then step through the trace below to check it.
02 · Watch it run
int a[3] = {1, 2, 3};int guard = 7;a[3] = 0;std::cout << guard;- int 0a[0]: 1
- int 1a[1]: 2
- int 2a[2]: 3
- int 3guard: 7
Here is one possible layout: the three elements of a and, directly after them, the int guard holding 7. The cells are ints this time, not bytes. The language does not promise this layout, as lesson 3 said, but compilers often produce something like it, and it makes the damage visible.
Machine work: a and guard sit next to each other in the stack frame
03 · The idea
Start with the problem
Step 4 of the last lesson read an element that did not exist, and nothing complained. In Python or Java the same mistake raises an exception at once. In C++ the program carries on. Take the smallest possible example, an array of three elements and a write to index 3:
int a[3] = {1, 2, 3};
a[3] = 0;What does this do? The honest answer is "anything", and the language has a precise name for that: undefined behavior. This lesson explains what it means, why C++ allows it, and how to find it before it finds you.
How the idea works
Out of bounds. For an array of n elements the valid indexes are 0 to n - 1. An access outside that range is out of bounds. A read returns whatever bytes sit at that address: a neighboring variable, padding, stack data, or, if the address is far away, unmapped memory, which stops the program with a segmentation fault. A write overwrites whatever is there: a neighbor, as in step 2 of the trace, saved registers, or even the return address from lesson 6, which is how stack smashing attacks work.
What undefined behavior means. The standard lists operations for which it places no requirements on the program. Out-of-bounds access is one. Others you have met are signed integer overflow, reading an uninitialized variable, and dividing by zero. Later lessons add dereferencing a null pointer and using memory after it was freed. This is not "an unpredictable result". It means the program is no longer covered by any promise, so every outcome is allowed: a crash, a wrong value, an apparently correct run, or behavior that changes with the compiler and its flags.
Two weaker categories. Do not confuse undefined behavior with two other terms. Implementation-defined behavior is a choice that your compiler must document, such as the size of an int. Unspecified behavior is one of several valid outcomes with no documentation required, such as which of two function arguments is evaluated first. Undefined behavior is different in kind: the program has no valid outcomes at all.
Why the language allows it. C++ follows the rule that you do not pay for what you do not use. A bounds check on every access would cost a comparison and a branch each time, which is unacceptable in code that touches arrays billions of times. So the language trusts you. It also lets the compiler assume that undefined behavior never happens, and optimize on that basis. A well-known case, from lesson 4: because signed overflow is undefined, the compiler may turn x + 1 > x into true for every x, even when x is the largest int.
Symptoms. The best symptom is an immediate crash. The worse ones are silent corruption, a wrong value far from the cause (steps 3 and 4), and the program that works on your machine and in a debug build and fails somewhere else or with -O2.
Finding it. Use several layers:
- Warnings:
-Wall -Wextracatch some constant out-of-range indexes, and GCC's-Warray-boundsdoes so more often with optimization on. - Sanitizers: AddressSanitizer reports the first bad memory access with a line number, and UndefinedBehaviorSanitizer reports things like signed overflow.
- Checked access:
std::vector::at(i)throws an exception on a bad index, and the next lesson covers it. In libstdc++, defining_GLIBCXX_ASSERTIONSadds cheap bounds checks to the ordinaryoperator[].
g++ -std=c++20 -Wall -Wextra -g -fsanitize=address,undefined main.cpp -o mainA report from the sanitizer has this shape:
ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 4 at 0x7ffc2b3a9e5c thread T0
#0 0x55d1c3 in main main.cpp:3Why it is correct
The rule is a contract between you and the compiler. The compiler promises that the code it generates behaves as the language specifies, but only for programs that never trigger undefined behavior. For such programs it may reorder, merge and delete code freely, which is where much of C++'s speed comes from. A program that does trigger it is outside the contract, and the compiler is not required to preserve anything. That is why "it worked when I tested it" proves nothing: the test may have been lucky with the layout.
The alternative, checking everything all the time, is available when you want it. It is not the default. Standard library vendors now offer hardened modes that turn some of these cases into controlled failures, and safer container access is a choice you can make everywhere.
What it costs
The bugs. Undefined behavior is expensive to debug, because the symptom is detached from the cause. It is also expensive to leave alone: reports from large software vendors put memory-safety bugs at around 70 percent of their serious security vulnerabilities. The Morris worm of 1988 used a buffer overflow, and the Heartbleed bug of 2014 was an out-of-bounds read.
The checks. A bounds check costs a comparison and a well-predicted branch, often a few percent of run time in ordinary code. AddressSanitizer is heavier, typically about twice as slow and using two to three times the memory, so you use it in testing and in continuous integration, not in the shipped program.
Pitfalls and variations
Trusting a successful run. A program that runs correctly has not proved that it is free of undefined behavior.
Off-by-one loops. The loop bound of lesson 4 is the most common source: i <= n instead of i < n.
Negative and computed indexes. a[-1] is out of bounds, and so is an index computed from input that was never validated. Check 0 <= i && i < n before using a value from outside the program, as in module 1's input lesson.
Testing in one build only. Undefined behavior can behave differently at -O0 and -O2, so run your tests in both, and with sanitizers on.
Strings. Reading past the terminating zero of a string is an out-of-bounds read. Lesson 5 returns to it.
Variations. std::array::at and std::vector::at give checked access. std::span, from C++20, carries a pointer and a size together. Static analyzers such as clang-tidy and cppcheck find some of these bugs without running the program, and fuzzers feed random input to a sanitized build to find the rest.
Where it is used
This knowledge is the foundation of safe systems programming. Teams run sanitizers in their continuous integration, fuzz their parsers, and enable the library's hardened modes in production. The same understanding explains why newer languages that check bounds by default are attractive for some projects, and why C++ projects still depend on discipline and tooling.
The next lesson, "std::array and std::vector", introduces containers that know their own size and can offer checked access, while keeping the contiguous layout you now understand.
04 · Prove it
Question 1 of 4 · answer from memory
`int a[3] = {1, 2, 3};` is followed by `a[3] = 0;`. Which statement is true?
05 · Practice
Asked in interviews
Something wrong with this lesson? Report a problem