Proven C Book←↑→

35 Assignment and side effects

What to know first

chapter 24, Declaring a variable · putting a value under a name
chapter 34, The meaning of a function · side effects and evaluation order
chapter 30, Implicit conversions · when values of different types meet

Looking back

Chapter 34 said that calls with side effects should be given their own statements. But assignment is itself a side effect — x = 1; changes the object x. Why, then, have we used assignment so freely all along?

A. Because each statement had one side effect. x = 1; changes one object, and there is a sequence point at the end of the statement, so by the time the next statement begins it is over. The danger starts when two or more side effects are packed into one expression — as in a[i] = i++. This chapter draws that line exactly.

The need for this chapter, and its context

Closing part 6 with assignment may look odd, since = was already used in chapter 24. But assignment is precisely the thing most used and least deeply understood, and its deeper rules — that it is an expression, that the left side is evaluated too — can only be explained once chapter 34′s evaluation order is known. Hence the last slot, not the first.

By the end of this chapter

The last chapter of this part is the operator used most often and understood most shallowly: assignment. That it is an expression, that the left side is evaluated too, that compound assignment evaluates the left side only once, and where the contract ends. The vocabulary built here carries into chapter 50′s gathering of the operators and chapter 54′s undefined behaviour.

The questions this chapter answers

  1. What, then, is outside the contract?
  2. Are x += 1, x++ and ++x not the same thing in the end?

35.1 Assignment is an expression, not a statement#

x = 1 looks like a command, but it is an expression — it yields a value. That value is “the assigned value, converted to the type of the left side”, and so it can be placed inside another expression.

examples-en/ch34/assign.c

/* The two things assignment does — yielding a value, and changing an object. */
#include <stdio.h>

static int calls;

static int where(void)      /* the computation that picks the place is evaluated too */
{
    calls++;
    printf("  where() called — it picks the place on the left\n");
    return 1;
}

static int what(void)
{
    calls++;
    printf("  what() called — it makes the value on the right\n");
    return 42;
}

int main(void)
{
    int a[3] = {0, 0, 0};

    /* ── (1) assignment is an expression — it yields a value ─────── */
    int x, y;
    x = (y = 7) + 1;                  /* the value of the expression y = 7 is 7 */
    printf("x = (y = 7) + 1  ->  x=%d y=%d\n", x, y);

    int p, q, r;
    p = q = r = 5;                    /* right-associative — r is filled first */
    printf("p = q = r = 5    ->  p=%d q=%d r=%d\n", p, q, r);

    /* ── (2) the left side is evaluated too ──────────────────────── */
    puts("\nrunning a[where()] = what();");
    calls = 0;
    a[where()] = what();
    printf("  both functions were called (%d calls), a[1]=%d\n", calls, a[1]);
    puts("  which of the two is called first is not settled by the standard (unspecified).");

    /* ── (3) compound assignment evaluates the left side once ────── */
    puts("\nrunning a[where()] += 1;");
    calls = 0;
    a[where()] += 1;
    printf("  where() call count: %d (spelled out as a[where()] = a[where()] + 1 it would be 2)\n",
           calls);
    printf("  a[1] = %d\n", a[1]);

    /* ── (4) assignment yields the value *converted* to the left type ─ */
    char c;
    int wide = 321;
    int back = (c = (char)wide);      /* narrowed then widened is not the original */
    printf("\nchar c = (char)321 -> c=%d, value of (c = ...) = %d\n", c, back);

    double d;
    int truncated = (int)(d = 3.9);   /* real to integer truncates toward zero */
    printf("d = 3.9 -> d=%.1f, (int)d = %d\n", d, truncated);

    /* ── (5) side effects get their own statements ──────────────── */
    int i = 0;
    a[i] = 10;
    i++;                              /* never mixed into one expression */
    a[i] = 20;
    printf("\nthe safe shape: a[0]=%d a[1]=%d i=%d\n", a[0], a[1], i);
    return 0;
}

Output

x = (y = 7) + 1  ->  x=8 y=7
p = q = r = 5    ->  p=5 q=5 r=5

running a[where()] = what();
  where() called — it picks the place on the left
  what() called — it makes the value on the right
  both functions were called (2 calls), a[1]=42
  which of the two is called first is not settled by the standard (unspecified).

running a[where()] += 1;
  where() called — it picks the place on the left
  where() call count: 1 (spelled out as a[where()] = a[where()] + 1 it would be 2)
  a[1] = 43

char c = (char)321 -> c=65, value of (c = ...) = 65
d = 3.9 -> d=3.9, (int)d = 3

the safe shape: a[0]=10 a[1]=20 i=1

The first two lines of the output confirm it. In x = (y = 7) + 1 the expression (y = 7) has the value 7, and adding 1 makes x 8. p = q = r = 5 makes all three 5 by the same principle — assignment is right-associative, so it groups as p = (q = (r = 5)) and the value of the inner assignment is handed outward.

