C++17 Order of Evaluation for Function Arguments

Prior to C++17, the C++ standard left the order of evaluation for function arguments largely unspecified and permitted compilers to interleave sub-expressions, often creating subtle bugs and memory leaks. The adoption of proposal P0145R3 in C++17 substantially tightened these rules. While the standard still permits compilers to choose which argument to evaluate first, it explicitly forbids interleaving the evaluation of different arguments and strictly sequences the function itself before its arguments.

The Problem in Pre-C++17

Before C++17, the evaluation order of function arguments was unspecified, and the operations between arguments were unsequenced. This meant compilers had the freedom to interleave the execution of operations from different argument expressions.

A classic example involved potential memory leaks during dynamic allocation:

void process(std::unique_ptr<Widget> w, int priority);

// Pre-C++17 risk
process(std::unique_ptr<Widget>(new Widget()), get_priority());

In pre-C++17 compilers, the code generation could proceed in the following order:

  1. Allocate memory and call the constructor for new Widget().
  2. Call get_priority().
  3. Construct the std::unique_ptr<Widget>.

If get_priority() threw an exception at step 2, the Widget allocated in step 1 was never encapsulated inside the unique_ptr, resulting in an unrecoverable memory leak.

How C++17 Clarified Function Calls

C++17 resolved this ambiguity by establishing two critical guarantees for function calls of the form f(a, b, c):

  1. The function designates first: The expression designating the function to be called (the postfix expression f) is sequenced before every argument expression and default argument.
  2. No interleaving between arguments: The evaluations of individual argument expressions are indeterminately sequenced with respect to one another.

"Indeterminately sequenced" means that while the compiler may still decide to evaluate b before a, or a before b, it must completely finish evaluating one entire argument (including all side effects and sub-expressions) before it begins evaluating another.

In the previous process example, the compiler must fully complete the construction of std::unique_ptr<Widget>(new Widget()) before starting get_priority(), or vice versa. The memory leak vulnerability due to interleaving was completely eliminated.

What C++17 Did Not Change

It is a common misconception that C++17 mandated a left-to-right evaluation order for function arguments. The C++ committee deliberately rejected strict left-to-right ordering for comma-separated function parameters to preserve compiler optimizations related to calling conventions and register usage.

Therefore, code that relies on the side effects of one argument affecting another remains unspecified:

void print_sum(int a, int b);

int n = 0;
print_sum(++n, ++n); // Unspecified behavior: could be (1, 2) or (2, 1)

Although the two ++n operations will not interleave, which one executes first remains up to the compiler.

Operator Overloads vs. Function Syntax

C++17 did enforce strict left-to-right evaluation for certain operators, such as member access (.), pointer-to-member (->*), shift operators (<<, >>), and assignment operators (evaluated right-to-left).

However, the standard differentiates between operator syntax and explicit function call syntax. If an overloaded operator is invoked using operator syntax (e.g., a << b), the left-to-right guarantees of that operator apply. If invoked as a regular function call (e.g., operator<<(a, b)), the rules for function arguments apply instead, meaning a and b are indeterminately sequenced.