C++14 Streams を使った関数型のデータ処理

C# の LINQ、Java の Stream のように「関数型のデータ処理」が多くの言語で取り入れられてきています。 そんな中、C++11,14 での機能追加によって、 C++ でもとうとう Steams ライブラリーを使えば、それができるようになりました。
そこで今回はこの関数型のデータ処理の魅力と Streams の使い方について紹介したいと思います。

関数型データ処理の魅力

関数型データ処理の何が素晴らしいのかを端的にいえば、従来よりもコードを短くかつわかりやすく記述することができることです。

範囲 for の魅力

C++11 では 範囲に基づく for 文 という機能も追加されました。 関数型データ処理の良さの説明のために、まず「範囲 for 」の良さを見てみます。

例えば、 STL コンテナーの各要素に対して、何か処理をする場合、 vector であればループカウンターを回すと思います。 より汎用性を高めるにはイテレーターを使うでしょう。
  vector<int> ary = { 3,2,9,6 };

  for (auto itr = ary.begin() ; itr != ary.end() ; itr++) {
    cout << *itr << endl;
  }  
これを 範囲 for を使って書くと次のようになります。
  vector<int> ary = { 3,2,9,6 };

  for (auto elem : ary) {
    cout << elem << endl;
  }  
比べてみると 何故この機能が追加されたのか分かりやすいのではないでしょうか?
カウンターやイテレーターをいじる操作が無くなるので、短く書けています。 それでいて、余計な処理がないので、コンテナーの要素に対する何か処理しているという意図が分かり易くなっています。

関数型データ処理

関数型データ処理の魅力は 範囲 for に通じるものです。 範囲 for では逐次処理しかできませんが、それを多くの処理に展開したものと言えます。

範囲 for は見方を変えると 要素を引数とした処理を行うコードの固まり(コードブロック)をコンテナーに適用しています。 関数型データ処理ではこのコードブロックの記述に関数内に関数定義が書けるラムダ式を使っています。
  vector<int> ary = { 3,2,9,6 };

  MakeStream::from(ary) |
      for_each([](auto elem) {
          cout << elem << endl;
        });
なお、先ほどの for やラムダ式の引数を (auto elem) にしていますが、 これは要素が整数だからです。サイズの大きな要素の場合はもちろん (const auto &elem) にしておく必要があります。

また、詳しくは後述しますが、 処理を連結できることも分かりやすさを向上しています。 その際、遅延評価によって、効果的な連結になるようになっています。

言語への広がり

関数型のデータ処理というのは特に呼び名がなかったので、 関数型プログラミングでよく使われていることから、私がてきとうに付けた名前です。
関数型のパラダイムでは変数を変更することができません(参照透過性)。 そのため、ループカウンターなどは使えず、こういった高階関数を使うスタイルになります。


関数型というと 「関数型言語は C++ や Java のようなオブジェクト指向言語よりも短く書ける」 と以前は言われていました。
しかし、そのコードが短くなる理由のほとんどが関数型のデータ処理にあります。 このデータ処理スタイルは「関数型でなければ使えない」 といったものではありません。 実際、今では多くの言語で取り入れられてきており、そのメリットを享受することができます。 なかでも、Ruby は言語レベルでコードブロックの機能を持ち、高階関数を使うよりもエレガントに書けるようになっています。

C++14 Streams

関数型のデータ処理を C++ で使えるようにするためのライブラリーが Streamsです。 その実現には C++11, 14 の機能を使っており、 C++14 以上に対応していないとコンパイルは通りません。

ただ、 Java の Streams から来ているのでしょうが、 C++ では stream というのはすでにあるので、名前が紛らわしいです。 処理のイメージとしては stream であっているのですが、「もうちょっと名前はなんとかならなかったのかな」 とは思います。


Streams はヘッダーをインクルードすれば、使えるタイプのライブラリーです。
ヘッダーはホームページの [Download Streams] のリンクからからダウンロードできます。

cpp_streams_dl.png


圧縮ファイルを展開した source ディレクトリーにヘッダーファイルがあります。 インクルードパスに追加し、 "Streams.h" をインクルードすると使えるようになっています。
#include "Stream.h"
Streams ライブラリーのクラスはすべて stream の名前空間で定義されています。
また、 +, - などの演算子のオーバーロードが定義されていますが、 そちらは stream::op の名前空間です。
使用する場合 stream:: をつけるか、 using を使うかです。演算子を使う場合は using を使います。
以降のサンプルでは using を使ったとして、名前空間は記述していません。
using namespace stream;
using namespace stream::op;

Streams の対象となるデータ

Streams の対象となるデータは 配列 と std::vector のような STL コンテナー などです。
ただ、そのままでは適用できないので、 MakeStream::from() の関数で Stream のオブジェクトにします。
Stream<T> MakeStream::from(const Container& cont);
Stream<T> MakeStream::from(T* arr, size_t length);
Stream<T> MakeStream::from(std::initializer_list<T> init);
  vector<int> vec = { 3,2,9,6 };
  int cary[] = { 3,2,9,6 };
  MakeStream::from(vec);
  MakeStream::from(cary, sizeof(cary)/sizeof(cary[0]));  // 配列はサイズも必要
  MakeStream::from({ 3,2,9,6 });                         // 初期化リストでも OK  
