Table of Contents

C++ has many dark corners and many caveats that can cause you to scratch your head in confusion. One of the issues we had until C++17 was the evaluation order of expressions. In this blog post, I’ll show you the new rules that we got in C++17 that made this complicated term much simpler and practical.

Here are the main points for today:

  • What’s the case with make_unique vs unique_ptr<T>(new T) in a function call.
  • What are the new rules for C++17?
  • Are all bugs fixed and now well defined?

Let’s go.

This article is based on an excerpt from my book: "C++17 in Detail".
Get the Ebook here at @Leanpub, or the print version @Amazon. And join almost 3000 readers!
Also, have a look at the Anniversary Promo at the end of the article :)

Stricter Expression Evaluation Order

Until C++17, the language hasn’t specified any evaluation order for function parameters. Period.

For example, that’s why in C++14 make_unique is not just syntactic sugar, but it guarantees memory safety:

Consider the following examples:

foo(unique_ptr<T>(new T), otherFunction()); // first case

And with make_unique:

foo(make_unique<T>(), otherFunction()); // second case

Considering the first case, in C++14, we only know that new T is guaranteed to happen before the unique_ptr construction, but that’s all. For example, new T might be called first, then otherFunction(), and then the constructor for unique_ptr is invoked.

For such evaluation order, when otherFunction() throws, then new T generates a leak (as the unique pointer is not yet created).

When you use make_unique, as in the second case, the leak is not possible as you wrap memory allocation and creation of unique pointer in one call.

C++17 addresses the issue shown in the first case. Now, the evaluation order of function arguments is “practical” and predictable. In our example, the compiler won’t be allowed to call otherFunction() before the expression unique_ptr<T>(new T) is fully evaluated.

In other words, in C++17 can still call otherFunction() before the memory allocation happens, but it cannot interleave sub expressions.

Read on for more details below.

The Changes

In an expression:

f(a, b, c);

The order of evaluation of a, b, c is still unspecified in C++17, but any parameter is fully evaluated before the next one is started. It’s especially crucial for complex expressions like this:

f(a(x), b, c(y));

if the compiler chooses to evaluate x first, then it must evaluate a(x) before processing b, c(y) or y.

This guarantee fixes the problem with make_unique vs unique_ptr<T>(new T()). A given function argument must be fully evaluated before other arguments are evaluated.

An Example

Consider the following case:

#include <iostream> 

class Query {      
public:
    Query& addInt(int i) {
        std::cout << "addInt: " << i << '\n';
        return *this;
    }
    
    Query& addFloat(float f) {
        std::cout << "addFloat: " << f << '\n';
        return *this;
    }
};

float computeFloat() { 
    std::cout << "computing float... \n";
    return 10.1f; 
}

float computeInt() { 
    std::cout << "computing int... \n";
    return 8; 
}

int main() {
  Query q;
  q.addFloat(computeFloat()).addInt(computeInt());
}

You probably expect that using C++14 computeInt() happens after addFloat. Unfortunately, that might not be the case. For instance, here’s an output from GCC 4.7.3:

computing int... 
computing float... 
addFloat: 10.1
addInt: 8

See the code and compare: @Compiler Explorer - GCC 4.7 and the same code @Compiler Explorer - GCC 8.

The chaining of functions is already specified to work from left to right (thus addInt() happens after addFloat()), but the order of evaluation of the inner expressions can differ. To be precise:

The expressions are indeterminately sequenced with respect to each other.

With C++17, function chaining will work as expected when they contain inner expressions, i.e., they are evaluated from left to right:

In the expression:

a(expA).b(expB).c(expC) 

expA is evaluated before calling b().

Compiling the previous example with a conformant C++17 compiler, yields the following result:

computing float... 
addFloat: 10.1
computing int... 
addInt: 8

Another result of this change is that when using operator overloading, the order of evaluation is determined by the order associated with the corresponding built-in operator.

For example:

