C / OPERATORS AND EXPRESSIONS
Side effects, sequence points and why i++ bites
Read any expression containing ++, -- or assignment and say what value it gives, when the update lands, and whether C defines the result at all.
What you will learn
- Separate the value an expression yields from the side effect it performs
- Name the sequence points: semicolon, comma, &&, ||, ?: and the function call
- Recognise i = i++ and a[i] = i++ as undefined, not merely unpredictable
- Rewrite a risky expression into statements that each perform one update
Understanding Side effects, sequence points and why i++ bites
A side effect is any change an expression makes to the state of the program rather than to the value it produces: an assignment, a ++ or --, a write through a pointer, an access to a volatile object, a call to printf. So i++ does two separate things: it yields the old value of i, and it arranges for i to become i + 1. Beginners collapse those two into "i++ makes i bigger", which is why int a = i++; surprises them. The increment really happens, but the value the expression handed over was fixed before it did.
C does not say exactly when that update happens, only that it has happened by the next sequence point. The sequence points in ordinary code are the semicolon ending a full expression, the comma operator, the split between the operands of && and ||, the condition of ?:, and the moment a function is actually called once its arguments have been evaluated. Between two sequence points the compiler may evaluate subexpressions in any order and interleave their side effects. The useful mental model is a set of pending updates that must all be applied before the next sequence point, in an order nobody promised you.
That freedom turns into a rule you must obey: between two sequence points an object may be modified at most once, and if it is modified you may not also read it for any purpose other than computing its new value. i = i++, a[i] = i++ and f(i++, i++) all break it, and the consequence is undefined behaviour, so the program may print anything, may behave differently at -O0 and -O2, and may compile without a warning. Separately, the order in which function arguments are evaluated is unspecified, which is harmless while the side effects touch different objects and fatal when they touch the same one. The cure is never a cleverer expression; it is a semicolon, which is a sequence point you can see.
<stdio.h>
int main(void)
{
int i = 5;
int a = i++; /* a takes the old value of i, then i changes */
int b = ++i; /* i changes first, then b takes the new value */
printf("a=%d b=%d i=%d\n", a, b, i);
int j = 0;
int c = (j++, j + 10); /* the comma is a sequence point: j++ has landed */
printf("c=%d j=%d\n", c, j);
int k = 0;
if (k++ == 0 && k == 1) /* && is a sequence point too */
printf("k is already %d when the right operand runs\n", k);
return 0;
}
i++ hands over its value immediately but its update is only guaranteed to have landed by the next sequence point, so no object may be modified twice, or modified and separately read, between two sequence points.
Worked examples
Arguments: order unspecified, updates complete
Two increments in one call are safe because they modify different objects, and both have finished before the function body starts.
<stdio.h>
static int pair(int x, int y)
{
printf("pair sees x=%d y=%d\n", x, y);
return x * 10 + y;
}
int main(void)
{
int i = 1, j = 1;
int r = pair(i++, j++); /* which argument goes first is unspecified */
printf("r=%d i=%d j=%d\n", r, i, j);
return 0;
}
Example explained
Line 1pair(i++, j++) passes 1 and 1, since each postfix ++ produces the value the variable had before it.
Line 2There is a sequence point after the arguments are evaluated, so both increments are guaranteed done before pair's first line runs.
Line 3The order of the two argument evaluations is unspecified, but nothing here can observe it: the side effects hit different objects.
Line 4Change the second argument to i++ and the same call modifies i twice with no sequence point between them, which is undefined rather than just unpredictable.
i++ inside a loop condition
A perfectly defined use of i++ that still produces a logic bug, because the increment also runs on the test that ends the loop.
<stdio.h>
int main(void)
{
int a[6] = {3, 7, 3, 3, 9, 3};
int i, count;
i = 0; count = 0;
while (i < 6 && a[i++] == 3)
count++;
printf("compact: count=%d i=%d a[i-1]=%d\n", count, i, a[i - 1]);
i = 0; count = 0;
while (i < 6 && a[i] == 3) {
count++;
i++;
}
printf("explicit: count=%d i=%d a[i]=%d\n", count, i, a[i]);
return 0;
}
Example explained
Line 1a[i++] == 3 is well defined: i is modified once, and the only read of i is the one that forms i++'s own value.
Line 2The increment still happens on the comparison that fails, so after the loop i sits one past the element that stopped it and you need a[i - 1] to inspect it.
Line 3The explicit version advances i in the body, so i is left exactly on the first element that is not 3.
Line 4Both loops agree on count, so the difference only shows up in the code after the loop, which is why this bug survives casual testing.
Splitting an undefined expression
The classic copy dst[i] = src[i++] is undefined, and the repair is a semicolon rather than a rearrangement.
<stdio.h>
int main(void)
{
char src[] = "abc";
char dst[4];
int i = 0;
/* dst[i] = src[i++]; reads i and writes i with no sequence point
between the two uses: undefined behaviour */
while (src[i] != '\0') {
dst[i] = src[i];
i++; /* one update per statement */
}
dst[i] = '\0';
printf("dst=%s i=%d\n", dst, i);
return 0;
}
Example explained
Line 1In the commented line the i that indexes dst is unsequenced with the i++ on the right, so the standard assigns the whole expression no meaning at all.
Line 2The semicolon after dst[i] = src[i]; is a sequence point, so that read of i is complete before i++ modifies it.
Line 3The loop leaves i at 3, the index of the terminator, so dst[i] = '\0'; closes the string in the right place.
Line 4Each statement now performs a single visible change, which is also what makes the loop steppable in a debugger.
Important notes
The operand of sizeof is not evaluated, so sizeof(i++) never changes i, and neither does the branch of ?: that is not taken; the only exception is a sizeof applied to a variable-length array type.
C11 restated sequence points as a "sequenced before" relation, but the practical rule is unchanged and i = i++ is still undefined, so a silent compile is not approval.
Common mistakes
Writing i = i++; to mean "increment i": i is modified twice with no sequence point between, so the statement is undefined, and gcc at -O2 commonly leaves i unchanged while a debug build may not, so the bug shows up only in release.
Compressing a copy into dst[i] = src[i++];: the index read of i is unsequenced with the increment, so the character can be stored at the wrong position and the result changes with compiler or optimisation level.
Hiding i++ in a loop condition such as while (a[i++] != 0) and then treating i as the index of the element found: the increment runs on the failing test too, so i is one too far and the following a[i] reads the wrong element.
Try it yourself
Change, predict, then run
Declare int a[5] = {4, 8, -2, 6, -1}; and int i = 0;, find the first negative value with while (a[i++] >= 0) ; and print i, then rewrite the loop so that i ends up holding the index of that negative element. Print a[i] in both versions and explain the off-by-one in a comment.
Open the C workspaceCheck your understanding
With int i = 2; and int a[4] = {0, 0, 0, 0};, which statement has undefined behaviour?
- a[i++] = 0;
- i++; a[i] = i;
- a[i] = i++;
- if (i++ == 2 && a[i] == 0) i = 0;
Show answer
In a[i] = i++; the read of i that selects the element is unsequenced with the write performed by ++, and that read is not part of computing i's new value, so the standard gives the statement no meaning. a[i++] = 0; has a similar shape but is fine: i is modified exactly once and the only read of i is the one producing i++'s own value.