One thing should be nailed down. The result of an assignment is not an lvalue. (a = b) = c does not compile. This differs from C++, and it is enough to remember that “assignment yields a value, but not a place”.

A common misconception. “if (x = 0) tests whether x is 0”

This is the oldest typo accident in C. x = 0 is an expression whose value is 0, so the if always sees false. Far from being tested, x is changed to 0. Comparison is ==.

There are three defences. First, turn compiler warnings on — gcc’s -Wparentheses points at an assignment in a condition (if it was intended, write if ((x = f())) with an extra pair of parentheses to say “on purpose”). Second, there is the old practice of putting the constant on the left (if (0 == x), the so-called Yoda condition), but it reads badly and this book does not recommend it. Third, and most reliable, is the habit of not assigning in a condition.

35.2 Assignment does three things#

Split E1 = E2 in the standard’s own vocabulary and it is three evaluations, not two — because the left side is computed as well.

  1. The value computation of the left operand — E1 is evaluated to settle which object is to be written. The standard is explicit that the value computation of an lvalue expression includes determining the identity of the designated object (§5.1.2.3).
  2. The value computation of the right operand — E2 is evaluated and its value converted to the type of the assignment expression (§6.5.17.2p2).1
  3. The side effect of storing — that value is written into the place settled above.

They must be kept as three because the ordering between them differs by pair.

which and whichthe relation the standard fixes
left value computation ↔ right value computationunsequenced — “the evaluations of the operands are unsequenced” (§6.5.17.1p3)
both value computations → the storethe store is sequenced after — same clause

Table 35.1 — Lvalue and rvalue — the relation the standard sets

“Unsequenced” does not mean “one of them goes first and we do not know which”. It is stronger: the standard says A may be neither before nor after B (§5.1.2.4p3). So the two computations may interleave and still conform. The term for “we only do not know which goes first” is a different one — indeterminately sequenced — and the body of a function call stands in that relation (§6.5.3.3p8). So the calls f() and g() do not interleave, while two computations that call nothing may.

Put §6.5.1p2 on top and the contract is complete.

Q. What, then, is outside the contract?

A. The standard’s own sentence: if a side effect on a scalar object is unsequenced relative to either a different side effect on the same scalar object or a value computation using the value of the same scalar object, the behavior is undefined (§6.5.1p2).

Footnote 81 even gives the examples. i = ++i + 1; and a[i++] = i; are undefined; i = i + 1; and a[i] = i; are allowed. The first two touch the same object twice with no ordering between the touches; the last two touch one thing only.

Platform note. C++ mended this place twice — C did not

It is one of the rare places where the same syntax is judged differently in the two languages. C++ tightened its ordering rules across two editions.

  • C++11 — the store of an assignment was fixed to be “after the value computation of the right and left operands, and before the value computation of the assignment expression”. That one line takes i = ++i; out of undefined territory: ++i’s store precedes its own value computation, and the assignment’s store follows that value computation, so the two stores acquire an order. i = i++; was still undefined, though — a postfix ++ stores after its value computation, so its store and the assignment’s store stayed unordered.
  • C++17 — P0145R3 was adopted and made “in E1 = E2 the right operand is sequenced before the left operand” a rule. That takes i = i++; into defined behaviour as well. The same change settled the result of common idioms such as m[0] = m.size().

C did not follow this road. §6.5.17.1p3 of C23 (N3220) still reads “the evaluations of the operands are unsequenced”, and the undefined-behaviour rule of §6.5.1p2 stands. Proposals to fix an order have been put to the committee, but the text of C23 did not change. So in C both i = ++i; and i = i++; are outside the contract.

There is one reason this difference hurts in practice: seeing a C++ compiler accept the same line without a word makes people believe it is fine in C too. When moving code between the languages, look at this place first.

35.3 The left side is evaluated too#

This is what beginners miss most often. The left of = is not a “value” but a computation that decides where to write, and that computation runs.

The second block of the example confirms it. Running a[where()] = what(); calls both where() and what(). The left computes “how to find that place”, the right computes “what to write”.

Which of the two is computed first? The standard does not settle it. A compiler may compute the right first or the left first. Here both computations are function calls, so they at least do not interleave — the indeterminate sequencing of the previous section. Only the order is unknown; one finishes before the other starts. Two computations that are not calls have no such protection (they are simply unsequenced). Either way, if both have side effects the result can differ with the order — the trap of the next section.

Platform note. compilers really do differ

The same code being evaluated in different orders by gcc and clang does happen. Change the optimisation level and it can differ within one compiler. This is a prime example of a place where “on my machine it comes out this way” is not evidence — the standard leaving the order open means both roads are correct, not that one was chosen and will be kept.

35.4 Compound assignment — the left side is evaluated once#

