Embercode
✕
C++ OperatorsLesson 7 of 14

40 min read

Evaluation order and i = i++

Why this matters

Some one-line expressions look simple and have no clear answer. i = i++ looks like it should add one to i. a[k] = k++ looks like it should write to the slot before the increment. Interview questions use lines like these to test whether you know how C++ runs an expression. The answers depend on a set of rules that most programmers never read. This lesson explains those rules in plain terms. You will learn which parts of an expression run first, which orders the language fixes, which orders it leaves open, and why some expressions are not allowed to be written at all.

Operator precedence, which decides how the parts of an expression group together, is covered earlier in this module. This lesson is about something different: the order in which the parts run.

Precedence is not the same as order

Take this line:

int result = first() * second() + third();

Precedence says that * groups before +. So the multiplication first() * second() is one unit, and third() is added to it. That is the grouping. Precedence says nothing about the order of the calls. The language does not say whether first() runs before second(), or whether third() runs before either of them.

Here is the difference in one sentence. Precedence decides which operator is applied to which operands. Evaluation order decides which operand is computed first. The two are separate rules.

Three words you need

The C++ standard uses three terms for the order of work inside an expression. They are easy to confuse, so here they are with plain meanings.

  • Sequenced before means one step finishes completely before another step starts. If step A is sequenced before step B, then B can see every change A made.
  • Unsequenced means the two steps have no order between them. The language does not promise either order.
  • Undefined behaviour means the program breaks its own rules. The compiler may produce any result, including a crash, and it may not even be consistent from one run to the next.

Unsequenced steps are not always a bug. If two steps only read values, the order does not matter. The bug starts when one step changes a value and another step reads or changes the same value, with no order between them. That is the case that leads to undefined behaviour.

Example 1: operands with no fixed order

Start with a program that makes the order visible. Each function prints a line when it runs, so the output shows the order that this compiler chose.

C++ · editable

The output on the machine that checked this lesson is:

left_value runs
right_value runs
sum = 3
first = 2, second = 1

The first two lines show left_value running before right_value. That is what this compiler did, but the language does not promise it. The operands of + have no fixed order, so a different compiler, or the same compiler with different settings, could call right_value first.

The last line is more surprising. show_pair takes two arguments, and each argument calls next_id, which changes counter. The output says first is 2 and second is 1, so the second argument ran first. The first argument then got the later value. Again, this is one result from one compiler. The standard says the arguments of a function call are evaluated in an order that is not specified, and each argument is finished before the function body starts.

The rule to take from this example is simple. If two calls or two operands can change the same thing, do not write code that depends on which one runs first. Put each call on its own line.

Example 2: the orders that are fixed

Not every order is open. The standard fixes several orders, and you can rely on them. Three are common in everyday code.

The first is the comma operator. It evaluates the left part, then the right part, and gives the value of the right part. The next program uses it.

C++ · editable

The output on the machine that checked this lesson is:

1 2
comma result = 30, e = 3
ok = 1, x = 5

The first line prints 1 2. Since C++17 the << operator in a chain is evaluated from left to right, so the first call to next_id runs before the second one. Before C++17 the same line was not fixed. Under the newer rules you can read a chain of << as a list of steps that run in order.

The second line uses the comma operator inside parentheses. e += 2 runs first, so e becomes 3. Then e * 10 runs and gives 30. The result is 30, not 10, because the left part was finished before the right part started.

The third line uses &&. The left side (x = 5) > 3 runs first. Only if it is true does the right side x == 5 run. The lesson on guards with short-circuit covers this rule in full. Here it shows that x is 5 when the right side reads it, so the code is safe.

Other fixed orders include the condition of ?:, which runs before either branch, and the rule for = that the right side runs before the left side. The next section shows that rule, because it is the one that causes the most confusion.

Example 3: the right side of = runs first

Before you read this section, a warning. The next line is hard to read, and g++ warns about it. It is here so you can see what the rule does. You should not write code like this in a real program.

C++ · editable

The output on the machine that checked this lesson is:

