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:
- Allocate memory and call the constructor for
new Widget(). - Call
get_priority(). - 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):
- 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. - 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.