Foundations · Module 2 · Lesson 5 of 10
Operators, Precedence, and Evaluation Order
Differentiate operator precedence from evaluation order, master short-circuit evaluation, and eliminate unsequenced modification bugs.
By the end you can
- Distinguish operator precedence and associativity from the order of side-effect evaluation
- Predict expression outcomes involving short-circuit logical operators
- Identify undefined behavior caused by unsequenced modifications like i = i++
- Apply bitwise masks, shifts, and boolean reductions cleanly
Builds on Fixed-Width Types and Conversions.
01 · A question first
Most programmers believe that operator precedence dictates which function runs first in f() + g() * h(). It does not. Why can g() run after f(), or h() run before either, even though multiplication binds tighter? Unraveling precedence, associativity, and sequence points exposes one of the deepest traps in C.
Make a guess, then step through the trace below to check it.
02 · Watch it run
int x = 0;int y = 10;int result = (x != 0) && (++y > 5);- slot 0x: 0(looked at in this step)
- slot 1y: 10(looked at in this step)
- slot 2lhs: uncomputed
- slot 3result: unset
Variables x and y are allocated on the stack frame with initial values 0 and 10.
Machine work: movl 0, -4(%rbp); movl10, -8(%rbp)
03 · The idea
Start with the problem
One of the most persistent assumptions among developers learning C is that operator precedence determines the order in which instructions execute on the processor.
Consider this line of code calling three sensor reading functions:
int total_metric = read_temp() + read_pressure() * read_humidity();Because multiplication has higher precedence than addition, nearly every novice programmer assumes that read_pressure() and read_humidity() run first, their outputs are multiplied, and finally read_temp() is called.
In reality, the C standard makes no such promise.
A compiler is completely free to call read_temp() first, then read_humidity(), and finally read_pressure(). Or it can call read_humidity() first. If those functions depend on shared global hardware state or clear hardware registers upon reading, running them in an unexpected order produces corrupted sensor data that appears and vanishes depending on which optimization flag (-O0 versus -O2) you pass to the compiler.
Now consider another common idiom written by programmers trying to be concise:
int count = 5;
printf("%d %d\n", count, count++);On one machine, this prints 6 5. On another machine, it prints 5 5. When compiled with an aggressive optimizing compiler, it may output something entirely different or delete surrounding checks.
Why does multiplication precedence fail to force read_pressure() to run first? Why does printf produce different numbers for count++ across platforms?
To write robust systems code, you must separate three distinct language concepts: operator precedence, associativity, and order of evaluation.
How the idea works
Every expression in C is governed by three independent rules:
- Precedence: Determines which operators bind to which operands when an expression lacks parentheses.
- Associativity: Determines whether operators of the same precedence level bind from left to right, or from right to left.
- Order of Evaluation: Specifies when the compiler evaluates individual subexpressions and when their side effects become visible in memory.
The Expression Evaluation Pipeline:
Source Text: a + b * c
│
▼ [1. Precedence & Associativity]
Parse Tree: (+ a (* b c))
│
▼ [2. Order of Evaluation]
Compiler schedules a, b, and c in ANY order
│
▼ [3. Sequence Points]
Side effects committed before the next statement1. Precedence vs. Order of Evaluation
Precedence is purely a syntactic rule used during parsing to build the Abstract Syntax Tree (AST). It tells the compiler that in a + b * c, the multiplication operator owns b and c, while the addition operator owns a and the result of b * c.
Precedence says nothing about runtime evaluation order.
In read_temp() + read_pressure() * read_humidity(), the compiler must compute the values of all three function subexpressions before performing the addition. However, the order in which those three functions are called is unspecified behavior. The compiler may choose whatever order minimizes register pressure or utilizes available CPU execution units.
2. Operator Precedence Hierarchy
The table below summarizes C operator precedence from highest (binding tightest) to lowest, along with their associativity:
| Category | Operators | Associativity |
|---|---|---|
| Postfix | () [] -> . ++ -- | Left to right |
| Unary | + - ! ~ ++ -- * & sizeof (type) | Right to left |
| Multiplicative | * / % | Left to right |
| Additive | + - | Left to right |
| Bitwise Shifts | << >> | Left to right |
| Relational | < <= > >= | Left to right |
| Equality | == != | Left to right |
| Bitwise AND | & | Left to right |
| Bitwise XOR | ^ | Left to right |
| Bitwise OR | | | Left to right |
| Logical AND | && | Left to right |
| Logical OR | || | Left to right |
| Conditional | ?: | Right to left |
| Assignment | = += -= *= /= %= <<= >>= &= ^= |= | Right to left |
| Comma | , | Left to right |
3. Short-Circuit Evaluation and Sequence Points
While most operators offer no guarantee about operand evaluation order, four specific operators introduce a strict sequence point and evaluate strictly left to right:
- Logical AND (
&&): Evaluates the left operand first. If the left operand is zero (false), the right operand is never evaluated. - Logical OR (
||): Evaluates the left operand first. If the left operand is non-zero (true), the right operand is never evaluated. - Conditional Operator (
?:): Evaluates the condition first. Only the chosen branch is evaluated; the unselected branch is completely bypassed. - Comma Operator (
,): Evaluates the left operand, discards its value, inserts a sequence point, and evaluates the right operand.
This short-circuit property is essential for memory safety:
/* Safe null check: if ptr is NULL, ptr->val is never evaluated */
if (ptr != NULL && ptr->val > 0) {
handle_node(ptr);
}In the trace above, step 2 checks (x != 0). Because x is 0, the left operand evaluates to false. In step 3, the CPU jumps directly over the right-hand side (++y > 5). In step 4, y retains its original value of 10. If logical AND evaluated both sides unconditionally, y would be modified unexpectedly.
4. Sequence Points and Unsequenced Modifications
A side effect is a change in the machine state: writing to a variable, modifying a pointer, or executing I/O.
A sequence point is a point in the program execution sequence where all side effects of previous evaluations are guaranteed to be complete, and no side effects of subsequent evaluations have yet taken place.
Sequence points occur:
- At the end of a full expression (the semicolon terminating a statement).
- At the
&&,||, and comma (,) operators. - At the
?after evaluating the condition in a ternary expression. - Immediately before a function call is entered (after argument expressions are evaluated).
The C standard specifies a strict rule regarding side effects: Between two sequence points, an object shall have its stored value modified at most once by the evaluation of an expression. Furthermore, the prior value shall be accessed only to determine the value to be stored.
Violating this rule produces Undefined Behavior (UB):
i = i++; /* UNDEFINED BEHAVIOR: i modified twice */
a[i] = i++; /* UNDEFINED BEHAVIOR: i modified and accessed */
f(i++, i++); /* UNDEFINED BEHAVIOR: arguments unsequenced */In i = i++, both the assignment operator (=) and the post-increment operator (++) attempt to write a new value into the memory slot of i without an intervening sequence point. Modern compilers can emit instructions that discard the increment, preserve the old value, or emit arbitrary assembly instructions.
Why it is correct
Why does the C standard leave the evaluation order of subexpressions unspecified instead of enforcing a strict left-to-right rule?
Compiler Instruction Scheduling and Register Pressure
CPUs do not execute high-level ASTs directly; they execute linear machine instructions using a fixed pool of hardware registers (such as 16 general-purpose registers on x86-64).
Consider an expression evaluating four terms:
int res = heavy_calc() + x + y + fast_calc();If the standard forced the compiler to evaluate heavy_calc() first, the result would have to occupy a physical register while the compiler calculates x, y, and fast_calc(). If an intervening function call requires many registers, the compiler would be forced to spill the result of heavy_calc() to stack memory, burning execution cycles.
By leaving operand evaluation order unspecified, the compiler's optimizer can evaluate independent subexpressions in whatever order minimizes register spilling and maximizes instruction-level parallelism (pipelining).
What it costs
Operator choices directly impact generated machine instructions and branch predictor performance:
| Operation Pattern | Underlying Mechanism | Typical Latency | Impact |
|---|---|---|---|
Bitwise operations (&, |, ^, ~) | Often lowered to integer instructions | Target and workload dependent | Usually avoids control-flow branches, but has no universal fixed cost |
Logical short-circuit (&&, ||) | Conditional jump instructions (je, jne) | 1–20 CPU cycles | Jump mispredictions cause pipeline flushes |
Shift by power of 2 (x << 3) | Native barrel shifter | 1 CPU cycle | Faster than multiplication (x * 8) |
Division / Modulo (/, %) | Hardware divider circuit | 10–40 CPU cycles | Significantly slower than addition or bitwise |
Branch Prediction Costs with Short-Circuiting
Because && and || guarantee short-circuiting, the compiler must emit conditional jump instructions to test the first condition before executing the second.
If these conditions are evaluated millions of times in a tight loop and their outcomes are unpredictable, the CPU branch predictor will frequently mispredict. Each misprediction incurs a pipeline flush penalty of 15 to 20 clock cycles.
When both operands are pure numeric flags without side effects, using bitwise operators (&, |) instead of logical operators (&&, ||) allows the compiler to evaluate both terms without conditional branching, often running significantly faster:
/* Uses conditional branches (risk of branch misprediction): */
bool match = (flag_a && flag_b);
/* Branchless bitwise evaluation (clean ALU instruction): */
bool match = (flag_a & flag_b);Pitfalls and variations
Pitfall 1: Operator Precedence Between Bitwise and Equality
One of the most infamous design quirks in C syntax is that equality operators (==, !=) have higher precedence than bitwise operators (&, |, ^):
#define FLAG_ACTIVE 0x04
uint8_t status = 0x04;
/* BUG: Evaluates as status & (FLAG_ACTIVE == 0x04) */
if (status & FLAG_ACTIVE == 0x04) {
printf("Device is active.\n");
}The programmer intended to isolate the bit (status & FLAG_ACTIVE) and compare it against 0x04. But because == takes precedence over &, the expression is evaluated as:
status & (FLAG_ACTIVE == 0x04) --> status & 1 --> 0x04 & 0x01 --> 0The condition evaluates to 0 (false), and the block never executes. Always defensively parenthesize bitwise operations:
if ((status & FLAG_ACTIVE) == 0x04) {
/* Correct */
}Pitfall 2: Shifting Beyond Type Width
Shifting an integer by a number of bits equal to or greater than its width is Undefined Behavior:
uint32_t mask = 1;
uint32_t bad_mask = mask << 32; /* UNDEFINED BEHAVIOR on 32-bit types */On x86 architectures, shift instructions mask the shift amount by 31 (count & 0x1F). As a result, shifting a 32-bit integer by 32 bits actually shifts it by 0 bits, leaving the value unchanged instead of zeroing it. On other architectures, it may yield 0 or raise a hardware exception.
Pitfall 3: Right Shift on Signed Negative Integers
When performing a right shift (>>) on a signed integer with a negative value:
int32_t val = -16;
int32_t res = val >> 2;Whether the sign bit is preserved (arithmetic right shift) or filled with zeros (logical right shift) is implementation-defined in earlier standards, though modern systems perform arithmetic shifts. For bit manipulation and masks, always use explicit unsigned types (uint32_t) to guarantee consistent logical zero-fill shifts.
Where it is used
Precision control over operators and evaluation boundaries is critical across systems engineering:
- Hardware Device Drivers: Accessing memory-mapped registers requires exact bitwise masking:
c /* Set bit 3, clear bit 1 atomically in hardware control register */ *CTRL_REG = (*CTRL_REG | (1U << 3)) & ~(1U << 1); - Defensive Defensive Pointers in the Linux Kernel: Kernel code systematically exploits short-circuit evaluation to safely guard against null dereferences before accessing sub-fields:
c if (task && task->mm && task->mm->mmap) { /* Safe traversal across nested pointer structures */ } - Bitboards in Game Engines & Cryptography: Chess engines and cryptographic algorithms pack 64-bit boards and state vectors into single
uint64_twords, performing hundreds of parallel board transformations using single-cycle&,|,^, and shift operations.
In the next lesson, we will see how these expressions dictate program execution paths: diving into control flow, branch prediction mechanics, jump tables, and structured cleanup patterns.
04 · Prove it
Question 1 of 3 · answer from memory
In the expression int res = a() + b() * c(); which guarantee does C operator precedence provide?
05 · Practice
Something wrong with this lesson? Report a problem