a[0] = 10, a[1] = 0, k = 1

The output shows that the increment ran first. k++ returned 0 and set k to 1. Then a[k] used the new value of k, which is 1, so the assignment wrote 0 into a[1]. Slot a[0] kept its value of 10.

In C++17 and later, the standard says that in a = b, the right operand b is evaluated before the left operand a. This is why the increment ran before the index was computed. In C++14 and earlier, the same line had no fixed order, so the result was undefined.

You may wonder why g++ still warns about this line. The reason is that the line is easy to misread. Even when the standard defines the order, a careful program does not depend on it. Write the increment on its own line, before or after the use, so the next reader does not have to think about the rule.

Example 4: one change per expression is safe

Not every expression with ++ is a problem. The rule is about two things in the same expression touching the same variable. An expression that changes a variable once, and reads it only as part of that change, is safe. A common pattern is adding an item to an array and moving a counter at the same time.

C++ · editable

The output on the machine that checked this lesson is:

size = 3, data = 10 20 30
old_count = 1, count = 2

In data[size++] = 10, the index uses the old value of size, which is 0 the first time. The ++ then makes size equal to 1, so the next line writes to slot 1. There is only one change to size in each statement, and the value that is read is the same value that the increment returns. That is why the program is safe. The same reasoning applies to old_count = count++: the old value goes into old_count, and count goes up by one.

This pattern is common in real code, and it shows that the problem is not ++ itself. The problem starts when a second part of the same expression reads or changes the same variable with no order between them.

Checking an expression in three questions

When you are not sure whether an expression is safe, ask three questions in order.

  1. Does any part of the expression change a variable, with ++, --, =, or +=, or by calling a function that changes something?
  2. If so, does any other part of the same expression read or change that same variable?
  3. If both are true, does the language fix the order between the two parts?

If the answer to the first question is no, or to the second question is no, the expression is safe. If the third answer is yes, as with &&, the comma operator, or the chain of << in C++17, the expression is safe under the fixed order. If the third answer is no, the expression is undefined, and you should split it into statements.

Apply the questions to three lines. data[size++] = 10 changes size once and reads it only inside that change, so the answer to question 2 is no. (x = 5) > 3 && x == 5 changes x and reads it, but && fixes the order, so the answer to question 3 is yes. i++ + i++ changes i twice with no order between the changes, so it fails question 3. This check is short, and it takes less time than running the program to see what happens.

The case i = i++

The line i = i++ is the most famous example. A reader expects the value of i to go up by one. The next program shows what happens.

#include <iostream>
using namespace std;
 
int main() {
    int i = 5;
    i = i++;
    cout << i << endl;
    return 0;
}

The output on the machine that checked this lesson with -std=c++20 is:

5

The value does not change. The increment i++ runs first and sets i to 6, but it returns the old value, 5. Then the assignment writes that old value back into i, so i is 5 again. The increment was lost.

Here is what the compiler reported when it checked the same line, with -Wall turned on:

ex_i.cpp: In function ‘int main()’:
ex_i.cpp:6:7: warning: operation on ‘i’ may be undefined [-Wsequence-point]
    6 |     i = i++;
      |     ~~^~~~~
ex_i.cpp:6:7: warning: operation on ‘i’ may be undefined [-Wsequence-point]
5

The warning says the operation may be undefined. Before C++17, that was the correct reading: the line changed i twice with no order between the two changes, so the result was undefined. Compiled with -std=c++14, this program gives the same 5 and the same warnings, but the standard then gave no guarantee. The rule that makes it defined in C++17 is subtle, and g++ still treats it as suspicious. Do not write i = i++. If you want to add one, write i++; on its own line, or i += 1;.

Why the standard leaves some orders open

If the standard fixed every order, the rules would be easier to learn. It leaves some orders open for a practical reason. A compiler may compute the operands of + in whichever order fits the registers of the processor, or whichever order makes the instructions shorter. Fixing one order for every case would force the compiler to add extra work in some cases.