E1 op= E2 is not the same as E1 = E1 op E2. The standard says the only difference is that E1 is evaluated once — and that one line of difference matters in practice.

Look at the third block of the example. In a[where()] += 1;, where() is called once. Spelled out as a[where()] = a[where()] + 1; it would have been called twice, reading one slot and writing another.

shapeleft-side evaluationsnote
a[f()] = a[f()] + 12with a side effect in f, it reads one slot and writes another
a[f()] += 11the recommended shape
*p++ += 11still hard to read — better split up

Table 35.2 — How many times the left side is evaluated in a compound assignment

There are ten compound assignments — += -= *= /= %= <<= >>= &= ^= |=. Do not forget that the grey zones of each operation come along unchanged: x /= 0 is still outside the contract, and so is x <<= 40 (chapter 29).

Q. Are x += 1, x++ and ++x not the same thing in the end?

A. The effect on the object is the same. What differs is the value of the expression — ++x and x += 1 yield the value after the change, x++ the value before it. In a statement that discards the value (x++;) all three are identical, so in practice it is a matter of taste.

One practical difference exists. x += n can add any value, and on a pointer it moves by elements (chapter 39). And a habit inherited from C++ — “use ++x when the value is not used” — is widespread, though in C there is no performance difference.

35.5 Where the contract ends#

Now the famous expressions can be judged. The code below carries no output — printing the result of an expression outside the contract would leave the false knowledge “on this compiler it comes out like this” (chapter 54′s principle).

int i = 0, a[4] = {0};

i = i++;              /* outside the contract — i is modified twice */
a[i] = i++;           /* outside — modified, and read to decide the place */
i = ++i + i++;        /* outside — modified twice */
a[i++] = i;           /* outside — the same reason */
printf("%d %d", i++, i++);  /* outside — no sequence point between arguments */

These, by contrast, are fine.

i = i + 1;            /* the read is to determine the new value */
a[i] = i;             /* one object changed, a[i]; i is only read */
i++, i++;             /* the comma *operator* has a sequence point */
x = (i++) && (i++);   /* so does && */
f(i++);               /* one argument cannot overlap */

Counter-example. gathering side effects into one expression

a[i] = ++i + i++;        /* outside the contract. Asking "what value" is the wrong question */

Meeting such code, read it not as “what is the result” but as “this code has no meaning”. Outside the contract does not mean the value is strange; it means the compiler may generate anything at all and owes you no diagnostic (chapter 54).

The safe shape splits the statements.

int t = i + 1;            /* whatever was intended, settle the value first */
i = t + 1;                /* then change the object */
a[i] = t;

In practice. compilers know about this place

gcc and clang catch the common cases with -Wsequence-point and -Wunsequenced. Obvious ones such as i = i++; are usually caught, but cases that pass through a function call or reach the same object through a pointer are missed — the problem is hard to decide statically. So tools are an aid; knowing the rule comes first.

35.6 The conversion hidden in an assignment#

An assignment puts the right-hand value in converted to the left-hand type. Chapter 30′s conversion rules apply here quietly, and quietly is the danger.

The fourth block of the example shows it. In c = (char)321 the value is cut to 65, and the value of the assignment expression is that 65 — not what was put in but what went in. That (int)d is 3 after d = 3.9 is the same story (truncation toward zero, chapter 29).

assignmentwhat happensverdict
char c = 300;narrowed in the way the implementation settlesimplementation-defined — the value does not fit
int n = 3.9;truncated toward zero to 3fine, but confirm the intent
unsigned u = -1;wraps to the maximumfine (unsigned is modular)
int n = 3e30;a real that does not fit an int → outside the contractUB
float f = 0.1;narrowed from double to singlefine; precision is lost (chapter 52)

Table 35.3 — Assignment, case by case

Turning on -Wconversion makes the compiler point at such places. It is a noisy option, hard to switch on across a whole project, but keeping it on for new code is a good habit.

Recap

to rememberthe point
assignment is an expressionit yields a value — but not an lvalue
right-associativea = b = c is a = (b = c)
the left side is evaluatedin a[f()] = g(), f is certainly called
order of the two sidesunspecified — the compiler decides
compound assignmentevaluates the left side once
where the contract endsmodifying the same object twice within one sequence point, or modifying it and reading it for another purpose
the hidden conversionthe right side goes in converted to the left type

Table 35.4 — Assignment and side effects — what to remember

Part VI is over — we laid out the families of types and forged values (chapters 27–30), governed flow (chapters 31–33), and gained the meaning of functions and of assignment (chapters 34–35). The next part is this book’s second mountain, memory: back to chapter 3′s locker corridor, this time walking it in C’s syntax.

Notes

  1. Precisely: not “the type of E1” but the type of the assignment expression, which is the type E1 would have after lvalue conversion (§6.5.17.1p3). Qualifiers come off. ↩