The short answer is "it does it not very well".
It invokes f
on each of the args...
, and discards the return value. But it does so in a way that leads to unexpected behavior in a number of cases, needlessly.
The code has no ordering guarantees, and if the return value of f
for a given Arg
has an overloaded operator,
it can have unfortunate side effects.
With some white space:
[](...){}(
(
f(std::forward<Args>(args)), 0
)...
);
We will start from the inside.
f(std::forward<Args>(args))
is an incomplete statement that can be expanded with a ...
. It will invoke f
on one of args
when expanded. Call this statement INVOKE_F
.
(INVOKE_F, 0)
takes the return value of f(args)
, applies operator,
then 0
. If the return value has no overrides, this discards the return value of f(args)
and returns a 0
. Call this INVOKE_F_0
. If f
returns a type with an overriden operator,(int)
, bad things happen here, and if that operator returns a non-POD-esque type, you can get "conditionally supported" behavior later on.
[](...){}
creates a lambda that takes C-style variadics as its only argument. This isn't the same as C++11 parameter packs, or C++14 variadic lambdas. It is possibly illegal to pass non-POD-esque types to a ...
function. Call this HELPER
HELPER(INVOKE_F_0...)
is a parameter pack expansion. in the context of invoking HELPER
's operator()
, which is a legal context. The evaluation of arguments is unspecified, and due to the signature of HELPER
INVOKE_F_0...
probably should only contain plain old data (in C++03 parlance), or more specifically [expr.call]/p7 says: (via @T.C)
Passing a potentially-evaluated argument of class type (Clause 9) having a nontrivial copy constructor, a non-trivial move constructor, or a non-trivial destructor, with no corresponding parameter, is conditionally-supported with implementation-defined semantics.
So the problems of this code is that the order is unspecified and it relies on well behaved types or specific compiler implementation choices.
We can fix the operator,
problem as follows:
template <class F, class... Args>
void for_each_argument(F f, Args&&... args) {
[](...){}((void(f(std::forward<Args>(args))), 0)...);
}
then we can guarantee order by expanding in an initializer:
template <class F, class... Args>
void for_each_argument(F f, Args&&... args) {
int unused[] = {(void(f(std::forward<Args>(args))), 0)...};
void(unused); // suppresses warnings
}
but the above fails when Args...
is empty, so add another 0
:
template <class F, class... Args>
void for_each_argument(F f, Args&&... args) {
int unused[] = {0, (void(f(std::forward<Args>(args))), 0)...};
void(unused); // suppresses warnings
}
and there is no good reason for the compiler to NOT eliminate unused[]
from existance, while still evaluated f
on args...
in order.
My preferred variant is:
template <class...F>
void do_in_order(F&&... f) {
int unused[] = {0, (void(std::forward<F>(f)()), 0)...};
void(unused); // suppresses warnings
}
which takes nullary lambdas and runs them one at a time, left to right. (If the compiler can prove that order does not matter, it is free to run them out of order however).
We can then implement the above with:
template <class F, class... Args>
void for_each_argument(F f, Args&&... args) {
do_in_order( [&]{ f(std::forward<Args>(args)); }... );
}
which puts the "strange expansion" in an isolated function (do_in_order
), and we can use it elsewhere. We can also write do_in_any_order
that works similarly, but makes the any_order
clear: however, barring extreme reasons, having code run in a predictable order in a parameter pack expansion reduces surprise and keeps headaches to a minimum.
A downside to the do_in_order
technique is that not all compilers like it -- expanding a parameter pack containing statement that contains entire sub-statements is not something they expect to have to do.