The cost is that code which depends on the open order can behave differently between compilers, between versions, or between two builds with different options. The language still gives you one firm rule that covers almost every real case. If two steps change the same variable, or one changes it while the other reads it, and nothing forces an order between them, the program has undefined behaviour.

Common mistakes

Mistake 1: changing the same variable twice

Here is the expression that most interview questions use.

#include <iostream>
int main() {
    int i = 3;
    int x = i++ + i++;
    return x;
}

The compiler reports this with the same kind of warning as before:

ex_x.cpp: In function ‘int main()’:
ex_x.cpp:4:14: warning: operation on ‘i’ may be undefined [-Wsequence-point]
    4 |     int x = i++ + i++;
      |             ~^~

Both i++ steps change i. The + operator has no fixed order between its two operands, so there is no order between the two changes either. The result is undefined. The program might return 7, or it might return something else. The fix is to make each change its own statement.

int i = 3;
int first = i++;
int second = i++;
int x = first + second;

Now first is 3, second is 4, and x is 7. Each statement changes i once, and the statements run in order.

Mistake 2: relying on the order of function arguments

The first program in this lesson showed that show_pair(next_id(counter), next_id(counter)) printed first = 2, second = 1 with g++. A reader who expects first = 1 will be wrong on that compiler, and may be right on another. The fix is to call the function first and store the results in variables, one per line.

int counter = 0;
int first = next_id(counter);
int second = next_id(counter);
show_pair(first, second);

This prints first = 1, second = 2 on every compiler, because each call is a separate statement.

Mistake 3: reading the warning as optional

A warning such as operation on 'i' may be undefined is not a style note. It means the compiler found a line that the standard does not define. Turn on -Wall when you compile, and treat each warning of this kind as a bug to fix. The check costs nothing and catches the errors in this lesson before they reach a test.

Practice

Try each exercise on paper first, then run your answer to check it.

Exercise 1. What does this program print?

int i = 2;
int j = i++ * 10;
cout << i << " " << j;

Show the answer

It prints 3 20. The increment runs once, so i becomes 3. The old value 2 is multiplied by 10, which gives j = 20.

Exercise 2. Does C++ guarantee that f() + g() calls f first?

Show the answer

No. The operands of + have no fixed order, so g may run first. If f and g print something, the printed order can differ between compilers. If you need f first, store its result in a variable on its own line, then call g.

Exercise 3. The line x = a++ + a++; has undefined behaviour. Rewrite it with one change to a per statement, so that a starts at 1 and the final x is the sum of the old values of the two increments. What are the final values of x and a?

Show the answer

int x = a++;
x += a++;

The first line sets x to 1 and a to 2. The second line adds the old value of the second increment, which is 2, so x becomes 3. The final values are x = 3 and a = 3.

Exercise 4. A function next_id(int& counter) increments counter and returns the new value. In C++17 and later, what does cout << next_id(c) << " " << next_id(c); print when c starts at 0?

Show the answer

It prints 1 2. Since C++17, the operands of << in a chain are evaluated from left to right, so the first call runs before the second.

Exercise 5. With e equal to 1, what does int r = (e += 2, e * 10); store in r, and why is it not 10?

Show the answer

It stores 30. The comma operator runs its left part first, so e += 2 makes e equal to 3. Then e * 10 runs and gives 30. If the right part ran first, e would still be 1 and the result would be 10, but the comma operator does not work that way.

Summary

  • Precedence decides how operands group. Evaluation order decides which operand runs first. They are separate rules.
  • The operands of + and the arguments of a function call have no fixed order, so do not depend on which runs first.
  • Since C++17 the operands of << in a chain run from left to right, and the right side of = runs before the left side.
  • i = i++ does not add one to i in practice. The increment is overwritten, and g++ warns about the line.
  • If one expression changes the same variable twice with no order between the changes, the result is undefined. Split the expression into statements.
  • Turn on -Wall and treat each may be undefined warning as a bug.
Practice this topic: coding problems for C++ Operators →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Floor division and modulo for negatives