Container は正確には STL のコンテナーである必要はなく、 begin, end のメソッドが定義されているクラスであれば何でもかまいません。開始、終了のイテレーターを渡してオブジェクトを作ることもできます。
Stream<T> MakeStream::from(Iterator begin, Iterator end);
他言語ではコンテナー以外のいろいろなものを同じように処理できるのがメリットの一つなのですが、 C++ ではそこまでではありません。しかし、イテレーターにすることによって、多少いろいろなものが処理できるようにはなっています。

input_iterator.cpp : (入力ストリームのイテレーターのサンプル)
  auto seq = MakeStream::from(istream_iterator<int>(cin), istream_iterator<int>());
  cout << endl;
  for (const auto &elem : seq) {
    cout << elem << endl;
  }
$ ./a.exe 
3 2 9 6 <Ctrl+D>
3
2
9
6
また、 from() 以外にも 0,1,2, ... といった無限順列を生成する counter() など多くの生成関数(Genelator)が用意されています。 ただ、テストやサンプル以外で使うことはあまりないです。

基本的な関数

Stream にはたくさんのメソッドが使えます。 しかし、多すぎて関数型のデータ処理になれていない人にとっては 「どれを使えばいいの」ということになると思います。
そこで、まず抑えておくべき重要な 3 つのメソッドについて紹介します。
  1. 写像(map_)
  2. フィルター(filter)
  3. 畳み込み、縮退(reduce)
各関数は " | " 演算子でStream オブジェクトを受け取って適用する形になります。

map_ : 写像

写像というのは map, mapping とも呼ばれる処理で、 配列などのコレクションのメンバーに一つずつ関数を適用して 戻り値で新しいコレクションを作ります。

cs_linq_map.png

Streams では、map はすでに std::map があるので、 map_ という関数名になっています。
MakeStream::from({3, 2, 9, 6})
    | map_([](auto elem) { return elem * 2; });  // 6 4 18 12 
map_ に渡す関数は、各要素を引数にとる関数で、その戻り値が新しいコレクションの要素となります。 渡す関数の戻り値の型を変えれば、 型を変えた新しいコレクションを作ることもでき、用途の広いメソッドです。

filter : フィルター

フィルターはコレクションの中から条件にあう要素を取り出す処理です。

cs_linq_filter.png

MakeStream::from({3, 2, 9, 6})
    | filter([](auto elem) { return elem % 2 == 1; });  // 3 9

reduce : 縮退(畳み込み)

要素を順に取得し、それらを計算に使った結果を返す処理です。
cs_linq_fold.png
この処理は指定した関数の結果を重ねていくため 畳み込み(fold)と呼ばれます。
また、要素が順に減っていって一つの値になるため、縮退(reduce)とも呼ばれます。 Streams では縮退の方の reduce という関数名です。
std::vector<int> src = {3, 2, 9, 6};
MakeStream::from(src) | reduce([](auto sum, auto elem) { return sum + elem; }); // 20
MakeStream::from(src) | reduce([](auto max, auto elem) { return (max < elem) ? elem : max; }); // 9
reduce に渡す関数は 2 つの引数を取り、 2 つ目が各要素で、 関数の戻り値が次に呼ばれた時の 1 つ目の引数です。
合計の例を順に記述すると次のようになります。
{3, 2, 9, 6}

(3, 2) => 3 + 2 ↓
                (5, 9) => 5 + 9  ↓
                                (14, 6) => 14 + 6  ↓
                                                   20
  
渡した関数は 2 つの目の要素から呼ばれ、最初の引数 1 つ目の要素になります。 identity_reduce() を使って、初期値を渡して、最初の要素から処理することもできます。
MakeStream::from(src) | identity_reduce(0, [](auto count, auto elem) { return count + 1; })); // 4
map, filter がコレクションから新しいコレクションを返す関数の基本なのに対し、 reduce はコレクションの各要素から値(スカラー値)を算出 する関数の中でもっとも基本的な関数です。
sum(), max() や count() の関数は予めライブラリーに用意されていますが、 サンプルコードのように reduce() で記述ことが出来ます。

その他の関数

データ処理の定番の関数以外で、抑えておいた方がいいかなと思うものも挙げて置きます。
limit
指定した範囲の要素を取得
MakeStream::counter(1) | limit(3);  // {1 2 3}
    
any, all
要素どれか一つ(すべて)が条件を満たすかどうかの判定。
MakeStream::from({3, 2, 9, 6}) | any([](auto elem) { return (elem % 3) == 0;});  // true
MakeStream::from({3, 2, 9, 6}) | all([](auto elem) { return (elem % 3) == 0;});  // false
    
