Last Update:
C++20 Ranges, Projections, std::invoke and if constexpr
Table of Contents
Continuing the topic from last week, let’s dive into the topic of std::invoke
. This helper template function helps with uniform syntax call for various callable object types and can greately reduce the complexity of our generic code.
Ranges and Projections
In C++20 there are handful of rangified algorithms. As a simple example let’s say we want to sort a vector of integers:
#include <algorithm>
#include <iostream>
#include <ranges>
#include <vector>
int main(){
std::vector vec { -1, 2, -3, 4, -5, 6 };
auto print = [](int i) { std::cout << i << ", "; };
std::ranges::sort(vec);
std::cout << "regular sort:\n";
std::ranges::for_each(vec, print);
}
This will output:
regular sort:
-5, -3, -1, 2, 4, 6,
As you can see in the example above we can call std::ranges::sort(vec)
. There’s no need to add .begin()
and .end()
calls.
But what’s interesting is that most of those new algorithm overloads also support “Projections”. In short, this allows us to modify the values coming from the container and pass those projected values to the algorithm. It’s something like calling std::transform
before applying the desired algorithm.
For example we can project input values and use std::abs()
:
int main(){
std::vector vec { -1, 2, -3, 4, -5, 6 };
auto print = [](int i) { std::cout << i << ", "; };
std::cout << "with abs() projection: \n";
std::ranges::sort(vec, {}, [](int i) { return std::abs(i); });
std::ranges::for_each(vec, print);
}
And now we have the following output:
with abs() projection:
-1, 2, -3, 4, -5, 6,
In the example I pass vec
as the first argument, then {}
means the default template argument - in this case, it’s ranges::less
as a comparator and then our Projection which is a callable which takes a single argument.
See the full live code @Wandbox.
We can also do some other tricks. For example, through projection, we can “extract” a data member from an element which is some class type and use it for the algorithm.
See here:
struct Task {
std::string desc;
unsigned int priority { 0 };
};
int main(){
std::vector<Task> tasks {
{ "clean up my room", 10 }, {"finish homework", 5 },
{ "test a car", 8 }, { "buy new monitor", 12 }
};
auto print = [](Task& t) {
std::cout << t.desc << ", priority: " << t.priority << '\n';
};
std::ranges::sort(tasks, std::ranges::greater{}, &Task::priority); // <<
std::cout << "my next priorities:\n";
std::ranges::for_each(tasks, print);
}
Quite handy… right? :) See the live code @Wandbox.
There’s no need to use custom comparator, as we can project “values” as we want.
How does it work then?
Let’s see the declaration of ranges::sort
at cppreference, there’s a following description of how the function works:
A sequence is sorted with respect to a comparator
comp
if for any iteratorit
pointing to the sequence and any non-negative integern
such thatit + n
is a valid iterator pointing to an element of the sequence:
std::invoke(comp, std::invoke(proj, *(it + n)), std::invoke(proj, *it))
evaluates to
false
.
In this sentence, we can read that values obtained from the input range are passed to proj
via std::invoke
. What’s more ranges::sort
also uses this template function to call the comparator.
Ok, so what’s this std::invoke
?
std::invoke
, C++17
The primary motivation for this helper function is the issue with a non-uniform syntax for various callable objects.
For example, if you have a regular function object, you can just call:
func(args...)
But if you have a pointer to a member function then the syntax is different:
(obj.*funcPtr)(args...)
This might be an issue when you write a function template like:
template <typename T, typename F>
void CallOnRange(T& container, F f) {
for (auto&& elem : container)
f(elem);
}
std::vector v { 1, 2, 3, 4 };
CallOnRange(v, [](int i) { std::cout << i << '\n'; });
CallOnRange
works nicely for a regular function object type (like a lambda or a function pointer), but won’t work on pointers to member functions. In that case, we need to make additional overload:
template <typename TCont, typename Type, typename U>
void CallOnRange(TCont& container, Type U::* f)
{
for (auto&& elem : container)
(elem.*f)();
}
See the experiments at @Wandbox
That’s why, for those special cases, we can use std::invoke
which gives us uniform syntax call:
template <typename T, typename F>
void CallOnRangeInvoke(T& container, F f)
{
for (auto&& elem : container)
std::invoke(f, elem);
}
In short invoke(f, t1, t2, ..., tN)
(proposed in N4169 and accepted for C++17) can handle the following cases::
- function objects: like
func(arguments...)
- pointers to member functions
(obj.*funcPtr)(arguments...)
+ pointers and references - pointer to member data
obj.*pdata
+ pointers and references
As you can see, this expression creates a nice abstraction over several options that you can “call” something. No matter if that’s a pointer to a member function, a regular callable object, or even a data member.
How it works?
In principle, the function has to check the type of the callable object and then make a right call to and forward the arguments.
Fortunately, since C++17 all of those checks can be done with a relatively easy way! There’s no need for complicated SFINAE tricks, and in most of the cases the code can leverage if constexpr
.
To understand the code, we can look at the sample implementation @cppreference.
The main function std::invoke
wraps the call to the INVOKE
template function that has two overloads:
Here’s one for a regular function:
template <class F, class... Args>
constexpr decltype(auto) INVOKE(F&& f, Args&&... args) {
return std::forward<F>(f)(std::forward<Args>(args)...);
}
And then the overload for pointers to member functions or for data members:
template <class T, class Type, class T1, class... Args>
constexpr decltype(auto) INVOKE(Type T::* f, T1&& t1, Args&&... args) {
if constexpr (std::is_member_function_pointer_v<decltype(f)>) {
if constexpr (std::is_base_of_v<T, std::decay_t<T1>>)
return (std::forward<T1>(t1).*f)(std::forward<Args>(args)...);
else if constexpr (is_reference_wrapper_v<std::decay_t<T1>>)
return (t1.get().*f)(std::forward<Args>(args)...);
else
return ((*std::forward<T1>(t1)).*f)(std::forward<Args>(args)...);
} else {
static_assert(std::is_member_object_pointer_v<decltype(f)>);
static_assert(sizeof...(args) == 0);
if constexpr (std::is_base_of_v<T, std::decay_t<T1>>)
return std::forward<T1>(t1).*f;
else if constexpr (is_reference_wrapper_v<std::decay_t<T1>>)
return t1.get().*f;
else
return (*std::forward<T1>(t1)).*f;
}
}
One note: in C++17 std::invoke
wasn’t specified with constexpr
, it was added in C++20.
Thanks to if constexpr
(added in C++17) we can read this function in a “normal” way. As we can see the function checks
- if the callable is a
is_member_function_pointer
- this is a type trait available in the standard library, see here - otherwise we can assume that it’s a pointer to a non-static data member. For this case, there can be no arguments passed, only the object itself.
Here’s a simple code that demonstrates pointers to non static data members:
struct GameActor {
std::string name;
std::string desc;
};
int main(){
std::string GameActor::* pNameMember = &GameActor::name;
GameActor actor { "enemy", "super evil" };
std::cout << actor.name << " is " << actor.desc << '\n';
actor.*pNameMember = "friend";
pNameMember = &GameActor::desc;
actor.*pNameMember = "very friendly";
std::cout << actor.name << " is " << actor.desc << '\n';
}
See the code @Wandbox
If we look closer in the function implementation, you can also spot that std::invoke
then have three more cases:
- regular call - no dereferencing needed
- via reference wrapper - so we have to call
.get()
to get the object - in other cases we assume it’s a pointer and then we need to dereference it. This supports, for example, smart pointers.
struct GameActor {
std::string name;
std::string desc;
};
int main(){
GameActor actor { "robot", "a friendly type" };
std::cout << "actor is: " << std::invoke(&GameActor::name, actor) << '\n';
auto ptr = std::make_unique<GameActor>("space ship", "slow");
std::cout << "actor is: " << std::invoke(&GameActor::name, ptr) << '\n';
}
See code @Wandbox
We can also look at more sophisticated, production-ready implementation at MSVC/STL code here @Github. Surprisingly the code for invoke
is located in the type_traits
header and not in <functional>
.
Summary
Through this post, I showed the motivation and examples where std::invoke
plays a crucial part. Since C++17, we got the ability to have a ‘uniform" syntax for calling various function objects, including even pointers to non-static data members or member functions. And throughout the Standard Library, you can find lots of examples where this pattern can significantly simplify the code. And this is even more important with C++20 Ranges.
By the way, if you want to read more about if constexpr
then please visit my other blog post: Bartek’s coding blog: Simplify code with ‘if constexpr’ in C++17.
I've prepared a valuable bonus for you!
Learn all major features of recent C++ Standards on my Reference Cards!
Check it out here: