Fold Expression

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.

Free, Unlimited Access
3D Character Concept Art
Ryan McCombe
Ryan McCombe
Updated

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.

Using Fold Expressions

When using a fold expression, we can imagine it 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 effects.

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. LeftFold() returns -25, whilst RightFold() returns 15:

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

Binary Folds

We have the option of adding a second 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 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(); // Returns 0 
  UnaryFold(); // Compilation error 
}
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

The previous example works because the initial expression will be std::cout << "Hello ". As we’ve seen in all our previous examples of terminal logging, this expression returns a reference to the std::cout object, allowing us to chain more values to the terminal.

The previous fold expression is effectively equivalent to:

(std::cout << "Hello ") << "World";

If our parameter pack had a third value, like the string "!!!", our fold expression would be:

((std::cout << "Hello ") << "World") << "!!!";

This pattern continues for any number of parameters.

Summary

In this lesson, we explored fold expressions, a C++17 feature that can make working with parameter packs easier. The key points to remember include:

  • C++17 fold expressions combine a parameter pack and an operator, separated by the ... syntax.
  • Fold expressions can be either unary (using one operand - the parameter pack) or binary (using two operands - the parameter pack and an initial value).
  • Using fold expressions with arithmetic operations to perform calculations on parameter packs.
  • The concept of left folds and right folds and their significance in operation ordering.
  • How to use the comma operator in fold expressions for iterating over parameter packs and executing side effects.
  • Addressing the potential issue of overloaded comma operators in fold expressions.
  • The utility of binary folds in handling empty parameter packs and their application beyond arithmetic operations, including logging with std::cout.

Was this lesson useful?

Next Lesson

Perfect Forwarding and std::forward

An introduction to problems that can arise when our functions forward their parameters to other functions, and how we can solve those problems with std::forward
3D Character Concept Art
Ryan McCombe
Ryan McCombe
Updated
Lesson Contents

Fold Expression

An introduction to C++17 fold expressions, which allow us to work more efficiently with parameter packs

A computer programmer
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
A computer programmer
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:

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

Perfect Forwarding and std::forward

An introduction to problems that can arise when our functions forward their parameters to other functions, and how we can solve those problems with std::forward
3D Character Concept Art
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved