Tricks with Default Template Arguments
Just like regular function parameters, template parameters can also have default parameters. For class templates, this behaves mostly just like default function arguments: if you pass fewer template arguments than required, default template arguments are used to fill the remaining places. However, for function templates, it gets more complicated as template parameters for functions can be deduced by the normal function arguments. This leads to some interesting side-effects. In particular, default arguments of template parameters don’t need to be put at the end!
Let’s take a look at a couple of things we can do with default template arguments.
Trick 1: Default template arguments can depend on other parameters
Suppose you want to write a function takes some strings using a C style API.
You want to support both a single const char*
for null-terminated strings and a const char*
plus std::size_t
for other ones.
void foo(const char* ptr, std::size_t size)
{
…
}
void foo(const char* str)
{
foo(str, std::strlen(str));
}
Not happy with the need for the second overload you try a default function argument:
void foo(const char* ptr, std::size_t size = std::strlen(ptr))
{
…
}
Now if someone calls the function with pointer and size, that size will be used. Otherwise, the length of the string. It just doesn’t compile: the value of a default function argument cannot depend on other parameters.
Such (silly?) restrictions don’t apply to default template arguments! And you’ve all relied on this feature a lot:
// Default Allocator depends on T.
template <typename T, typename Allocator = std::allocator<T>>
class vector;
// Default Traits and Allocator depend on T.
template <typename T, typename Traits = std::char_traits<T>, typename Allocator = std::allocator<T>>
class basic_string;
// etc.
One particular use case I’ve had recently are class templates that take an iterator/sentinel pair. In most cases, the iterator and sentinel type are the same, so you default the sentinel argument:
template <typename Iterator, typename Sentinel = Iterator>
struct my_range
{
Iterator begin;
Sentinel end;
};
Trick 2: Help Type Deduction
The C++ standard library has a handy little function called std::exchange()
, which assigns a new value to an object and returns the old one.
template <typename T, typename U>
T exchange(T& obj, U&& new_value)
{
T old_value = std::move(obj);
obj = std::forward<U>(new_value);
return old_value;
}
This function allows a couple of nice patterns. For example, we can move a string out of a container and replace it with the empty string:
std::vector<std::string> strings;
…
auto str = std::exchange(strings[i], "");
Writing
auto str = std::move(strings[i])
would not be enough, as the moved-from state of astd::string
isn’t necessarily the empty string.
This can be seen as part of more general idiom of exchanging an object with a default constructed one.
In case you’re familiar with Rust, it’s done by a function called std::mem::take()
.
In C++ we can write it in a nice concise way using std::exchange()
:
auto value = std::exchange(obj, {});
The {}
gives us a default constructed object that we’re exchanging with obj
.
Except the code doesn’t actually compile with the definition of exchange()
I’ve given above.
This is because exchange()
has two template parameters, T
and U
, both deduced from the types of their corresponding function arguments.
However, a braced initializer has no type, so the compiler is unable to deduce a type for U
.
In order to make it work, we need to tell the compiler that U
should be the same type as T
if it is unable to deduce a type for U
.
This is done – you guessed it – with a default template argument:
template <typename T, typename U = T>
T exchange(T& obj, U&& new_value);
Now the compiler first tries to deduce the type of U
using the second argument.
If that fails due to a braced initializer, the compiler will use the default type and turn new_value
into an rvalue reference to T
.
Whenever you have a function that should support a braced initializer by defaulting the template parameter to some type, use a default template argument.
The standard library does it with std::exchange()
, and should also do it with std::optional<T>::value_or()
or std::fill()
.
// The optional value or a default constructed one.
auto value = opt.value_or({});
// Fill with default value.
std::fill(begin, end, {});
There is a proposal for
std::optional<T>::value_or()
already.
Trick 3: The two parameter sets of function templates
If you have a function template, some template parameters are meant to be deduced by the function arguments,
and some are meant to be explicitly specified by the caller.
An example is std::make_unique
:
template <typename T, typename ... Args>
std::unique_ptr<T> make_unique(Args&&... args);
The type T
has to be passed by the caller, whereas the Args
are deduced from the function arguments.
You can’t ask the compiler to deduce T
because it doesn’t appear as a function argument,
and you really shouldn’t explicitly specify the types of Args
(you’re going to get them wrong eventually).
I like to mentally split the template parameters in two:
// Pseudo-code.
template <typename T> // explicit
template <typename ... Args> // deduced
std::unique_ptr<T> make_unique(Args&&... args);
template <> // no explicit
template <typename T, typename U = T> // deduced
T exchange(T& obj, U&& value);
template <typename T> // explicit
template <> // no deduced
… forward(T…); // (signature complicated)
I really wish C++ had
typename
parameters for functions:template <typename ... Args> std::unique_ptr<T> make_unique(typename T, Args&&... args);
When you look at it that way, it becomes immediately obvious why the compiler allows non-trailing default template parameters:
they’re just at the end of the explicit template parameter set.
So we could write a version of make_unique
that defaults to int
(examples are hard):
template <typename T = int, typename ... Args>
std::unique_ptr<T> make_unique(Args&&... args);
// or in pseudo-C++:
template <typename T = int> // explicit
template <typename ... Args> // deduced
std::unique_ptr<T> make_unique(Args&&... args);
Calling make_unique<float>(42)
deduces Args
and sets T
to float
, whereas make_unique(42)
deduces Args
and sets T
to int
(the default).
Of course, you can also always use a separate overload without the explicit T
parameter, but I find overload resolution more difficult to do in my head than just having a single function.