C++20 - Free Functions
Step 0: The Start
To start let’s just get something going:
#include <concepts>
#include <tuple>
#include <type_traits>
template<typename FuncType, typename... ArgTypes>
inline constexpr bool HasValidFunctorParams = true;
struct Small{};
struct Large{ char Data[32];};
struct SmallNotTrivial{ SmallNotTrivial(const SmallNotTrivial&){} };
static_assert(!std::is_trivially_copyable_v<SmallNotTrivial>);
namespace FreeFunc
{
void SmallByValue(Small, Small){}
void LargeByValue(Large, Large){}
void SmallNotTrivialByValue(SmallNotTrivial, SmallNotTrivial){}
}
static_assert( HasValidFunctorParams<decltype(FreeFunc::SmallByValue), Small, Small>);
static_assert(!HasValidFunctorParams<decltype(FreeFunc::LargeByValue), Large, Large>);
static_assert(!HasValidFunctorParams<decltype(FreeFunc::SmallNotTrivialByValue), SmallNotTrivial, SmallNotTrivial>);Note
The title of the snippet above can be clicked and it will take you to a godbolt of this code for you to play with
This code does not compile… yet. Our type trait, HasValidFunctorParams, always returns true so the last 2 static_asserts will fail.
The rest of this part of the series will be to implement HasValidFunctorParams so that it will pass these tests and more.
But before we go any further let’s explain what is going on here, and what our intent is.
The type trait
template<typename FuncType, typename... ArgTypes>
inline constexpr bool HasValidFunctorParams = true;This will be the type trait that will be the subject of this blog series. We will be building this up, piece-by-piece, into a type trait that can inspect and validate the parameters of any callable that we throw at it.
For right now though, it is a bit useless and just returns true… Everything needs to start somewhere and this is our starting point :)
The test types
struct Small{};
struct Large{ char Data[32];};
struct SmallNotTrivial{ SmallNotTrivial(const SmallNotTrivial&){} };These are the types that we will be using in our tests throughout this blog series. We have:
Small-> A small trivially copyable type that we are perfectly happy with people copyingLarge-> A “large” trivially copyable type. The idea here is that it is too large to want to copy. Your definition of “large” might be very different :)SmallNotTrivial-> A small type that is not trivially copyable. We don’t want to copy this type because the copy could be much more expensive than a simplememcpy
The initial tests
namespace FreeFunc
{
void SmallByValue(Small, Small){}
void LargeByValue(Large, Large){}
void SmallNotTrivialByValue(SmallNotTrivial, SmallNotTrivial){}
}
static_assert( HasValidFunctorParams<decltype(FreeFunc::SmallByValue), Small, Small>);
static_assert(!HasValidFunctorParams<decltype(FreeFunc::LargeByValue), Large, Large>);
static_assert(!HasValidFunctorParams<decltype(FreeFunc::SmallNotTrivialByValue), SmallNotTrivial, SmallNotTrivial>);These are the initial functions to test, and their tests. Only SmallByValue here should pass our type trait. HasValidFunctorParams should return false when Large and SmallNotTrivial are the parameters.
Step 1: FunctionTraits
The first utility we will need to write is one that can allow us to decompose a function type into its return type and parameter types:
template<typename FuncType>
struct FunctionTraits;
template<typename RetType, typename... ParamTypes>
struct FunctionTraits<RetType(ParamTypes...)>
{
using ParamList = std::tuple<ParamTypes...>;
static constexpr auto NumParams = sizeof...(ParamTypes);
};This is using an old trick that has been used since variadic templates came around in C++11.
Tip
Andrei has a fantastic (and hilarious) talk from 2012 all about working with variadics. This is the timestamp when he talks about this particular trick.
We declare the primary template for FunctionTraits. We do not define it. The template parameter is the function type that we want to decompose into its parameter and return types.
Then we specialize FunctionTraits for function types, and this is how we can decompose the function type into its RetType and ParamTypes. We don’t really care about the RetType here. Only the ParamTypes matter.
We are using a std::tuple here as a convenient type list.
Step 2: The type predicate
Next thing we need is the predicate that we are going to evaluate each parameter type with. I am going to call this one IsValid:
template<typename T>
bool IsValid()
{
return sizeof(T) <=16 && std::is_trivially_copyable_v<T>;
}Fairly simple.
Step 3: AllOf
At this point we can:
- Get a parameter type list
- Query if a type
IsValid
So the next step is being able to apply a predicate to every type in a type list. This is where AllOf comes in:
template<typename... Ts, typename FuncType>
constexpr bool AllOf(std::tuple<Ts...>, FuncType InFunc)
{
return (InFunc.template operator()<Ts>() && ...);
}Note
This is the first time we have used anything beyond C++14. This can be written in C++14… just in a more verbose and recursive way with template instantiations
There are a couple of things going on here that might look a little odd to some:
InFunc.template operator()<Ts>()-> this is how you call a callable which has a templated call operator and the template arguments can’t be deduced from the function arguments. We have to use.templatebecauseoperator()is a template rather than an object or function, and becauseInFunc’s type is a dependent name.(<expr> && ...)-> This is a C++17 fold expression and it is simply just evaluating our expression for every type inTs, and then&&ing all the results together. This is effectively a compile time range for loop over types. Very handy.
Step 4: Putting it all together
Ok we now have all of the necessary components to implement a basic HasValidFunctorParams
template<typename FuncType, typename... ArgTypes>
inline constexpr bool HasValidFunctorParams =
[]()
{
using ParamTypes = FunctionTraits<FuncType>::ParamList;
return AllOf(ParamTypes{},
[]<typename T>()
{
return IsValid<T>();
});
}();We grab our std::tuple<ParamTypes...> from FunctionTraits and then use AllOf to iterate through the type list and apply a lambda to each type which simply calls our IsValid function.
This is the first time we have dipped into the C++20 feature set. Explicit type parameters for lambdas are a C++20 feature. We could make this work with earlier versions by creating our own std::type_identity e.g.
template<class T>
struct type_identity { using type = T; };
int main()
{
auto func =
[](auto val)
{
return sizeof(typename decltype(val)::type);
};
return func(type_identity<double>{}) < 4 ? 0 : 1;
}But that is… not great and introduces a lot of noise, so I am going to stick with the C++20 version.
Note
With this C++20 feature, lambdas now make use of all bracket types in their definition. []<>(){}. Fantastic!
There are two things to note at this point:
- Our tests are passing for the first time! Happy days!
- By using
std::tupleas a stand-in for a type list, and having to construct an object ofstd::tuple<ParamTypes...>we have put an unfortunate constraint on the types that can be evaluated by this type trait… They must be default constructible. Which is why ourSmallNotTrivialtype has sneaked in a defaulted default constructor.
This is a great start. In the most basic case of a free function, we can iterate through the function parameter list, and perform some operation on each parameter type. But I would rather not put unnecessary constraints on the types that can be used…
Step 5: Implementing our own TypeList
Now we are going to do something that Andrei predicted we would do…
template<typename... Ts>
struct TypeList{};This is all we need for a type list for now. And we can swap all mentions in our code that used std::tuple to using TypeList and it all just works. And in fact, it works better because now we can default construct a TypeList without needing any of its types to be default constructible!
So now we can get rid of the default constructor in SmallNotTrivial and everything still continues to work! Awesome!
Step 6: Handling references
One last step before I am happy to say that we are done with free functions. What about parameters that are references? This whole endeavour is to prevent expensive copies when calling callables. Well, passing by reference is pretty cheap. So we should probably cover that case:
template<typename T>
constexpr bool IsValid()
{
return
(sizeof(T) <=16 && std::is_trivially_copyable_v<T>) ||
std::is_reference_v<T>;
}
// Then add some new tests
namespace FreeFunc
{
void SmallByRef(const Small&, const Small&){}
void LargeByRef(Large&, Large&){}
void SmallNotTrivialByRef(SmallNotTrivial&, SmallNotTrivial&){}
}
static_assert(HasValidFunctorParams<decltype(FreeFunc::SmallByRef), Small, Small>);
static_assert(HasValidFunctorParams<decltype(FreeFunc::LargeByRef), Large, Large>);
static_assert(HasValidFunctorParams<decltype(FreeFunc::SmallNotTrivialByRef), SmallNotTrivial, SmallNotTrivial>);Wrapping up
We have taken the first step in implementing a type trait which can validate whether our callables don’t do expensive copies when calling them… if the callable is a pure function that is. To do this we have learned about the following:
- template specialization
- variadic templates
- fold expressions
- explicit type parameters for lambdas
- Andrei Alexandrescu’s great talks on template meta programming.
Now of course we want our type trait to work on more than just free functions. So in the next article in this series we will start delving into callable objects.