Fold Expressions

An introduction to C++17 fold expressions, which allow us to work more efficiently with parameter packs
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

dsz.jpg
Ryan McCombe
Ryan McCombe
Posted

C++17 introduced fold expressions, which provide a shortened syntax to implement simple recursive algorithms that act on a parameter pack.

A fold expression looks like this:

(Args + ...)

It has four components:

  • A set of parenthesis, ( and )
  • An expression involving a parameter pack. In this example, we’ve assumed our parameter pack is called Args
  • An operator - in this example, we’ve used +
  • A set of ellipses - ...

We’ll delve into the specific behavior of parameter packs throughout this lesson. For now, a simplified explanation is that we can imagine inserting the operator between every object in the parameter pack and returning the result of that expression.

In the following example, we use a parameter pack containing the integers 5, 10, and 20. Our fold expression uses the + operator, so our expression unpacks to something equivalent to 5 + 10 + 20.

#include <iostream>

template <typename... Types>
auto Fold(Types... Args){
  return (Args + ...);
}

int main(){
  std::cout << "Result: " << Fold(5, 10, 20);
}

Our result is the sum of everything in the parameter pack:

Result: 35

Unary Fold

Fold expressions can be either unary, where they have a single operand, or binary, where they have two operands. (Args + ...) is an example of a unary fold expression.

This may seem unintuitive, as it looks a lot like binary operations we’ve seen in the past, where the operands are Args and ....

But ... is not an operand - it’s just part of the fold expression syntax. The expression only has one operand - the parameter pack called Args.

The operand we use in a fold expression can be any expression involving the parameter pack. Simply using Args in isolation is the most obvious form of that, but we have more options.

In this example, our fold expression uses the square of each value in the parameter pack, resulting in an expression like (1*1) + (2*2) + (3*3):

#include <iostream>

template <typename... Types>
auto Fold(Types... Args){
  return ((Args * Args) + ...);
}

int main(){
  std::cout << "Result: " << Fold(1, 2, 3);
}
Result: 14

Below, we send every value into a function and use the function’s return value in our fold expression. In this case, our function just logs the value, and then returns it unmodified:

#include <iostream>

int Log(int Arg){
  std::cout << "Arg: " << Arg << '\n';
  return Arg;
}

template <typename... Types>
auto Fold(Types... Args){
  return (Log(Args) + ...);
}

int main(){
  int Result{Fold(5, 10, 20)};
  std::cout << "Result: " << Result;
}
Arg: 5
Arg: 10
Arg: 20
Result: 35

Folding Over the Comma Operator

Often, we will simply want to iterate over every parameter in our container. A fold expression can help us here, too. In these scenarios, we don’t care what the fold expression returns, we only care about the side effect.

To do this, we can use the comma operator , within our fold expression. Here, we pass every parameter in our pack into a function:

#include <iostream>

template <typename T>
void Log(T Object){
  std::cout << Object << ' ';
}

template <typename... Types>
void Fold(Types... Args){
  (Log(Args), ...);
}

int main(){
  Fold("Hello", "World");
}
Hello World

We can shorten this using a lambda, which we pass Args to:

#include <iostream>

template <typename... Types>
void Fold(Types... Args){
  (
    [](auto& Arg){
      std::cout << Arg << ' ';
    }(Args),
    ...
  );
}

int main(){ Fold("Hello", "World"); }
Hello World

In this case, the Args we pass to the lambda is the same as the Args parameter of the outer Fold expression.

As such, this example can be further simplified by having the lambda capture Args directly from the outer function’s scope using [&Args] or simply [&] as the capture group:

[&]{ std::cout << Args << ' '; }

This allows us to remove both the parameter list from our lambda, and the argument when we invoke it:

#include <iostream>

template <typename... Types>
void Fold(Types... Args){
  (
    [&]{ std::cout << Args << ' '; }(),
    ...
  );
}

int main(){ Fold("Hello", "World"); }
Hello World

Overloaded Comma Operators

The previous examples assume what is returned from our function does not overload the comma operator in a way that would disrupt this technique.

If we believe that it may, we can proactively discard that return value before invoking the comma operator. The simplest way of doing this is by casting it to void:

template <typename... Types>
void Fold(Types... Args){
  (static_cast<void>(Log(Args)), ...);
}

