As-If Rule
The compiler may transform code in any way that preserves the observable behavior of the abstract machine, enabling aggressive optimization.
As-If Rulesince C++98A conforming C++ implementation may transform, reorder, or eliminate any code as long as the observable behavior of the resulting program is identical to that of the abstract machine executing the original source.
Overview
The C++ standard does not specify machine code β it specifies the behavior of an abstract machine. The as-if rule, stated at [intro.abstract] in the standard, is the license that frees compilers from literal execution of that abstract machine: any transformation is permissible provided the program's observable behavior remains intact.
This is not a narrow permission. It is the foundation on which every modern C++ optimizer stands. Dead-store elimination, loop unrolling, inlining, constant folding, instruction reordering, common subexpression elimination β all are justified by the as-if rule. A compiler that faithfully executed every source statement in written order would be nearly useless for performance-critical code.
What Counts as Observable
The standard defines observable behavior with precision. Only three categories of effects are protected:
- Volatile accesses. Reads from and writes to
volatile-qualified objects must occur in the order specified by the source, with no additions, elisions, or reorderings across sequence points. - I/O and interactive devices. Prompting text sent to interactive output must be flushed before the program waits for input on those same devices.
- Floating-point environment (when
#pragma FENV_ACCESS ONis active). Changes to floating-point exception flags and rounding modes must be visible to subsequent floating-point operations as if no optimization had occurred. The count and order of individual exceptions may still change, as long as the state observed by the next floating-point operation is correct.
Everything else β register contents, stack layout, heap fragmentation, the number of constructor invocations on temporaries, the order of intermediate arithmetic, instruction count β belongs entirely to the implementation.
Examples
Dead Store and Redundant Computation Elimination
volatile int sink;
int expensive(); // external, unknown side effects
void example() {
int scratch = expensive(); // may be called β has unknown effects
int local = 42 * 7; // C++98: no observable effect, folded at compile time
local = 0; // dead store: local never read after this point
sink = 1; // volatile write β MUST occur
}The write to local after it is last read is removed entirely. The write to sink is mandatory. The call to expensive() is retained because the compiler cannot prove it has no observable effects (I/O, volatile access, etc.).
Constant Folding Across Call Boundaries
// C++98: inline gives the compiler latitude to substitute
inline int square(int x) { return x * x; }
int g = square(7); // 49 appears directly in the binary; the call may not existSince C++11, constexpr formalises this β evaluation at compile time is guaranteed for constant arguments:
constexpr int cube(int x) { return x * x * x; } // C++11
static_assert(cube(3) == 27, ""); // C++11: evaluated entirely at compile timeReordering and Fusion
// volatile input prevents the compiler folding the entire program away
volatile int input = 7;
volatile int result;
int& preinc(int& n) { return ++n; }
int add(int a, int b) { return a + b; }
int main() {
int n = input;
// Note: ++n + ++n on built-in int is UB (unsequenced modification).
// Routing through named functions sequences the calls, making this well-defined.
int m = add(preinc(n), preinc(n)); // n incremented twice: 7β8β9; m = 8+9 = 17
result = m;
}A typical x86-64 compiler reduces this to three instructions: read input, compute 2 * input + 3, write result. Both calls and both increments disappear. Observable behavior β the final volatile write β is preserved.
The Copy Elision Exception
Copy elision (RVO and NRVO) is an explicit, named exception to the as-if rule. Compilers are permitted to elide copy and move constructor calls β along with the corresponding destructor calls β even when those constructors have observable side effects.
struct Tracer {
Tracer() { std::puts("default"); }
Tracer(const Tracer&) { std::puts("copy"); }
Tracer(Tracer&&) { std::puts("move"); } // C++11
~Tracer() { std::puts("~Tracer"); }
};
Tracer make() {
return Tracer{};
}
int main() {
Tracer t = make();
// C++17 guaranteed copy elision: prints only "default" and "~Tracer".
// Pre-C++17: compilers were permitted (not required) to elide the moves.
}C++17 made this mandatory for prvalues: in Tracer t = make(), no temporary object exists β the constructor is called directly into t. Prior standards permitted elision but could not guarantee it. Code that depends on the count of constructor calls for correctness (not diagnostics) was fragile before C++17 and should still be avoided after it.
Interaction with Undefined Behavior
The as-if rule's guarantee presupposes a well-formed program with defined behavior. When undefined behavior is present, the rule's precondition fails: the abstract machine has no single defined execution sequence to preserve, so the implementation may produce any output.
This has counterintuitive consequences in practice:
// Signed integer overflow is UB in all C++ standards.
bool will_overflow(int x) {
return x + 1 > x; // UB when x == INT_MAX
}A compiler may constant-fold this to return true;. For every defined value of int, x + 1 > x is mathematically certain β so the branch that was intended to detect overflow is silently deleted. Programs that appear correct at -O0 but fail at -O2 almost always contain UB that the as-if rule permits the optimizer to exploit.
// Correct overflow detection:
#include <climits>
bool will_overflow(int x) {
return x == INT_MAX;
}
// Or with C++20 standard library:
#include <numeric>
// std::add_sat, std::cmp_equal in <numeric>/<utility> for saturating/checked arithmeticMultithreading and the As-If Rule
The as-if rule applies per-thread. Within a single thread, the compiler may freely reorder memory operations provided that thread's observable behavior is unchanged. Across threads, the C++11 memory model ([atomics], [thread]) defines which operations establish synchronization boundaries that the as-if rule cannot cross.
#include <atomic> // C++11
std::atomic<bool> ready{false}; // C++11
int data = 0;
// Thread A:
void producer() {
data = 42;
ready.store(true, std::memory_order_release); // C++11
}
// Thread B:
void consumer() {
while (!ready.load(std::memory_order_acquire)) {} // C++11
assert(data == 42); // guaranteed: happens-before established
}The release/acquire pair prevents the compiler from hoisting the data = 42 write past the store to ready. Without the atomic, the as-if rule would permit that reordering, and the consumer could observe ready == true with data == 0.
Without synchronization, data races are themselves undefined behavior β meaning the as-if rule's guarantee disappears entirely, not just the ordering guarantee.
Common Pitfalls
Treating volatile as a threading primitive. Volatile guarantees ordered access within a single thread of execution. It makes no guarantee about visibility across threads. Use std::atomic<T> (C++11) for inter-thread communication.
Relying on constructor side effects of temporaries. Because copy elision is permitted (pre-C++17) or mandated (C++17 for prvalues), code whose correctness depends on a specific number of constructor or destructor invocations is unreliable. Diagnostics are fine; logic must not be.
Signed integer "wraparound" assumptions. Signed overflow is UB; the optimizer is entitled to assume it never happens. Use unsigned arithmetic where wraparound semantics are needed, or use compiler-specific builtins (__builtin_add_overflow) / C++26 std::add_overflow.
Expecting zeroed bytes after a move. A moved-from object is left in a valid but unspecified state (C++11). The implementation is not required to write zeros to the source; doing so would be a dead store in many cases, and the as-if rule allows it to be dropped.
Optimizer Transformations Enabled by the As-If Rule
The as-if rule is not a single optimization β it is the legal foundation that lets the optimizer apply an entire pipeline of independent passes without asking for explicit permission at each step. Each pass below preserves observable behavior while discarding or rearranging everything the standard does not require to remain.
Constant Folding and Constant Propagation
The compiler evaluates constant expressions at compile time and substitutes their values everywhere they appear. It then propagates those constants through subsequent operations, often allowing more folding to occur.
// Source
int width = 1920;
int height = 1080;
int pixels = width * height; // 2,073,600
int bytes = pixels * 4; // 8,294,400
// After constant folding + propagation: the compiler emits
// the literal 8294400 directly. No multiplication at runtime.Because none of width, height, pixels, or bytes are volatile, and none are ever read by observable I/O, all four variables and all three multiplications may be eliminated entirely if bytes is never used in an observable context.
Common Subexpression Elimination (CSE)
If the same computation appears more than once and neither its inputs nor any side effects change between the two occurrences, the compiler computes it once and reuses the result.
volatile int out;
int f(int a, int b) {
// (a * b) appears twice; computed once, stored in a register
int lo = (a * b) & 0xFF;
int hi = (a * b) >> 8;
out = lo + hi;
return lo;
}On x86-64 at -O2, imul edi, esi appears exactly once in f's body. The second apparent multiplication disappears β observable behavior (the volatile write and the return value) is unchanged.
Dead Store Elimination
A write to a non-volatile variable that is overwritten before the next read, or that is never read at all, is a dead store. The compiler is free to remove it.
void process(int* data, int n) {
int tmp = 0; // dead store if tmp is overwritten before use
for (int i = 0; i < n; ++i) {
tmp = data[i]; // each iteration overwrites tmp; all but the last are dead
}
// tmp never used after the loop β the entire loop may be eliminated
// if data has no volatile elements and the loop has no other observable effects
}Contrast this with a version where tmp is volatile int tmp or where its final value is written to a file or global: the compiler must then perform every write in program order.
Loop Vectorization (Auto-Vectorization)
When a loop body contains no data dependences across iterations, no calls with unknown side effects, and no volatile accesses, the compiler may replace the scalar loop with SIMD instructions that process multiple elements per clock cycle.
void add_arrays(float* __restrict__ dst,
const float* __restrict__ src,
int n) {
for (int i = 0; i < n; ++i)
dst[i] += src[i];
}With GCC or Clang at -O2 on x86-64, this loop becomes a sequence of vaddps (AVX) or addps (SSE) instructions operating on 8 or 4 floats at once. The result for every element is identical to the scalar version β observable behavior is preserved β but the instruction count shrinks by a factor of 4β8.
The __restrict__ annotation (a compiler extension, not standard C++) tells the compiler that dst and src do not alias, which is necessary for the optimizer to prove the absence of cross-iteration dependences. The standard equivalent is to ensure the arrays genuinely do not overlap; the compiler may still vectorize if it can prove non-aliasing from the types alone.
Strength Reduction
The compiler replaces expensive operations with cheaper equivalents that produce the same result.
// Source: multiply inside a loop
for (int i = 0; i < n; ++i)
result += i * stride;
// After strength reduction: replace multiply with repeated add
// int acc = 0;
// for (int i = 0; i < n; ++i) { result += acc; acc += stride; }
// (then potentially further unrolled or vectorized)Integer division by a compile-time constant is similarly replaced by a multiply-and-shift sequence that is several times faster on all modern architectures.
The Assembly Picture
The cppreference canonical example (reproduced here for reference) shows the full effect of several passes combined. The source calls two helper functions, performs two increments, and an addition. The compiler collapses the entire computation to a single lea instruction:
; x86-64, GCC -O2
; result = 2 * input + 3
movl input(%rip), %eax ; load volatile input
leal 3(%rax,%rax), %eax ; eax = eax + eax + 3 (the LEA fuses add + multiply)
movl %eax, result(%rip) ; store to volatile result
xorl %eax, %eax ; return 0
retThe two function calls, two stack frames, two increments of n, and one addition all vanished. Only the two volatile accesses remain β exactly what the standard requires.
volatile as the Escape Hatch
volatile is the primary mechanism for declaring that a memory location participates in observable behavior beyond what the compiler can see. Every access to a volatile-qualified object β read or write β is an observable side effect that the compiler must:
- Execute β it cannot be eliminated, even if the value is never subsequently used.
- Order β it cannot be reordered relative to other volatile accesses on the same thread (C++11
[intro.execution]). - Not coalesce β two writes must produce two memory accesses; a write followed by a read must not be turned into one.
volatile int hw_status; // memory-mapped hardware register
volatile int hw_command; // write-only command register
void send_command(int cmd) {
hw_command = cmd; // MUST write β cannot be a dead store
while (hw_status == 0) { // MUST re-read each iteration β cannot cache the first read
}
}Without volatile, the compiler may hoist the load of hw_status above the loop (it never changes in the C++ abstract machine sense) and produce an infinite loop or no loop at all. With volatile, each loop iteration issues a fresh load from the memory address.
What volatile does not do:
- It provides no synchronization between threads. Two threads accessing the same
volatilevariable without a mutex or atomic have a data race, which is undefined behavior. - It does not prevent the CPU's hardware memory subsystem from reordering accesses. On weakly-ordered architectures (ARM, POWER),
volatilealone does not guarantee that another core sees the writes in order. Usestd::atomic<T>with appropriate memory ordering for inter-thread communication. - It does not prevent reordering of non-volatile accesses relative to volatile ones. The standard only constrains volatile-to-volatile ordering; a non-volatile write may be moved past a volatile read if observable behavior is unchanged from the abstract machine's perspective.
volatile int flag = 0;
int data = 0;
// Thread A β WRONG: not safe for inter-thread communication
void producer_wrong() {
data = 42; // non-volatile β may be reordered past the volatile write
flag = 1; // volatile β write must occur, but data may not precede it
}
// Thread A β CORRECT: use acquire/release atomics (C++11)
#include <atomic>
std::atomic<int> flag_ok{0};
void producer_correct() {
data = 42;
flag_ok.store(1, std::memory_order_release); // C++11
}Copy Elision in Depth: RVO, NRVO, and Throw Expressions
The existing copy elision section covers the basic guarantee. The following details sharpen the picture.
RVO vs. NRVO
Return Value Optimization (RVO / URVO) applies when the returned expression is a prvalue β a nameless temporary constructed in the return statement itself:
Widget make_widget() {
return Widget{42}; // prvalue: RVO applies
// C++17: guaranteed β Widget constructed directly in caller's storage
// C++14 and earlier: permitted but not guaranteed
}Named Return Value Optimization (NRVO) applies when the returned expression is the name of a local variable with automatic storage duration (not a parameter, not a handler variable):
Widget make_widget_nrvo() {
Widget w{42}; // named local
w.prepare();
return w; // NRVO candidate: compiler may construct w in caller's storage
// Standard: permitted in all versions, never mandated β even in C++17
}The key distinction: C++17 mandated prvalue elision by redefining how prvalues work ("unmaterialized value passing" β there is no temporary to elide; it was never materialized). NRVO remains a quality-of-implementation optimization in every standard through C++26. If a function has multiple return paths returning different named variables, NRVO is less likely to apply.
Elision During Exception Propagation
Copy elision also applies in throw expressions (since C++11). When the thrown operand is the name of a non-volatile local with automatic storage duration that does not belong to a scope containing the innermost enclosing try block, the compiler may construct the exception object in-place rather than copying or moving:
#include <stdexcept>
std::runtime_error make_error(const char* msg) {
std::runtime_error e{msg}; // local variable
// ... augment e ...
throw e; // NRVO-like elision: e may be constructed directly as the exception object
// The copy/move constructor for runtime_error may not be called
}This is the same permission as NRVO β it is allowed by the standard, not mandated β but good optimizing compilers perform it reliably at -O1 and above.
When Elision Cannot Happen
Copy elision is never performed in:
- Constant expressions and constant initialization (
constexprcontexts). - When the source object is a function parameter (NRVO explicitly excludes parameters).
- When the returned name refers to different objects on different execution paths and the compiler cannot unify them.
Widget tricky(bool b) {
Widget a, bw; // two distinct named objects
if (b) return a; // NRVO for 'a'?
return bw; // NRVO for 'bw'?
// Compiler may not be able to apply NRVO because a and bw
// occupy different storage β it cannot construct both in the
// caller's single result slot simultaneously.
// In practice: move constructor is called instead (C++11).
}Observable Behavior: The Complete Enumeration
The standard ([intro.execution]) defines observable behavior as the union of:
| Category | Detail |
|---|---|
| Volatile object accesses | Every read/write of a volatile-qualified object must occur in program order, without elision or coalescing. |
| File I/O at termination | Data written to files at program termination must match what the abstract machine would have written. |
| Interactive I/O ordering | Prompting output sent to an interactive device must be flushed before the program waits for input on that device. |
| Floating-point environment (conditional) | When #pragma STDC FENV_ACCESS ON is active, changes to FP exception flags and rounding modes must be visible to subsequent FP operations as if no optimization occurred. The count and order of individual exceptions may still change, as long as the state observed by the next FP operation is correct. |
Everything outside this list belongs to the implementation: register allocation, stack layout, the number of copies of an object, instruction scheduling, the number of times a loop body executes before unrolling, heap allocation patterns, and so on.
C++26 refinement. The C++26 standard introduces observable checkpoints and the concept of the defined prefix. Loosely: the compiler's guarantee now extends only up to the last observable checkpoint that precedes any undefined behavior, rather than collapsing the entire program's guarantees the moment any UB is reachable. This gives implementations more room to reason about UB in dead paths while preserving diagnostics for the reachable, well-defined portion of a program.
See Also
reference/language/copy-elisionβ the named exception to the as-if rule; guaranteed prvalue elision in C++17reference/language/undefined-behaviorβ when the as-if guarantee disappears and the optimizer acts on impossible assumptionsreference/language/volatileβ the primary mechanism that creates observable-access obligationsreference/language/constant-expressionsβ constexpr and compile-time evaluation as a consequence of as-if folding