Default Parameters With Default Template Parameters Types
There is a particular case for default parameters: it’s when their type is a template type.
Even though the idea is similar to the regular default parameters, there are some subtleties that are worth mentioning. To illustrate this, I will use the example of map_aggregator
, the output iterator for aggregating data into a map, for which we have a new requirement: default aggregation.
What makes this case interesting is that
- it’s harder to get right than simple default parameters,
- it shows a practical illustration of the dilemna between overloads and default parameters.
This post is part of a series on default parameters:
- Default parameters in C++: the facts (including the secret ones)
- Should I overload or use default parameters?
- Default Parameters With Default Template Type Parameters
Defaulted
: a helper to work around default parameters constraints- Implementing Default Parameters That Depend on Other Parameters in C++
- How default parameters can help integrate mocks
Application to map_aggregator
map_aggregator
is a component that helps aggregating new data into a map in a very concise manner. It is an output iterator, much like std::back_inserter
, that you can plug onto a map and that will:
- either insert new data if its key isn’t already in the map,
- or aggregate the new value with the one in the map correponding to this key.
map_aggregator
takes an aggregating function, like concatenateStrings
, to know how to aggregate a new value with an existing one.
Here is a usage example:
std::vector<std::pair<int, std::string>> entries = { {1, "a"}, {2, "b"}, {3, "c"}, {4, "d"} }; std::vector<std::pair<int, std::string>> entries2 = { {2, "b"}, {3, "c"}, {4, "d"}, {5, "e"} }; std::map<int, std::string> results; std::copy(entries.begin(), entries.end(), map_aggregator(results, concatenateStrings)); std::copy(entries2.begin(), entries2.end(), map_aggregator(results, concatenateStrings)); // results contains { {1, "a"}, {2, "bb"}, {3, "cc"}, {4, "dd"}, {5, "e"} };
You can see that entries2
has some keys that are also in entries
(3
, 4
and 5
). All the values in entries
get inserted into the map, and then the values in entries2
are either inserted or aggregated on the existing keys. map_aggregator
is available on its GitHub repository if you want to try it yourself.
A new requirement
My colleague Damien, using map_aggregator
, had the need for a new requirement.
For quite a few cases, the aggragation is just doing a call to operator+
. In particular when the values of the maps are numbers (for numerical addition) and strings (for concatenation). So why force the user to write an aggregating function that just does +
? It would be nicer to have this as a default behaviour, and provide an aggegator only when we need something more specific. I’m very grateful to Damien for suggesting this new feature for map_aggregator
.
I’m now going to show you two ways to implement this, and discuss the pros and cons of each. For you to make the most of this discussion, I suggest that you take a moment to think about how you would have done it, if you were in that situation. The requirement is to have a default parameter (the aggregating function), which is itself a template (because we don’t know in advance the type of the aggregating function, it could even be a lambda).
The STL style: overloading
When you think about it, this requirement is very similar to what std::accumulate
does.
Indeed, std::accumulate
returns the sum (computed with operator+
) of the elements in a sequence:
std::vector<int> numbers = {1, 2, 3, 4, 5}; const int sum = std::accumulate(begin(numbers), end(numbers), 0); // sum is now 15.
But this is the default version of accumulate. If you need something more sophisticated than operator+
, you can pass a function as an extra parameter of std::accumulate
:
std::vector<int> numbers = {1, 2, 3, 4, 5}; const int sum = std::accumulate(begin(numbers), end(numbers), 0, [](int cumulatedResult, int number){ return 2 * (cumulatedResult + number); }); // sum is now 114.
I hope you can see the similarity of requirements between std::accumulate
and map_aggregator
. The default version does +
, and you can specify your own function if needed.
So how does the STL go about solving this problem? Here is the interface of std::accumulate
:
template<typename InputIterator, typename T> T accumulate(InputIterator first, InputIterator last, T init); template<typename InputIterator, typename T, typename BinaryOperation> T accumulate(InputIterator first, InputIterator last, T init, BinaryOperation op);
The STL uses overloading here. Here is how overloading would look like for our map_aggregator
:
template<typename Map, typename Function > map_aggregate_iterator<Map, Function> map_aggregator(Map& map, Function aggregator) { return map_aggregate_iterator<Map, Function>(map, aggregator); } template<typename Map> map_aggregate_iterator<Map, std::plus<typename Map::mapped_type> > map_aggregator(Map& map) { return map_aggregator(map, std::plus<typename Map::mapped_type>()); }
The part that needs explanation is std::plus<typename Map::mapped_type>
. Let’s decompose it:
mapped_type
is the type of the value (as in key-value) stored inside anstd::map
,- We have to write
typename
in front of this nested type, because sinceMap
is a template, the compiler needs an indication that we’re talking about a type, not a value, std::plus
is a function object that calls theoperator+
of its template type. It could be implemented like this:template <typename T> struct plus { constexpr T operator()(T const& lhs, T const& rhs) const { return lhs + rhs; } };
But when doing design, it’s good to consider several solutions and compare them. So let’s do it with default parameters.
Default template types
We’re going to use default template parameters here. Here is what I mean by that:
template<typename T = int>. // default parameter for T class MyClass { // ... };
Default template parameters have a few differences with default function parameters.
The first thing to note about default template parameters is that you still have to write the angle brackets <>
of the template:
MyClass<double> md; // instantiates a MyClass<double> MyClass<> mi; // instantiates a MyClass<int>
This makes sense because the class is still a template, and also because it’s symmetric with function calls that keep parentheses even when they only have default parameters. But it’s still worth noting as there is something unnatural about these empty <>
.
The second thing to note is that, contrary to default parameters in functions, default template parameters can depend on other parameters. Here is an example:
template<typename T, typename U = T> class MyClass { // ... };
As we saw in Default parameters in C++: the facts (including the secret ones), defaults parameters in function arguments cannot do that. The following code, for instance, is illegal:
void f(int x, int y = x) { // ... }
(And we saw there how we could achieve the same purpose by using overloads.)
Default parameters, a better alternative?
This is the part where I had to fumble around the most to get it right. To use a default parameter whose type is a template, you need to set this template parameter as optional too. Indeed, if you set it as a regular template parameter and call the function without specifying a value, the compiler can’t deduce the type for the template. Unless the template is deducible another way (with another argument, or specified explicitly at call site) in which case you don’t need a default type for the template.
So this is what it looks like for map_aggregator
:
template<typename Map, typename Function = std::plus<typename Map::mapped_type> > map_aggregate_iterator<Map, Function> map_aggregator(Map& map, Function aggregator = std::plus<typename Map::mapped_type>()) { return map_aggregate_iterator<Map, Function>(map, aggregator); }
Let’s examine the template line:
template<typename Map, typename Function = std::plus<typename Map::mapped_type> >
We’re using the specific feature of default template parameters: using the other parameters of the template (here the default parameter uses Map
).
And on the prototype:
map_aggregate_iterator<Map, Function> map_aggregator(Map& map, Function aggregator = std::plus<typename Map::mapped_type>())
…the default value instantiates an object (of type std::plus<typename Map::mapped_type
): note the parentheses ()
at the end (those could have been brackets {}
too).
In C++14, we no longer have to specify the template type to std::plus
and its siblings. So the interface becomes:
template<typename Map, typename Function = std::plus<> > map_aggregate_iterator<Map, Function> map_aggregator(Map& map, Function aggregator = std::plus<>{})
In conclusion, here are the 2 interfaces:
Default parameters:
template<typename Map, typename Function = std::plus<> > map_aggregate_iterator<Map, Function> map_aggregator(Map& map, Function aggregator = std::plus<>());
Advantages:
- it is concise,
- it shows that the default argument is
plus
, - it is clear that there is only one function.
Overloading:
template<typename Map> map_aggregate_iterator<Map, std::plus<typename Map::mapped_type> > map_aggregator(Map& map); template<typename Map, typename Function > map_aggregate_iterator<Map, Function> map_aggregator(Map& map, Function aggregator);
Advantages:
- the simpler case is not polluted with the potential extra argument,
- it’s consistent with the style of the STL.
I would tend to prefer the version using default parameters, because it ends up being much more concise, especially since C++14. Which one would you prefer, and why?
You may also like
Don't want to miss out ? Follow:   Share this post!