Projection, a powerful feature in C++20 Ranges library
日本語版: C++20のRangeライブラリの強力な機能、プロジェクション
I'm Ryou Ezoe. Today, I'm going to write about the projection, a powerful feature in C++20 Ranges library.
Suppose, you have a class that represents a person:
struct Person
{
std::string name ;
std::string address ;
int age ;
int height ;
int weight ;
} ;
And a vector of persons.
std::vector<Person> persons ;
Naturally, you want to sort persons by a specific data member.
How can we do that? You can write your own compare function.
std::sort( persons, []( auto & a, auto & b ) { return a.age < b.age ; } ) ;
I don't want to write this. Not only for its long boilerplate code, but also for the compiler can't catch the obvious bugs like this.
// using a wrong comparison operator
std::sort( persons, []( auto & a, auto & b ) { return a.age > b.age ; } ) ;
Or this.
// It doesn't compare the two parameters.
// Compiler don't warn it because it's perfectly well-formed code.
std::sort( persons, []( auto & a, auto & b ) { return a.age < a.age ; } ) ;
Or this.
// comparing wrong data members.
// the types are same so compiler don't warn it.
std::sort( persons, []( auto & a, auto & b ) { return a.age < b.height ; } ) ;
The C++ compiler cannot warn these codes because it's perfectly well-formed code. The compiler can't guess the programmer's unwritten intention and the last time I checked, nobody seriously researched on using trending machine learning 2.0 based solution which can guess the unwritten intention.
The C++20 Ranges got you covered on this problem with the projection. You can simply pass the ranges::less and a pointer to the data member as arguments and it just works.
std::ranges::sort( persons, {}, &Person::age ) ;
Why does it work? the ranges::sort without projection works like this.
auto sort = []( auto && range, auto comp = std::ranges::less{} )
{
// ...
// i, j are iterators
// compare two elements for ordering
if ( comp( *i, *j ) )
// ...
} ;
But ranges::sort has a extra parameter for projection.
auto sort = []( auto && range, auto comp = std::ranges::less{} , auto proj = std::identity{} ) ...
And it works like this.
auto sort = []( auto && range, auto comp = std::ranges::less{} , auto proj = std::identity{} )
{
// ...
if ( comp( std::invoke( proj, *i), std::invoke( proj, *j ) ) )
// ...
}
std::invoke is an ugly version of function call. std::invoke( f, args.. ) is equivalent to f( args ... ) if the f is function. In that sense, above code is equivalent of
if ( comp( proj(*i), proj(*j) ) )
But if the f is a pointer to a data member, and args... has exactly one argument which is a object of class type, std::invoke( f, a ) is equivalent to a.*f ;
if ( comp( (*i).*proj, (*j).*proj ) )
So if the iterator i, j's value_type to Person, and proj is &Person::age, that is our case, it works like this.
if ( comp ( i->age, j->age ) )
Thus it just works.
Since it use std::invoke, you can also pass the pointer to member function which takes no argument and it just works.
class Person
{
int age ;
public :
int get_age() const noexcept { return age ; }
} ;
int main()
{
std::vector<Person> persons ;
std::ranges::sort( persons, {}, &Person::get_age ) ;
}
You can also pass function object too.
std::ranges::sort( persons, {}, []( auto && n ) { return n.age ; } ) ;
This code looks like boilerplate too you. But it's actually better than C++17 era code. Because projection function only deal with one parameter and how to project that parameter. You don't need to write the rest of boilerplate code so you are immune from above typical problems.
So, what other algorithms support the projection? Well, most of them. Those which take a function object from user also take the projection function object in the last parameter.
all_of( range, pred, proj ) ;
for_each( range, function, proj ) ;
It's also interesting that std::ranges::transform also support the projection.
std::vector<bool> out ;
std::ranges::transform( persons, back_inserter( out )
, []( auto age ) { return age < 40 ; }
, &Person::age ) ;
This code take each Person value from persons, project it to it's data member age, then transform it to bool with certain condition, and push_back it to the out vector.
Since transform's user supplied function object is essentially same with projection, this feels odd. But it's good for consistency and you don't need to precombine the function object and projection by yourself.
Speaking of transform, std::ranges::view::transform_view call function with std::invoke too. Although this isn't a projection in strictly speaking, but it works like a projection.
for ( auto age : persons | transform( &Person::age ) )
std::cout << age << '\n' ;
This code take a range(persons), then apply transform_view which is just a pointer to a data member. Since transform_view call function by std::invoke, it just works. and variable auto age take each age value of Person object inside the range.
Above article assumes that P1252 will be accepted.