In this next part of
the big STL algorithm tutorial
, it's time to move forward and start discussing the
<numeric>
header. We discussed all the non-range functions of the
<algorithm>
header.
Today we're going to discuss:
accumulate
reduce
transform_reduce
The C++ standard library doesn't have a
sum
function that you could call to add up all the elements of a container and get the sum of its items. What you'll likely end up with - unless you write a raw
for
loop - is
std::accumulate.
It takes a range by its begin and end iterators, an initial value and then it uses
operator+
first on the initial value and the first element of the range, then on their sum and the next value and so on, till there are no more elements to add.
As an initial value, we take the identity property of addition, which for numbers is 0. I say for numbers because you can define
operator+
on any type. For a
std::string
, it would be the empty string.
It's worth noting that in the lambda, the first parameter is the so far accumulated result (the initial value in the first iteration) and as a second parameter, the next element of the container is passed.
The accumulated result can be a different type than each element. Let's try to join numbers into a string with a separator.
#include<iostream>
#include<numeric>
#include<string>
#include<vector>intmain()std::vectornums{1,2,3,4};std::cout<<std::accumulate(nums.begin(),nums.end(),std::string(),[](std::stringpreviousResult,intitem){returnpreviousResult+'-'+std::to_string(item);<<'\n';
-1-2-3-4
Enter fullscreen modeExit fullscreen modestd::vectornums{1,2,3,4};std::cout<<std::accumulate(nums.begin(),nums.end(),std::string(),[](std::stringpreviousResult,intitem){if(previousResult.empty()){returnstd::to_string(item);returnpreviousResult+'-'+std::to_string(item);<<'\n';
1-2-3-4
Enter fullscreen modeExit fullscreen mode
If the previousResult is empty which is the initial value, we don't add a separator and we return early. Otherwise, business as usual.
The other is through the initial element and the beginning point of the accumulation:
#include<iostream>
#include<numeric>
#include<string>
#include<vector>intmain()std::vectornums{1,2,3,4};std::cout<<std::accumulate(nums.begin()+1,nums.end(),std::to_string(nums[0]),[](std::stringpreviousResult,intitem){returnpreviousResult+'-'+std::to_string(item);<<'\n';
1-2-3-4
Enter fullscreen modeExit fullscreen mode
Note that in this example we both had to modify the beginning of the range and in initial value, while in the previous solution we only modified the lambda. But we do an extra check for each iteration.
I think the first one is more readable (for my eyes at least), and in terms of performance - according to Quick Bench - there is no significant difference.
std::reduce was only introduced with C++17
While std::accumulate is basically a left fold operation, std::reduce doesn't guarantee any order
As elements can be rearranged and grouped during execution, it makes sense that std::reduce can take an ExecutionPolicy in the "0th" position
To demonstrate the main difference, let's run the previous example with reduce instead of accumulate:
main.cpp:10:84: note: candidate: 'main()::<lambda(std::string, int)>'
10 | std::cout << std::reduce(nums.begin()+1, nums.end(), std::to_string(nums[0]), [] (std::string previousResult, int item) {
| ^
main.cpp:10:84: note: no known conversion for argument 2 from 'std::__cxx11::basic_string<char>' to 'int'
Enter fullscreen modeExit fullscreen mode
That is very interesting. It complains that a string cannot be converted to an integer. That's true, but we didn't have such a problem with accumulate! So there must be another difference!
So what does the documentation say about BinaryOp:
"T must meet the requirements of MoveConstructible. and binary_op(init, *first), binary_op(*first, init), binary_op(init, init), and binary_op(*first, *first) must be convertible to T.
Clearly, our binary operation doesn't satisfy these requirements.
What does the documentation say for accumulate?
op: binary operation function object that will be applied. The binary operator takes the current accumulation value a (initialized to init) and the value of the current element b.
The signature of the function should be equivalent to the following: Ret fun(const Type1 &a, const Type2 &b);
The type Type1 must be such that an object of type T can be implicitly converted to Type1. The type Type2 must be such that an object of type InputIt can be dereferenced and then implicitly converted to Type2. The type Ret must be such that an object of type T can be assigned a value of type Ret.
The only things missing are
that T is the type of the accumulate's return value and the type of initInputIt is the type of the begin and end iterators.
So there is this extra - explicitly - unsaid difference between accumulate and reduce.
With accumulate, you fold all the elements to get a result in whatever type, but with reduce you fold the elements in a way that the result must stay convertible to the type of the elements.
I think that the reason behind this is that reduce can take items in whatever order and even the result of the previous iteration can appear in both positions of the BinaryOp.
As you can see, reduce can default even the initial value to the default constructed value of the underlying type. This is dangerous because the default constructed type might not always be identity value.
Now let's see another example, where we can see a potential difference in the outputs:
With accumulate we get 1 as expected, but reduce produces different outputs except for with the unsequenced_policy. The last call, where we pass in a lambda doing an identical operation compared to std::minus, reveals the reason. Subtraction is not commutative and associative, therefore when the items are evaluated in a different order, you won't have the same result.
So when you make a decision between accumulate and reduce, you have to take into account that as well.
std::transform_reduce is also a recent addition to the STL, we can use it starting from C++17.
It has quite a few overloads. It takes either one range denoted by its begin and end iterators, or two ranges where the second range is defined only by its input iterator.
Then it takes an initial value that is non-defaultable, unlike for std::reduce.
The following parameter is a binary reduction operation that might be defaulted to addition (std::plus<>()) if the last parameter is also defaulted. The last parameter is either a unary or a binary transform operation (depending on the number of ranges passed in) and that can be defaulted to std::multiplies only for binary transformations.
But what would be the output of such an algorithm?
Let's start with the one range example. It'll take each element and apply the transform operation on them, then they will be reduced to one single value.
In this other example, we passed in also v2 and the second lambda that includes the transformation takes two parameters, one from both ranges. We take the product of the items and sum up these products.
Let me share three thoughts on transform_reduce.
First, like for std::reduce, you have to keep in mind that if the reduce or transform operations are not associative and commutative, the results are non-deterministic.
Second, I find it strange that while the algorithm is called transform_reduce, first you pass in the reduction algorithm and then the transformation. I think the name is good because first the transformation is applied, then the reduction, but it should take the two operations in a reversed order.
Third, I said that first the transformation is applied and then the reduction. It's only logically true, but the implementation is more optimal. Imagine, if first all the transformations are applied, then each transformed value has to be stored. Instead, whenever there are two values available to be reduced, reduction happens so that fewer values have to be stored.
You can see this if you add some print statements into the transformation and reduction operations.
This time, we learnt about three algorithms from the <numeric> header. accumulate, reduce and transform_reduce all help us to reduce a range of items into one single value. Using them can simplify your codebase and introduce more constness.
Next time we'll continue with iota another 3 functions from the same header.
Stay tuned!
Built on Forem — the open source software that powers DEV and other inclusive communities.