C++20のRangeライブラリの強力な機能、プロジェクション
English version is available at: Projection, a powerful feature in C++20 Ranges library
C++20のRangesライブラリにはプロジェクションという強力な機能が追加された。
例えば、人間を表現するクラスがあったとして、
struct Person
{
std::string name ;
std::string address ;
int age ;
int hegiht ;
int weight ;
} ;
そのvectorであるpersonsがあるとして、
std::vector<Person> persons ;
このpersonsを特定のデータメンバーでソートしたいとする。
これは比較関数を自分で書くことで可能になる。
std::sort( persons, []( auto & a, auto & b ) { return a.age < b.age ; } ) ;
しかしこんなコードは書きたくない。ボイラープレートにもほどがあるし、コンパイラーは間違えてもエラーも警告も出してくれない。例えば、
// 比較演算子を間違えている
std::sort( persons, []( auto & a, auto & b ) { return a.age > b.age ; } ) ;
あるいは、
// 2つの引数を比較していない
// コンパイラーは警告すらしてくれない
std::sort( persons, []( auto & a, auto & b ) { return a.age < a.age ; } ) ;
さらには、
// 比較するメンバーを間違えている
// 型が同じなのでコンパイラーは警告してくれない。
std::sort( persons, []( auto & a, auto & b ) { return a.age < b.height ; } ) ;
C++コンパイラーは上記のコードにエラーも警告も出さない。コードは完璧に合法で間違っていないからだ。コンパイラーはプログラマーの不文律の意図を推測してくれたりはしない。最近流行りの機械学習2.0とやらでも人間様の不文律の意図を判定しようなどということはできないだろう。たぶん。
C++20のレンジライブラリを使えばこの問題はプロジェクションという機能で解決できる。単にranges::lessとデータメンバーへのポインターを渡すと動く。
std::ranges::sort( persons, {}, &Person::age ) ;
なぜ動くのか。プロジェクションなしのranges::sortはだいたい以下のようになっている
auto sort = []( auto && range, auto comp = std::ranges::less{} )
{
// ...
// i, j はイテレーター
// 2つの要素を比較する
if ( comp( *i, *j ) )
// ...
} ;
ranges::sortにはプロジェクションという追加の引数がある。
auto sort = []( auto && range, auto comp = std::ranges::less{}, auto proj = std::identity{} ) ...
これは以下のように動く。
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というのは野暮ったい関数呼び出しだ。std::invoke( f, args... )は、fが関数の場合、f( args ... )と同じだ。そういう意味では、上記のコードは以下と等しい。
if ( comp( proj(*i), proj(*j) ) )
もし、fがデータメンバーへのポインターであり、args... が引数1つでクラス型のオブジェクトの場合、std::invoke( f, a )はa.*fに等しい。
if ( comp( (*i).*proj, (*j).*proj ) )
この場合で、i, jのvalue_typeがPersonで、projが&Person::ageの場合、以下のようになるわけだ。
if ( comp ( i->age, j->age ) )
なので動く。
std::invokeを使っているので、引数を取らないメンバー関数へのポインターを渡しても動く。
class Person
{
int age ;
public :
int get_age() const noexcept { return age ; } ;
} ;
int main()
{
std::vector<Person> persons ;
std::ranges::sort( persons, std::ranges::less{}, &Person::get_age ) ;
}
普通に関数オブジェクトをプロジェクションとして渡すこともできる。
std::ranges::sort( persons, {}, []( auto && n ) { return n.age ; } ) ;
このコードは少々長ったらしいが、C++17時代の古臭いコードよりはマシだ。というのもプロジェクション関数は1つの引数をどのようにプロジェクトするか、ということにしか責任を負わないので、上で上げたような些細なバグを引き起こすことはない。
他にはどのようなアルゴリズムがプロジェクションをサポートしているのか。だいたいのユーザーからの関数オブジェクトを取るアルゴリズムはプロジェクション関数オブジェクトを最後の引数に取る。
all_of( range, pred, proj ) ;
for_each( range, function, proj ) ;
ところで、std::ranges::transformもプロジェクションをサポートしている。
std::vector<bool> out ;
std::ranges::transform( persons, back_inserter( out )
, []( auto age ) { return age < 40 ; }
, &Person::age ) ;
このコードはpersonsからPersonの値をそれぞれ取り、データメンバーのageにプロジェクトし、条件付きでboolにトランスフォームし、vectorのoutにpush_backしている。
transformが受け取る関数オブジェクトはプロジェクションと同じなので、これは冗長のように思われるが、transformもプロジェクションをサポートすることで一貫性があるのと、トランスフォーム関数とプロジェクション関数をわざわざユーザー側で合成しなくてもすむようになる。
ところでtransfromといえば、std::ranges::view::transform_viewも関数オブジェクトはstd::invoke経由で呼んでいる。これはプロジェクションというわけではないが、プロジェクションと同じように動く。
for ( auto age : persons | transform( &Person::age ) )
std::cout << age << '\n' ;
このコードはレンジとしてpersonsを取り、データメンバーへのポインターを渡したtransfrom_viewを適用する。transform_viewはstd::invokeを使っているのでこれは動く。変数auto ageにはレンジ内のPersonの値それぞれのageが入る。
この記事はP1252提案が入ることを前提にしている。