Fold Expressions

While implementing a logging facility I recently stumbled across a problem that can be reduced to the following: Inside a variadic function template, if the log level is high enough, forward all arguments into an ::std::ostream&. Dutifully I solved it in a recursive fashion:

inline log(loglevel_t min_level) { }

template<typename T, typename... V>  
void log(loglevel_t min_level, T&& arg, V&& more_args) {  
    if(min_level <= current_level) {
        out << ::std::forward<T>(arg);
        log(min_level, ::std::forward<T>(more_args);
    }
}

However, not only does that feel like a clutch - after all I really just want to push everything into the output stream - but it also requires the instantiation of all those additional functions, which causes the binary to grow larger than necessary1.

After a bit of playing around, I decided to cheat a bit by abusing the comma operator to create an initializer list filled only with zeros, which I then simply never use. Any compiler worth its salt should then optimize the overhead away, leaving me with a small binary and a definition that matches my intent better:

template<typename... V>  
void log(loglevel_t min_level, T&& arg, V&& args) {  
    if(min_level <= current_level) {
        ::std::initializer_list<int> _ =
            {(out << ::std::forward<V>(args), 0)...};
        static_cast(_);
    }
}

Going through this one by one, it should be fairly obvious that the variable _ is only used to bind the initializer list to, but never actually used. The initializer list consists of the unpacking of the part in parentheses, which does nothing more than first pushing the current argument into out, and then returning 02.

However, not only this this a workaround for the fact that I cannot simply unpack the parameters at block scope (instead, the result of the unpacking needs to go somewhere), but it also really generates a different kind of result than I would write by hand. How come? Well by hand, I would write:

out << arg1 << arg2 << arg3;  

But the above really performs something more similar to:

out << arg1;  
out << arg2;  
out << arg3;  

Still, to the best of my knowledge that is the best that one can do with C++14. However, the upcoming C++1z standard is expected to bring fold expressions to the table, which could then finally be used to express what I really want to say, using a syntax that reminds me a lot of how I would write something like this as a mathematical expression:

template<typename... V>  
void log(loglevel_t min_level, T&& arg, V&& args) {  
    if(min_level <= current_level) {
        out << ... << ::std::forward<V>(args); // works as expected
    }
}

There are four different kinds of fold expressions that will be supported. The binary fold expressions already explored above accounts for two of them: Those were the parameter pack is on the left hand side, and those where it is on the right hand side. Besides the obvious difference (where the initial element ends up), they also differ in whether the expression is evaluated left to right or right to left, as the initial (non-parameter-pack) value is always innermost in the resulting expression. This has the useful side-effect that out << ... << ::std::forward<V>(args) is unpacked as (out << ::std::forward<V1>(args1)) << ::std::forward<V2>(args2).

Additionally, there are unary fold expressions, which - despite their name - does not actually work on unary operators, but really are just binary folds without an initial value. A unary right fold is written as pack op ... while a unary left fold is written as ... op pack. The difference is only in the order in which the operation is unfolded; a unary right fold will apply the op first to the rightmost elements of the pack, and a unary left fold will apply the op first to the leftmost elements of the pack. For example:

if(... && args)  

Will, when having three actual elements, be unpacked to something similar to:

if(((args1 && args2) && args3) && args4)  

On the other hand

if(args && ...)  

would be unpacked to something similar to:

if(args1 && (args2 && (args3 && args4)))  

Of course, this would cause problems with empty parameter packs, which is why the proposal for fold expressions adds a table of values that are to be returned in case of an unary fold of an empty parameter pack. Logically, the values are chosen as the neutral elements of the binary operator in question (e.g., a 0 for addition and a 1 for multiplication), leading to the alternative (arguably technically false) definition that a unary fold expression is just a shortcut for a binary fold with the neutral element of the operation as the implicit initial value. To illustrate:

if(args && ...) { } // unary fold that unpacks to:  
if(args1 && (args2 && (args3 && args4))) { }

if(args && ... && true) { } // equivalent binary fold unpacks to:  
if(args1 && (args2 && (args3 && (args4 && true)))) { }  

However, while this makes sense mathematically, it may give rise to a non-obvious idiom to deal with an important case:

if(sizeof...(V) && (some_condition(args) && ...)) {  
    // only execute if arguments exist and satisfy a condition
}

Personally I think that while this is definitely a step in the right direction, the unpacking of parameter packs still feels somewhat restricted in general.

Footnotes:

  1. I expect that it also hurts performance, but have no measurements to back that up.

  2. This is an instance of the comma operator, which is really very simple: It computes the left hand side and forgets the result. Then it computes the right hand side, whose result is then used as the result of the whole expression.

Daniel Schemmel

is currently employed at the Chair of Communication and Distributed Systems at RWTH Aachen University, where he researchs the testability of distributed systems. He can be reached at blog(at)gha.st.

Aachen, Germany, Terra, Sol, Milky Way, Laniakea SC https://gha.st/about/