std::cout << a() << b() << c();

The above code contains operator overloading and expands to the following function notation:

operator<<(operator<<(operator<<(std::cout, a()), b()), c());

Before C++17, a(), b() and c() could be evaluated in any order. Now, in C++17, a() will be evaluated first, then b() and then c().

Rules

Here are more rules described in the paper P0145R3:

The following expressions are evaluated in the order a, then b:

  1. a.b
  2. a->b
  3. a->*b
  4. a(b1, b2, b3) // b1, b2, b3 - in any order
  5. b @= a // '@' means any operator
  6. a[b]
  7. a << b
  8. a >> b

If you’re not sure how your code might be evaluated, then it’s better to make it simple and split it into several clear statements. You can find some guides in the Core C++ Guidelines, for example ES.44 and ES.44.

And here’s also a critical quote about argument interleaving; this is prohibited since C++17:\

From N4868, October 2020, Draft

[intro.execution], point 11:

When calling a function (whether or not the function is inline), every value computation and side effect associated with any argument expression, or with the postfix expression designating the called function, is sequenced before execution of every expression or statement in the body of the called function. For each function invocation F, for every evaluation A that occurs within F and every evaluation B that does not occur within F but is evaluated on the same thread and as part of the same signal handler (if any), either A is sequenced before B or B is sequenced before A.49

And there’s also a handy and concise summary added in the note below:

  1. In other words, function executions do not interleave with each other.

The famous example

In the 4th Edition of The C++ Programming Language, Stroustrup, you can find the following example:

#include <iostream>
#include <string>

int main() {
    std::string s = "but I have heard it works even"
                    "if you don't believe in it";
    s.replace(0, 4, "")
     .replace(s.find("even"), 4, "only")
     .replace(s.find(" don't"), 6, "");
    std::cout << s;
}

Play at @Compiler Explorer

And what’s surprising is that before C++17, this code was unspecified, and you could get different results.

Since C++17, you’ll see only one correct final value of s:

I have heard it works only if you believe in it

Does it mean all errors are fixed?

I got into a discussion recently with Patrice Roy, and thanks to his knowledge, I understood that the changes in C++17 are not the solution to all of our issues.

Have a look at the following contrived code:

foo(unique_ptr<T>(new T), otherFunction());

We said that we won’t leak from new T, but we could invent the following “deadly” code:

// don't write such code! it's only for experiments!
foo(unique_ptr<T> ptr, int *p) {
    if (p) {
        record(*p);
        delete p;
    }
}

foo(unique_ptr<T>(new T), new int {10});

While the evaluation of arguments cannot be interleaved, the compiler can select the following order:

  • new int { 10 }
  • new T
  • unique_ptr creation

And now, if new T throws, then new int is left as a memory leak (since the body of the function won’t be executed).

But… here’s the catch :)

The code I presented is really contrived and violates many rules of modern C++. So in practice, it’s hard to come up with code that will easily fail due to evaluation order after C++17. This might be the case with somehow wrongly passing resource ownership or functions with side effects.

Summary

Evaluation order is one of the primary “features” of the language, and before C++17, it could cause some unexpected effects. This was especially tricky for code that was supposed to run on many platforms and compilers. Fortunately, with C++17 the order is more practical, and thus it saves us from many mistakes.

You can also look at the proposal that went into the Standard: P0145R3.

Back to you

  • Has the evaluation order caused some bugs/errors/unexpected behavior in your code?
  • Do you try to make your expressions simple?

Let us know in the comments below the article.

Special Promo

It’s three years since I released “C++17 in Detail”! See the full info here: C++17 In Detail Book! and Print Version!.

To celebrate the anniversary, you can buy the book much cheaper!

Here are the options:

Another option, direct coupon codes, -40% on Leanpub:

Also with a pack with C++ Lambda Story:

You can also buy Team edition - 5 copies, only for 49,95$ (50% discount!)

The Print version at Amazon has also lower price in August: