Fun with fold expressions

Fold expressions, starting with C++ 17, provide succinct, if somewhat unorthodox, syntax for processing of parameter packs.

Printing a pack to standard output

Suppose, I want to write a function that outputs a bunch of values to cout.

I can do something like this:

#include <iostream>
#include <sstream>

using namespace std;

class Printable {};

ostream& operator<<(ostream& s, Printable const&) {
    return s << "(I am a Printable)";
}
// end common preamble, all subsequent examples omit it

template <typename... Args>
void output_cout(Args&&... args) {
   (cout << ... << args); // binary fold expression; parentheses are mandatory
   // the above line is equivalent to cout << arg1 << arg2 << ... << argN;
}

int main()
{
    output_cout(1, "ab", 3.14, Printable()); // 1ab3.14(I am a Printable)
}

This prints all parameters to cout, glued together without spaces. This example uses a feature known as “binary fold expression”.

Printing a pack to the first parameter

We can make it more flexible, and shorter, if we assume that the first parameter of the pack is the destination, which can be of any type. This examples and all subsequent ones omit the common preamble seen in lines 1-11 of the first example.

template <typename... Args>
void output(Args&&... args) {
   (... << args); // unary fold expression; parentheses are mandatory
   // the line above is equivalent to arg1 << arg2 << ... << argN;
}

int main()
{
    output(cout, 1, "ab", 3.14, Printable()); // 1ab3.14(I am a Printable)
}

Adding spaces

In previous examples all values were glued together. What if we want to add a space between values? If we are OK with a trailing space, we can use a variant of the first example with a more sophisticated fold expression:

template <typename... Args>
void output_cout(Args&&... args) {
   (..., (cout << args << ' ')); // all parentheses are mandatory
   // the above line is equivalent to (cout << arg1 << ' '), (cout << arg2 << ' '), ..., (cout << argN << ' ');
}

int main()
{
    output_cout(1, "ab", 3.14, Printable());
    cout << '$'; // 1 ab 3.14 (I am a Printable) $
}

Here we use a unary fold expression with operator comma, and the inner expression contains two operators <<. We verify that we have an extra space in the end by adding a $ sign.

Getting rid of the final space

To get rid of the final space, we’d need to treat the last argument differently. This is not something fold expressions can easily do. One way to handle it is to avoid fold expressions altogether and switch to recursion insead:

template <typename THead, typename... TTail>
void output_cout(THead&& head, TTail&&... tail) {
    cout << head;
    // 'constexpr' below is very important, or the compiler will be looking for an overload of output_cout() without parameters   
    if constexpr (sizeof...(tail) > 0) { 
        cout << ' ';
        output_cout(tail...);
    }
}

int main()
{
    output_cout(1, "ab", 3.14, Printable());
    cout << '$';  // 1 ab 3.14 (I am a Printable)$
}

We can see that the final space is gone now.

Getting rid of the final space, fold expression style

Fold expressions insist on treating each argument identically, but we still can use them if we keep the state somewhere else.

class Joiner {
    bool _has_content;
public:
    Joiner() : _has_content(false) {}
    
    template<typename T>
    Joiner& operator << (T&& value) {
        if (_has_content) {
            cout << ' ';
        }
        cout << value;
        _has_content = true;
        return *this;
    }
};

template <typename... Args>
void output_cout(Args... args) {
   (Joiner() << ... << args); // binary fold expression
   // the above line is equivalent to Joiner() << arg1 << arg2 << ... << argN;
}

int main()
{
    output_cout(1, "ab", 3.0, Printable()); 
    cout << "$"; // 1 ab 3 (I am a Printable)$
    return 0;
}

Execution order

Placement of the ellipsis inside a fold expression changes the order in which operations are applied. If the operation is associative like operator comma, it does not matter, but for not associative operations like operator << or operator / it may change the result dramatically. We say that comma is associative, because (a,b),c is the same as a,(b,c). We say that division is not associative, because (a/b)/c is not the same as a/(b/c).

Consider this example:

template <typename... Args>
auto bigDivideLeft(Args... args) {
    return (.../args); // applies division left-to-right
}

template <typename... Args>
auto bigDivideRight(Args... args) {
    return (args/...); // applies division right-to-left
}

int main()
{
    cout << bigDivideLeft(1, 2.0, 4) << endl;  // 0.125 == (1/2.0)/4
    cout << bigDivideRight(1, 2.0, 4) << endl; // 2 == 1/(2.0/4)
    return 0;
}

bigDivideLeft() applies the division operator left to right, to it first divides 1 by 2.0, and then divides the result by 4. This is how division normally works in C++.

bigDivideRight() works in the opposite direction, it first divides 2.0 by 4, yielding 0.5, and then divides 1 by 0.5, yielding 2.

We can have binary fold expression variants of these two:

template <typename... Args>
auto bigDivideLeft(Args... args) {
    return (1.0/.../args); // applies division left-to-right
}

template <typename... Args>
auto bigDivideRight(Args... args) {
    return (args/.../4.0); // applies division right-to-left
}

int main()
{
    cout << bigDivideLeft(2, 4) << endl;  // 0.125 == (1.0/2)/4
    cout << bigDivideRight(1, 2) << endl; // 2 == 1/(2/4.0)
    return 0;
}

In our previous examples, (..., (cout << args << ' ')); can be replaced with ((cout << args << ' '), ...);, because comma is associative.

However, if we try to replace (... << args); with (args << ...);, we get a compilation error, because the execution of the operations starts from the right, and 3.14 << Printable() is not legal.

Conclusion

Fold expressions are a powerful tool, but they come with a cost.

Usability:

  • The syntax is succinct, but not very intuitive. Perhaps some kind of loop construct would be more helpful.
  • Parentheses are mandatory. If you forget parentheses, the whole thing is not recognized as a fold expression, and weird errors arise.
  • Different placement of ellipsis may yield different results if the operation is not associative.

Functionality:

  • Fold expressions will treat all arguments identically.
  • If we don’t want to treat the arguments identically, we must either avoid fold expressions, or introduce mutable state.

 

Posted in

Leave a Reply

Your email address will not be published. Required fields are marked *