C++: Pattern Matching Template Types
How to check if a template type matches a pattern? Something like is_like_v<T, vector<int,_>>
A previous post
considered function overload resolution in the presence of
forwarding references, and proposed a design pattern of
merging all the overloads of a function into one, using
if constexpr to implement the desired overload
resolution logic for various argument types. This was
restricted to using std::is_same_v and
std::decay_t for exact type matches, up to
const and ref qualifiers.
A follow up
post extended this to checks of whether a given type is
an instantiation of a given template, introducing a new type
trait is_instance_of_v, used as
e.g. is_instance_of_v<T, std::vector>.
In this post we go further again to implement pattern
matching on template types. Patterns are written as
e.g. A<_,_> for an instantiation of a
class template A with two template arguments,
or A<int,_> where the first template
argument must be int but the second may be
anything, or even nested patterns such as
B<_, A<int,_>>. We then introduce a
new type trait is_like_v to allow matching of
types against such patterns, used as
e.g. is_like_v<T, A<int,_>> or
is_like_v<T, std::vector<_, std::allocator<_>>.
Motivation
Consider a template type such as std::vector:
template<
class T,
class Allocator = std::allocator<T>
> class vector;It has two template parameters, T and
Allocator, the first giving the element type,
the second the allocator type. We may want to query whether
some type is an instantiation of std::vector
but with a specific element type, say int,
while allowing for any allocator type. Effectively we’re
looking for a partial match, a bit like a partial
specialization of a template. We cannot do this with
std::is_same_v or the
is_instance_of_v type traits used in previous
posts, however.
In this post we develop a new type trait
is_like_v that works a bit like
std::is_same_v, but with support for partial
matches using a wildcard denoted by the underscore
_ (using the underscore for fun,
mostly—alternatives are possible). It will be used something
like this:
if constexpr (is_like_v<T,vector<double,_>>) {
// ...do something
}Implementation
We start with a basic declaration of
is_like_v<T,P>, where T
denotes a type, and P a pattern against which
to match it. The pattern is itself a type, but it may
include the wildcard _.
The initial definition equates
is_like_v<T,P> with
std::is_same_v<T,P>, i.e. two types are
alike if they are the same:
template<class T, class P>
inline constexpr bool is_like_v = std::is_same_v<T,P>;Next, we introduce a type to denote wildcards in template
patterns. For fun and nice-looking patterns, we’re going to
make this the underscore _, although in
practice we should be wary of name collisions and reserved
words:
struct _ {};All types match the wildcard:
template<class T>
inline constexpr bool is_like_v<T,_> = true;The final piece is more complicated: two template types are alike if they are instantiations of the same class template, and their template arguments are alike:
template<template<class...> class T, class... Ts, class... Us>
requires (sizeof...(Ts) == sizeof...(Us))
inline constexpr bool is_like_v<T<Ts...>,T<Us...>> = (is_like_v<Ts,Us> && ...);Here we use a plentiful mix of modern C++ features:
template parameter
packs and the sizeof...
operator, template template arguments (as we did for is_instance_of_v),
constraints
and concepts (the requires) to check for
the same number of template arguments, and fold
expressions (the
(is_like<Ts,Us> && ...)).
But those few lines are all we need.
This does pattern matching up to const and ref
qualifiers. To ignore const and ref qualifiers, massage in
std::decay_t as in the first post.
Demonstration
We can now use is_like_v along the following
lines:
is_like_v<std::vector<int>, std::vector<int,_>>; // true
is_like_v<std::vector<int>, std::vector<_,_>>; // true
is_like_v<std::vector<int>, std::list<int,_>>; // false
is_like_v<std::vector<int>, std::list<_,_>>; // false
is_like_v<std::vector<int>, std::vector<_, std::allocator<_>>>; // trueWe can use such checks in if constexpr
statements to implement the body of a function with
forwarding reference arguments, as suggested in the first post, but also
other circumstances.
Bonus: one-of-a-set
Let’s get beyond single wildcards, and consider sets of
patterns as well. Given two or more patterns, we would like
is_like_v to give true if the given type
matches at least one of those patterns.
By way of motivation, consider a numerical library where
expression templates are used to represent mathematical
formulae that are lazily evaluated. A linear expression of
such as
may be represented by the type
Add<Mul<double,T>,double>, where
T is the generic type of
(either a simple arithmetic type such as double
or a nested expression template type). But because
multiplication and addition are commutative,
i.e.
and
,
a linear expression of
may emerge with any one of the types
Add<Mul<double,T>,double>,
Add<Mul<T,double>,double>,
Add<double,Mul<double,T>> or
Add<double,Mul<T,double>>.
How to match all of these?
Similar to the wildcard type _, we can
introduce a set type in that collects together
multiple patterns:
template<class... Ps>
struct in {};Now extend is_like_v with a new
specialization to handle the in type:
template<class T, class... Ps>
inline constexpr bool is_like_v<T,in<Ps...>> = (is_like_v<T,Ps> || ...);This uses a fold expression to give true if the type
T matches any of the patterns
Ps.... We can now use something like the
following:
is_like_v<T, in<
Add<Mul<double,_>,double>,
Add<Mul<_,double>,double>,
Add<double,Mul<double,_>>,
Add<double,Mul<_,double>>
>>;If the pattern is to be reused, or if we just prefer the aesthetic, we can declare the pattern first:
using linear_pattern = in<
Add<Mul<double,_>,double>,
Add<Mul<_,double>,double>,
Add<double,Mul<double,_>>,
Add<double,Mul<_,double>>
>;
is_like_v<T, linear_pattern>;Implementing a function with forwarding reference parameters may then look something like this:
template<class T>
void f(T&& x) {
if constexpr (is_like_v<std::decay_t<T>, linear_pattern>) {
// implementation for linear expression
std::cerr << "f(linear x)" << std::endl;
} else {
// implementation for everything else
std::cerr << "f(T&& x)" << std::endl;
}
}This approach may reduce code size compared to separate function overloads for each of the linear expression types.
Limitations
There are some limitations to pattern matching template types in this way.
Because the wildcard _ is defined as a type,
it cannot be substituted for non-type template parameters.
That precludes its use where value template parameters are
involved, as for the second argument of
std::array (which gives the static size of the
array):
template<
class T,
std::size_t N
> struct array;or where template template parameters are involved, as
for the is_instance_of_v
introduced previously.
Where duck-typed class templates are involved, we get
away with using _ as a template argument in
patterns because, while those patterns are types, they are
not used in such a way that requires their instantiation. On
the other hand, where class templates with concept or SFINAE
constraints are involved, _ may be rejected as
a template argument prior to instantiation, in which case
the approach will not work.
The same applies to the set type in.
A workaround for some of these limitations is to
specialize is_like_v for particular types.
Consider std::array again; if appropriate for
the use case, we could specialize is_like_v to
match the value type T, but ignore the length
D:
template<class T, size_t M, class U, size_t N>
inline constexpr bool is_like_v<std::array<T,M>,std::array<U,N>> = is_like_v<T,U>;Where concept or SFINAE constraints are in use, we might
also specialize those constraints for the wildcard
_ and set in types to satisfy them
artificially. How will depend on the specifics of the
constraints, but will likely involve the specialization of
certain other type traits for these two types.
Mileage will vary, and the implementation of
is_like_v may need to be specific to the
circumstances.
Conclusion
This is the final of three posts inspired by the idea of establishing a design pattern for overloading functions in the presence of forwarding references, with greater control over overload resolution. Putting them all together, the suggested paradigm is to:
- Combine all function overloads into one with forwarding reference parameters.
- Within the body of that one function, use
if constexprto provide implementations for different argument types. - Within each
if constexprcondition, use type traits such asstd::is_same_v, or the new type traits introduced in these posts,is_instance_of_vandis_like_v, to check for types. - Within each
if constexprbody, provide the appropriate implementation for those types.
Taking this approach can reduce repeated code in situations where multiple function overloads would otherwise be required, especially so where several of those overloads would have essentially the same implementation. It can also provide finer control over behavior where the usual overload resolution rules do not yield the desired precedence, as the body of the one function can be implemented with arbitrary selection logic for the argument types.
Here’s a complete example for further experimentation
with is_like_v:
#include <type_traits>
#include <iostream>
#include <vector>
#include <list>
#include <array>
template<class T, class P>
inline constexpr bool is_like_v = std::is_same_v<T,P>;
struct _ {};
template<class T>
inline constexpr bool is_like_v<T,_> = true;
template<template<class...> class T, class... Ts, class... Us>
requires (sizeof...(Ts) == sizeof...(Us))
inline constexpr bool is_like_v<T<Ts...>,T<Us...>> = (is_like_v<Ts,Us> && ...);
template<class T, size_t M, class U, size_t N>
inline constexpr bool is_like_v<std::array<T,M>,std::array<U,N>> = is_like_v<T,U>;
template<class... Ps>
struct in {};
template<class T, class... Ps>
inline constexpr bool is_like_v<T,in<Ps...>> = (is_like_v<T,Ps> || ...);
int main() {
std::cerr << is_like_v<std::vector<int>, std::vector<int,_>> << std::endl; // true
std::cerr << is_like_v<std::vector<int>, std::vector<_,_>> << std::endl; // true
std::cerr << is_like_v<std::vector<int>, std::list<int,_>> << std::endl; // false
std::cerr << is_like_v<std::vector<int>, std::list<_,_>> << std::endl; // false
std::cerr << is_like_v<std::vector<int>, std::vector<_, std::allocator<_>>> << std::endl; // true
std::cerr << is_like_v<std::array<int,10>, std::array<_,20>> << std::endl; // true, due to specialization
using pattern = in<
std::vector<_,std::allocator<_>>,
std::list<_,std::allocator<_>>
>;
std::cerr << is_like_v<std::vector<int>,pattern> << std::endl; // true
return 0;
}