Unsequenced Modifications in C++ Undefined Behavior
This article provides a comprehensive overview of undefined behavior resulting from multiple unsequenced modifications to the same variable in C++. It explains the concepts of sequencing and evaluation order, analyzes why the C++ standard designates these operations as undefined behavior, highlights classic code patterns that trigger the issue, and details how modern C++ standards regulate expression evaluation to prevent subtle runtime bugs.
Sequencing and Evaluation Order in C++
In C++, the execution of an expression involves both value computations (determining the result of an expression) and side effects (modifications to the state of an object, such as writing to memory). Prior to C++11, the language relied on the concept of "sequence points" to define boundaries where all previous side effects were guaranteed to be complete. Starting with C++11, the standard formalized this into a partial ordering model called the sequenced-before relationship.
An evaluation can be:
- Sequenced before another evaluation: The first completes before the second begins.
- Sequenced after another evaluation: The reverse of sequenced before.
- Unsequenced relative to another: The evaluations can occur in any order, can overlap, or can be interleaved.
- Indeterminately sequenced: One happens before the other, but the compiler chooses which one runs first; they cannot overlap.
The Undefined Behavior Rule
The C++ standard specifies that:
- If a side effect on a scalar object is unsequenced relative to another side effect on the same scalar object, the behavior is undefined.
- If a side effect on a scalar object is unsequenced relative to a value computation that uses the value of the same scalar object, the behavior is undefined.
When behavior is undefined (UB), the compiler is not obligated to generate predictable machine instructions, issue a warning, or fail to compile. The resulting program may produce erratic output, crash unpredictably, or behave differently across different optimization levels and compiler versions.
Common Examples of Unsequenced Modifications
A classic demonstration of this violation occurs when applying increment or decrement operators multiple times to a scalar variable within a single expression:
int i = 0;
i = ++i; // Undefined behavior prior to C++17In the assignment i = ++i, ++i modifies
i, and the assignment itself attempts to write a new value
to i. Without an explicit sequencing rule between the
assignment's side effect and the pre-increment's side effect, the order
of writes is unresolved.
Another prevalent scenario involves function arguments:
void execute(int a, int b);
int x = 1;
execute(++x, ++x); // Undefined behaviorIn C++, function arguments are unsequenced (or indeterminately
sequenced since C++17) relative to one another. Because both
subexpressions modify x without an intervening sequence
point or sequenced-before relationship, modifying x twice
leads directly to undefined behavior.
A related violation involves reading and modifying the same variable:
int arr[5] = {0};
int index = 0;
arr[index] = index++; // Undefined behavior prior to C++17Here, index is being read for the array subscript while
simultaneously undergoing a side effect via index++.
The Impact of C++17
C++17 refined the rules governing expression evaluation to eliminate some of the ambiguities that historically caused accidental undefined behavior:
- Assignment expressions (
=,+=, etc.): The right-hand side operand is sequenced before the left-hand side operand. - Subscript expressions (
E1[E2]): The expressionE1is sequenced beforeE2. - Shift operators (
<<,>>): The left operand is sequenced before the right operand.
Because of these revisions, an expression like
arr[i] = i++ or i = ++i has well-defined
sequencing in C++17 and later. However, expressions like
execute(++x, ++x) remain non-deterministic or problematic,
and multiple unsequenced writes inside subexpressions where no order is
mandated remain strictly undefined.
Why Compilers Treat This as Undefined
Modern compilers heavily optimize code using pipelining, instruction reordering, and register allocation. If the standard mandated an arbitrary evaluation order for subexpressions, compilers would be forced to emit redundant store and reload instructions, severely degrading performance. By classifying unsequenced modifications as undefined behavior, the language allows the optimizer to assume that such conflicting operations never occur in a well-formed program.
How to Prevent Unsequenced Modifications
To avoid undefined behavior from unsequenced modifications:
- Avoid modifying a variable more than once within the same full expression.
- Do not read and modify the same variable in different operands of an operator unless a strict sequencing guarantee applies.
- Separate complex, side-effect-heavy expressions into multiple distinct statements.
- Enable comprehensive compiler warnings (such as
-Walland-Wextrain GCC and Clang), which routinely detect and flag unsequenced operations.