sort
要素のソート
MakeStream::from({3, 2, 9, 6}) | sort();  // {2 3 6 9}      
    
演算子
Stream オブジェクトに対する四則演算など。
MakeStream::from({3, 2, 9, 6}) / 3.0; // {1.0 0.666667 3.0 2.0}
5 * MakeStream::from({3, 2, 9, 6});   // {15 10 45 30}
MakeStream::from({3, 2, 9}) + MakeStream::from({1, 2, 3}); // {4 4 12}

    
print_to
Stream オブジェクトのダンプ
MakeStream::from({3, 2, 9, 6}) | print_to(std::cout);  // 3 2 9 6      
    

メソッドの連結と遅延評価

メソッドの連結

Stream の関数は " | " 演算子の後に記述しますが、 これを連結して書けるようになっています。
Unix 系のコマンドラインをご存じの人にはパイプみたいと思われるはずです。 パイプのイメージを持ってもらって間違いないです。
MakeStream::from({3, 2, 9, 6})
      | filter([](auto elem) { return elem % 2 == 1; })  // {3 9 }
      | map_([](auto elem)  { return elem * 2; })        // {6 18 }
      | reduce([](auto sum, auto elem) { return sum + elem; });  // 6+18 => 24

連結の終わり

連結できるのは map_() や filter() 関数の戻り値が Stream のオブジェクトだからです。
最後に forEach で逐次処理したり、 reduce で値の算出に使うと連結が終わります。 終わらずに得た変数は範囲 for で使うこともできます。
しかし、 STL コンテナーなどに戻したいといった場合には to_container を呼ぶ必要があります。 container には vector や list を記述し、それぞれのコンテナーへ変換します。
MakeStream::from({3, 2, 9, 6})
      | filter([](auto elem) { return elem % 2 == 1; })  // {3 9 }
      | map_([](auto elem)  { return elem * 2; })        // {6 18 }
      | to_vector();                                     // [6 18 ]

遅延評価

関数を連結して書くと、要素を溜めているため、 ループで書くよりもメモリーがもったいないと思われる人もいるかもしれません。
しかし、必要になるまで実行(評価)されないという遅延評価(Lazy evaluation) の機能によって、 無駄がないようになっています。

前節の連結の処理は一見、次のように処理してるように見えます。
              filter           map
{3, 2, 9, 6}    →   {3, 9}    →   {6, 18}
しかし、実際には遅延評価により、メソッドごとに結果をためるのではなく、 1 要素ずつ流すように次のメソッドに渡していきます。
   filter        map
3    →    3     →    6
2    →    ☓
9    →    9     →    18
6    →    ☓
実際に上記の順で処理されているかは peek() で確認できます。 peek() は渡した関数を実行し、要素をそのまま次に渡す確認用のメソッドです。
  auto lazyseq =
      MakeStream::from({3, 2, 9, 6})
      | peek([](auto elem) { std::cout << "[in]  : " << elem << std::endl; })
      | filter([](auto elem) { return elem % 2 == 1; })
      | peek([](auto elem) { std::cout << " - filter : " << elem << std::endl; })
      | map_([](auto elem)  { return elem * 2; })
      | peek([](auto elem) { std::cout << " - map    : " << elem << std::endl; });
  
  for (auto &elem : lazyseq) {
    std::cout << "[out] : " << elem << std::endl;
  }
実行結果:
[in]  : 3
 - filter : 3
 - map    : 6
[out] : 6
[in]  : 2
[in]  : 9
 - filter : 9
 - map    : 18
[out] : 18
[in]  : 6
なお、遅延評価では必要な時に処理されるため、 sort() だと少し注意が必要です。
ソートという処理を考えたらわかると思いますが、 ソートは全要素が揃わないと完了しない処理です。 そのため、 sort() に関しては流れるように次に渡すのではなく、 そこで一旦要素が溜められることになります。

今回紹介しているサンプルの多くは以下に記述しています。 こちらには sort() の確認用のコードも記述しています。


関連記事
スポンサーサイト



Prev.    Category    Next 

Facebook コメント


コメント

コメントの投稿

Font & Icon
非公開コメント

このページをシェア
アクセスカウンター
アクセスランキング
[ジャンルランキング]
コンピュータ
23位
アクセスランキングを見る>>

[サブジャンルランキング]
プログラミング
7位
アクセスランキングを見る>>
カレンダー(アーカイブ)
プルダウン 降順 昇順 年別

05月 | 2023年06月 | 07月
- - - - 1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 -


はてな新着記事
はてな人気記事
ブロとも申請フォーム
プロフィール

yohshiy

Author:yohshiy
職業プログラマー。
仕事は主に C++ ですが、軽い言語マニアなので、色々使っています。

はてブ:yohshiy のブックマーク
Twitter:@yohshiy

サイト紹介
プログラミング好きのブログです。プログラミング関連の話題や公開ソフトの開発記などを雑多に書いてます。ただ、たまに英語やネット系の話になることも。