Thursday, March 10, 2022

Emulating template named arguments in C++20

std::unordered_map is a highly configurable class template with five parameters:

template<
    class Key,
    class Value,
    class Hash = std::hash<Key>,
    class KeyEqual = std::equal_to<Key>,
    class Allocator = std::allocator< std::pair<const Key, Value> >
> class unordered_map;

Typical usage depends on default values for most of these parameters:

using my_map=std::unordered_map<int,std::string>;

but things get cumbersome when we want to specify one of the usually defaulted types:

template<typename T> class my_allocator{ ... };
using my_map=std::unordered_map<
int, std::string,
std::hash<int>, std::equal_to<int>,
my_allocator< std::pair<const int, std::string> >
>;

In the example, we are forced to specify the hash and equality predicate with their default value types just to get to the allocator, which is the parameter we really wanted to specify. Ideally we would like to have a syntax like this:

// this is not actual C++
using my_map = std::unordered_map<
Key=int, Value=std::string,
Allocator=my_allocator< std::pair<const int, std::string> >
>;

Turns out we can emulate this by resorting to designated initializers, introduced in C++20:

template<
typename Key, typename Value,
typename Hash = std::hash<Key>,
typename Equal = std::equal_to<Key>,
typename Allocator = std::allocator< std::pair<const Key,Value> >
>
struct unordered_map_config
{
Key *key = nullptr;
Value *value = nullptr;
Hash *hash = nullptr;
Equal *equal = nullptr;
Allocator *allocator = nullptr;

using type = std::unordered_map<Key,Value,Hash,Equal,Allocator>;
};

template<typename T>
constexpr T *type = nullptr;

template<unordered_map_config Cfg>
using unordered_map = typename decltype(Cfg)::type;

...

using my_map = unordered_map<{
.key = type<int>, .value = type<std::string>,
.allocator = type< my_allocator< std::pair<const int, std::string > > >
}>;

The approach taken by the simulation is to use designated initializers to create an aggregate object consisting of dummy null pointers: the values of the pointers do not matter, but their types are captured via CTAD and used to synthesize the associated std::unordered_map instantiation. Two more C++20 features this technique depends on are:

  • Non-type template parameters have been extended to accept literal types (which include aggregate types such as unordered_map_config instantiations).
  • The class template unordered_map_config can be specified as a non-type template parameter of unordered_map. In C++17, we would have had to define unordered_map as
    template<auto Cfg>
    using unordered_map = typename decltype(Cfg)::type;
    which would force the user to explicit name unordered_map_config in
    using my_map = unordered_map<unordered_map_config{...}>;

There is still the unavoidable noise of having to use the type template alias since, of course, aggregate initialization is about values rather than types.

Another limitation of this simulation is that we cannot mix named and unnamed parameters:

// compiler error: either all initializer clauses should be designated
// or none of them should be
using my_map = unordered_map<{
type<int>, type<std::string>,
.allocator = type< my_allocator< std::pair<const int, std::string > > >
}>;

C++20 designated parameters are more restrictive than their C99 counterpart; some of the constraints (initializers cannot be specified out of order) are totally valid in the context of C++, but I personally fail to see why mixing named and unnamed parameters would pose any problem.