Left Folds and Right Folds

There are two ways a fold operation can be implemented - these are generally called left folds and right folds. The difference is the order in which the parameters in the parameter pack are combined to generate the return value.

We control the direction of the fold by reordering the components of the fold expression:

template <typename... Types>
auto LeftFold(Types... Args){
  return (... + Args);
}

template <typename... Types>
auto RightFold(Types... Args){
  return (Args + ...);
}

For some operators, the fold direction doesn’t matter. For example, addition is associative - that is, given a list of numbers, their sum will be the same regardless of the order in which the numbers are added.

But that is not always the case. For example, subtraction is not associative. The following example shows a left fold and right fold in that context:

#include <iostream>

template <typename... Types>
auto LeftFold(Types... Args){
  return (... - Args);
}

template <typename... Types>
auto RightFold(Types... Args){
  return (Args - ...);
}

int main(){
  std::cout << "(5 - 10) - 20 = ";
  std::cout << LeftFold(5, 10, 20);

  std::cout << "\n5 - (10 - 20) = ";
  std::cout << RightFold(5, 10, 20);
}

Even though the parameter pack is the same in each case, each fold type composes the operations in a different order, thereby generating different return values:

(5 - 10) - 20 = -25
5 - (10 - 20) = 15

Binary Folds

We have the option of adding an additional operand to a fold expression, thereby creating a binary fold. This additional operand is sometimes referred to as the “initial value”. Below, we demonstrate the syntax using using 0 as the initial value:

template <typename... Types>
int BinaryFold(Types... Args){
  return (0 + ... + Args);
}

Why is this called a Binary Fold?

Similar to unary folds, referring to this as a binary operation is likely to seem counterintuitive. 0 + ... + Args does not look like other binary operations we’ve seen before.

The fact that the operator (+ in this case) is appearing twice in this expression makes this seem like multiple operations.

However, this is just another illusion created by the unusual syntax of fold expressions. The operator is repeated, but it must be the same in both positions. If we try to use different operators, we will get a compilation error:

template <typename... Types>
int BinaryFold(Types... Args){
  return (0 + ... - Args);
}
error: '-' in a binary fold expression
both operators must be the same

Like unary folds, binary folds have two variants - commonly called right and left, which we can control by reordering the components of our fold expression:

#include <iostream>

template <typename... Types>
int BinaryLeftFold(Types... Args){
  return (0 - ... - Args);
}

template <typename... Types>
int BinaryRightFold(Types... Args){
  return (Args - ... - 0);
}

int main(){
  std::cout << "((0 - 5) - 10) - 20 = ";
  std::cout << BinaryLeftFold(5, 10, 20);

  std::cout << "\n5 - (10 - (20 - 0)) = ";
  std::cout << BinaryRightFold(5, 10, 20);
}
((0 - 5) - 10) - 20 = -35
5 - (10 - (20 - 0)) = 15

Binary folds have two main uses. First, they succinctly allow us to add support for empty parameter packs.

In the following example, the binary fold function will return 0 when called with no arguments, whilst the unary fold function will cause a compilation error:

template <typename... Types>
int BinaryFold(Types... Args){
  return (0 + ... + Args);
}

template <typename... Types>
int UnaryFold(Types... Args){
  return (... + Args);
}

int main(){
  BinaryFold(); // 0 
  UnaryFold();
}
error: a unary fold expression over '+'
must have a non-empty expansion

see reference to function template
instantiation 'int UnaryFold<>(void)'

Secondly, fold expressions do not need to be arithmetic. We can fold over any operator, and binary fold expressions give us additional options here.

The following example logs everything in a parameter pack, using << as the operator, and std::cout as the initial operand:

#include <iostream>

template <typename... Types>
void Fold(Types... Args){
  (std::cout << ... << Args);
}

int main(){ Fold("Hello ", "World"); }
Hello World

Was this lesson useful?

Edit History

  • — First Published

Ryan McCombe
Ryan McCombe
Posted
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

7a.jpg
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access!

This course includes:

  • 106 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Perfect Forwarding and std::forward

Solving problems that arise when our functions forward their arguments to other functions
3D Character Concept Art
Contact|Privacy Policy|Terms of Use
Copyright © 2023 - All Rights Reserved