StreamAPIについてまとめてみる

11 min read読了の目安(約10200字

はじめに

開いていただきありがとうございます。
会社の後輩に「StreamAPIが分からないので教えてください!」と言われたので、「どうせ教える用に資料作成するならzennで公開するか」と思い作成しました。本記事ではStreamAPIについて説明しております。

StreamAPIとは?

JDK8以降で導入され、コレクション(List,Map)や配列に対して何らかの処理(集計や変換)を行うことができます。
Streamはコレクションフレームワーク(List・MapやCollectionsクラス等)やjava.io.InputStreamjava.io.OutputStreamやPrintStream等とは全く別のものです。

どう使うの?

基本的な使い方は下記の通りになります。

  1. Streamを取得します。
    • ListMapなどのコレクションからStreamを生成します。
    • 基本的にはListMapなどのコレクション
  2. 中間操作を実施します。
    • 変換や集計などのデータ加工を行います。
    • 中間操作は複数のメソッドをチェーンすることができる。
  3. 2で処理したものに対して、終端操作を実施します。
    • Streamの全データを一括で別のデータに変換します。

文字だけで書いてもイメージが沸かないと思うので、サンプルを載せてみました。

// Listを生成。
List<String> list = Arrays.asList("a", "bb", "ccc");

List<String> filterList = 
    // ListをStreamに変換(Streamの生成)
    list.stream()
    // 中間操作。例は2文字のものをフィルターする。
    .filter(s -> s.length() == 2)
    // 終端操作。StreamからListに変換する。
    .collect(Collectors.toList());

上記の処理を実行すると「bb」のみを抽出することができます。

なぜStreamを使うのか

先ほどのサンプルコードをStreamを使わずに書く場合はどうなるでしょうか?
Streamを使わない例です。

// Listの生成
List<String> list = Arrays.asList("a", "bb", "ccc");

// フィルター結果格納用
List<String> filterList = new ArrayList<>();

// Listの各要素をイテレート
// 通常のfor文
for (int i = 0; i < list.size(); i++) {
    // 長さが2文字の場合のみリストに追加する。
   if (list.get(i).length() == 2) {
       filterList.add(list.get(i);
    }
}

// フィルター結果格納用
List<String> filterList2 = new ArrayList<>();
// 拡張for文バージョン
for (String s : list) {
    // 長さが2文字の場合のみリストに追加する。
   if (s.length() == 2) {
       filterList2.add(s);
    }
}

こちらも同じく「bb」のみを抽出する処理となります。
これはこれで正解なのですが、Streamを使って書く方が良いとされています。その理由として、

  • コードの行数が少なくなる
  • より直感的に表現することができる
    • 今回の例だと「値を条件でフィルターしてリストに変換する。」といった感じでコード読むことができる。(慣れの問題もありますが。。。)
    • ダミーの初期値を用意して、それぞれの文字をイテレートして追加するといった複数の段階に分けてコードを書く必要がなくなる。
  • 並列処理が行いやすい
    ことが主に挙げられます。

このようにStreamAPIを使用することで、従来の書き方とは異なりシンプルでわかりやすいコードを書くことができます。

従来のfor文を使用した方がいい場面もあるので、必ずしもfor文を使わないという訳ではない。
例えば、コレクション内の特定のインデックスの要素を操作する際はfor文で書く方が良いとされている。

中間操作

中間操作とは

中間操作とはStream内の要素に対して操作を行います。中間操作はメソッドチェーンで連結していくことができます。
中間操作は即時実行されますが、渡されたラムダ式やメソッド参照はすぐに評価されるわけではありません。
これらのメソッドが行う処理はあとの実行時のためにキャッシュされ、呼ばれるまでは何も実作業を行いません。
キャッシュされた処理は終端処理が呼び出される際に実行されます。

中間操作のメソッド

中間操作のメソッドには以下の種類があります。

filter

条件を満たした値だけを抽出するメソッドです。
Predicate<T> (Streamの要素Tを引数に取り、booleanを返す関数)を引数に取るメソッドです。
ある要素のコンテクストで実行された関数がtrueを返す場合、その要素は結果のコレクションに格納されます。
falseを返す場合その要素は無視され、格納されません。関数がtrueを返した要素だけを格納したStreamを返却します。
すでに「StreamAPIとは?」でfliterメソッドを使用したサンプルを再掲します。

// Listを生成。
List<String> list = Arrays.asList("a", "bb", "ccc");

// 文字列の長さが「2」のものを抽出する。
List<String> filterList = 
    list.stream()
    .filter(s -> s.length() == 2)
    .collect(Collectors.toList());

// 実行結果
// bb

map,mapToInt,mapToLong,mapToDouble,mapToObj

保持している値を変換します。
Function<T,R>(Streamの要素Tを引数に取り、R型の戻り値を返す関数。TとRは同じ型でも良い)を引数に取るメソッドです。
入力されたコレクションを新たなコレクションに変換する際に非常に便利です。
このメソッドでは入力されたコレクションと新たに生成されたコレクションが常に同数の要素を持つことを保証します。
しかし、型は入力されたコレクションと新たに生成されたコレクションで必ずしも一致するとは限りません。
mapにはint型に変換するmapToInt,long型に変換するmapToLong,double型に変換するmapToDouble,オブジェクトに変換するmapToObjが存在します。

// Listを生成。
List<String> list = Arrays.asList("a", "bb", "ccc");
// リストの文字列を大文字に変換する。
list.stream()
  .map(s -> s.toUpperCase())
  .forEach(s -> System.out.print(s + " "));

// 実行結果
// A BB CCC

flatMap,flatMapToInt,flatMapToLong,flatMapToDouble,flatMapToDouble

複数の値(0個も可)に変換します。
mapメソッドが行うようにコレクションの要素をマップしますが、引数にFunction<T, Stream<R>> という1つの要素TからStream<R>型を生成する関数を取ります。
flatMapにもmapと同様にint型に変換するflatMapToInt,long型に変換するflatMapToLong,double型に変換するflatMapToDoubleが存在します。

final List<String> player = 
  Arrays.asList("Imai", "Takahasi", "Masuda", "Matsumoto", "Matsuzaka");

// 文字列(String)から[文字列(String), 文字列長(Integer)]へ1:Nの変換
Stream<Object> stream = player.stream().flatMap(p -> Stream.of(p, p.length()));
System.out.println(stream.collect(Collectors.toList()));

// 実行結果
// [Imai, 4, Takahasi, 8, Masuda, 6, Matsumoto, 9, Matsuzaka, 9]

sorted

文字通りソートします。このメソッドには2種類あります。

  1. 保持されているデータがjava.lang.Comparableを実装しているパターン
  2. 関数型インターフェースComparatorを渡してソート順を定義するパターン
    1はComparableを実装しているため、引数なしでソート可能です。
    2はComparatorは関数型インタフェースなので、互換性のある関数(ラムダ式)を渡します。
final List<Integer> numList = Arrays.asList(5, 9, 2, 1, 0);
// 1.のやり方でソートする。
numList.stream().sorted().forEach(System.out::println);

// 実行結果
// 0 
// 1
// 2
// 5
// 9

limit

指定された個数までに限定します。最大個数を渡します。

final List<String> player = 
  Arrays.asList("Imai", "Takahasi", "Masuda", "Matsumoto", "Matsuzaka");
// 5文字より大きいものを抽出し、2つまでに限定する。
player.stream()
  .filter(s -> s.length() > 5)
  .limit(2)
  .forEach(System.out::println);

// 実行結果
// Takahasi
// Masuda

skip

指定された個数をスキップします。スキップする個数を渡します。

final List<String> player = 
  Arrays.asList("Imai", "Takahasi", "Masuda", "Matsumoto", "Matsuzaka");
// 2つ分スキップする。
player.stream()
  .skip(2).forEach(System.out::println);

// 実行結果
// Masuda
// Matsumoto
// Matsuzaka

distinct

重複した値を削除します。

final List<String> distinctList 
  = Arrays.asList("a", "bb", "ccc", "a");
// 重複を取り除く。今回は「a」。
distinctList.stream().distinct().forEach(System.out::println);

// 実行結果
// a
// bb
// ccc

peek

デバック用途で、どんな値が入ってるか確認したい時などに使用します。

終端操作

終端操作とは・・・

終端操作とはStreamの中の要素を実際に処理する工程であり、fliter等の中間操作は終端操作をトリガーにして初めて実行される。
中間操作を適用した要素に対して、終端操作で処理し、最終的にStreamから別のデータへの変換を行います。
終端操作の役割として以下の2つが挙げられます。

  • Streamから他のデータへ変換
  • 検索集計などの操作を行う

終端操作のメソッド

終端操作のメソッドには以下の種類があります。

  • 変換
    • collect
    • toArray
  • 検索
    • findFirst,findAny
    • allMatch,anyMatch,noneMatch
  • 集約・集計
    • average,sum,count,max,min
      • average,sumはプリミティブ型でのみ提供されています。
    • summaryStatistics
    • reduce
  • 出力
    • forEach
    • forEachOrdered
    • iterator

各メソッドについて

サンプルコードで使用する不変リストは下記の通りです。

List<String> player = 
  Arrays.asList("Imai", "Takahasi", "Masuda", "Matsumoto", "Matsuzaka");

collect

あるコレクションを可変コレクションなど他のデータ型へ変換する際に便利な集約処理を行います。
Collectorsクラスのユーティリティメソッドと組み合わせるととても便利です。
Collectorsクラスは以下のページを参照してもらえればと思います。

https://docs.oracle.com/javase/jp/11/docs/api/java.base/java/util/stream/Collectors.html

http://www.ne.jp/asahi/hishidama/home/tech/java/collector.html

Streamの要素をArrayListに変換する際に使用することが多いです。

// 文字列を大文字に変換し、「/」で連結します。
System.out.println(player.stream()
    .map(String::toUpperCase)
    .collect(Collectors.joining("/ ")));

// 実行結果
// IMAI/ TAKAHASI/ MASUDA/ MATSUMOTO/ MATSUZAKA

toAarray

配列に変換します。配列インスタンスを生成する関数を渡します。

// 文字列を大文字に変換し、出力します。(こんな周りくどい書き方普通しない。。。)
String[] array = 
    player.stream()
    .map(String::toUpperCase)
    .toArray(String[]::new);
System.out.println(Arrays.toString(array));

// 実行結果
// [IMAI, TAKAHASI, MASUDA, MATSUMOTO, MATSUZAKA]

count

要素数を返却します。

// 先頭の文字が「M」のものを抽出し、その数をカウントします。
System.out.println(player.stream().filter(n -> n.startsWith("M")).count());
// 実行結果
// 3

max,min

最大値(最小値)を返却します。

// 文字列の長さが最小(最大)の選手を出力します。
System.out.println(player.stream().min((s, t) -> s.length() - t.length()));
System.out.println(player.stream().max((s, t) -> s.length() - t.length()));
// 実行結果
// Optional[Imai]
// Optional[Matsumoto]

average

平均値を返却します。

// 文字列の長さの平均値を算出します。
System.out.println(player.stream().mapToDouble(p -> p.length()).average());
// 実行結果
// OptionalDouble[7.2]

sum

合計値を返却します。

// 文字列の長さの合計を算出します。
System.out.println(player.stream().mapToInt(p -> p.length()).sum());
// 実行結果
// 36

reduce

2つの要素を比較し、「その結果を次の要素と比較のために渡す」という処理をコレクション内の最後の要素まで繰り返します。
リストが空である可能性があるので、メソッドの戻り値はOptionalになります。

// リストの要素を「/」で結合して返却します。
Optional<String> foundPlayer = 
    player.stream()
    .reduce((String joined, String element) -> {
            return joined + '/' + element;
        });
System.out.println(foundPlayer);

// 実行結果
// Optional[Imai/Takahasi/Masuda/Matsumoto/Matsuzaka]

findFirst,findAny

先頭の値(ストリーム内のいずれかの値)を返却します。空のStreamの場合はOptional.emptyを返却します。

// 先頭の文字列が「M」で最初の選手を表示します。
Optional<String> foundPlayer = 
    player.stream()
    .filter(n -> n.startsWith("M"))
    .findFirst();
System.out.println(foundPlayer);

// 実行結果
// Optional[Masuda]

allMatch,anyMatch,noneMatch

条件判定の関数Predicateを条件にStream結果の要素を検索し、マッチする要素の存在状態を判定します。
allMatch全ての値が条件に合致したらtrueを返却します。
anyMatch1つでも条件に合致する値があればtrueを返却します。
noneMatch条件に合致する値が1つもなかったらtrueを返却します。

// 先頭の文字列が「T」の選手が一人でもいるかをチェックします。
System.out.println(player.stream().anyMatch(p -> p.startsWith("T")));
// 実行結果
// true
// 全ての選手の先頭の文字列が「M」の選手がどうかをチェックします。
System.out.println(player.stream().allMatch(p -> p.startsWith("M")));
// 実行結果
// false

forEach,forEachOrdered

コレクションの要素をイテレートします。Consumer型を引数に取ります。
forEachメソッドを開始するとイテレーションから抜けることができません。(この制限を処理する機能は存在します。)
そのため、この書き方はコレクションの各要素を処理したい場合に有用です。
forEachOrderedは値を1つずつ順番に処置するメソッドです。

// 5文字より大きいものを抽出し、2つまでに限定したものを出力します。
player.stream().filter(s -> s.length() > 5)
  .limit(2)
  .forEach(System.out::println);

// 実行結果
// Takahasi
// Masuda

最後に・・・

最後まで読んでいただきありがとうございました。
知らなかったメソッドもあったので改めて勉強になりました。
StreamAPIで必ず使用する「ラムダ式」や「メソッド参照」についても以下の記事にまとめているので、参照いただければと思います。

https://zenn.dev/s_t_pool/articles/a00ccfcb23ea5fd941b2

参考