【Java】Stream APIを書き続けて得た知見
2019.01.15
はじめに
こんにちはシステム開発Gの中川です。
Java8で登場したStream APIですが、Version UPの度に機能が追加されています。
今回はそのStream APIを書き続けて得た知見について書いていこうと思います。
本記事では現時点(2019/01/15時点)での最新版であるJava11のOrale Open JDKを使用してコードを記述しています。
本記事で扱うこと扱わないこと
Stream APIについて書いていくと際限がないため、ここで記述範囲について定義します。
扱うこと
- Stream APIの基礎的なお話
- 実装する上でのお話
扱わないこと
- Lambda式についてのお話(知っている前提で書きます)
- 関数型の思想的なお話
- parallelStreamのお話(並列操作関連)
- 性能的なお話
Stream APIとはそもそも何者か
そもそも何者かというのを公式のJavaDocから引用します。
コレクションに対するマップ-リデュース変換など、要素のストリームに対する関数型の操作をサポートするクラスです。
https://docs.oracle.com/javase/jp/11/docs/api/java.base/java/util/stream/package-summary.html
ということで「Collection」の操作に関係するような処理であることが分かります。
JavaDocを確認していくとjava.util.Collection内にstreamメソッドが存在しています。
そこからもCollectionを操作することが出来るというのが分かります。
マップ-リデュースについてはWikipediaにページがありましたので、そちらをご覧いただければと思います。
公式から見るにStream APIとはCollectionを細かい単位に区切って処理させて何かしらの結果を出力させるためのAPIとなります。
Stream APIの基本的なお話
Streamがどういう概念で作成されたのかが分かりましたので、具体的な使い方についてみていきます。
Stream APIを使用するには大きく3つの操作を実装することになります。
具体的な操作は以下の通りです。
[ stream ] 加工したいデータをStream化する
↓
[ 中間操作 ] データを加工する
↓
[ 終端操作 ] 結果を得る
ご覧のようにとても簡単な操作となっています。
操作の中で中間操作は複数回行うことが可能となっており、複数の操作をまとめて行うことが出来るようになっています。
終端操作は1回のみ可能となっており、加工したデータの結果を得ることが出来るようになっています。
また、Streamについては使いまわし出来ません(auto close)。
ただし、たまに自分でcloseしなくてはならないケース(Files#line)があったりするので、そちらも注意ください。
JavaDocから見るStream API
Java11のJavaDocを見ながらStream APIについて操作順に見ていきます。
加工したいデータをStream化する
上にも書きましたが、Collection内にあるstreamメソッドから生成するのが基本パターンです。
基本パターン以外にも配列からStream化することも出来、Arrays#streamやStream#ofも用意されています。
また、プリミティブ型のStreamサポートとしてIntStream LongStream DoubleStreamも用意されています。
ただ、生成時に使用することは少ないかもしれません。
さらにFilesクラスなんかにもStreamにする手段が存在しています。
流石に全API確認はしていないので、他にも隠れているかもしれません。
データを加工する(中間操作)、結果を得る(終端操作)
Streamにした後の操作は全てStreamクラスと各プリミティブ型のStreamクラスに存在しています。
様々なメソッドが用意されているため最初は戸惑うかもしれません。
しかし、見方は簡単です。
・Streamを返却するのが中間処理(例外あり)
・それ以外(voidを含む)を返却するのが終端処理
です。
具体的なメソッド名を書くと
中間処理 -> 「map filter distinct peek」
終端処理 -> 「collect toArray forEash max min」
が該当します。
実践編
普段からStream APIを使ってコードを書いていますが、書いていると使うべきシーン、使わない方が良いシーン、使えないシーンなど様々な状況が起きます。
ここからはそれらについて書いていこうと思います(やっと本題です)。
Streamが使えないシーン
そもそもStreamが使えないシーンというのも残念ながら存在しています。
使えないシーンではforで処理しましょう。
実際のシーンは以下の通りです。
CollectionにIndexが欲しい場合
残念ながらJavaにおいてStream操作内でIndexを付与して処理されることは出来ません。
ランキング処理だったりで順位を付けたいなどIndexを付与したい場面というのは出てきます。
しかし、Java11現在の話でStreamを使用しながらIndexを付与することは出来ません。
検査例外を扱いたい場合
StreamというよりはJavaのLambda式の制限になります。
Lambda内で発生した検査例外についてはLambda内部で処理する必要があります。
catchして無理矢理どうにかするという方式をとれば使えなくないのですが、あまりクールではありません。
自分は余り良いコードが作れる感じがしないので例外を扱いたい場合はStreamを使用しないようにしています。
ただ、非検査例外については問答無用でLambdaを抜けてくれるので気にしなくても大丈夫です。
複数の変数をまとめて扱いたい場合
1つのCollectionを扱うStreamはとても苦手です。
複数の変数をStreamで扱おうとしてデータを作成するぐらいなら素直にfor文で処理させた方が良いことも多いです。
気を付けましょう。
このケースはまずは設計等で複雑さを排除すべきではないかとは思っています。
Streamを書く上での注意点(中間処理編)
Stream APIを書く際の多くは複数の処理を連結して行う中間処理に集約されます。
ここのコードの良し悪しが全てを握るのかなと思って自分はいつもコードを書いています。
なので、ここでは書いていくうちに自分なりに行き着いた中間処理の書き方について述べていきたいと思います。
処理の中はなるべく短く細かく分ける
具体的に処理を書く際の注意点です。
中間処理を書く際は複数の処理をまとめて行わず、1つの処理を1つの中間処理としてチェインさせた方が良いです。
言葉だけだと分かりづらいので、サンプルコードを用意しました。
今回は文字列のListから3文字より大きくaから始まる文字列を抽出して
別のリストに移し替えるというサンプルで説明していきます。
まずは拡張for文で作成した例から見ていきましょう。
public static void main(String[] arg) { var strList = List.of("hogehoge","aiued", "a", "aa", "aabd"); var newList = new ArrayList(); for(String str : strList) { if(3 < str.length() && str.startsWith("a")) { newList.add(str); } } System.out.println(newList); }
特に言うことはない感じで、StreamAPIが登場するまではこのようなコードを書くのが普通でした。
そして、これを書き直した際に書かれるのは、以下のようなコードになりがちです。
public static void main(String[] arg) { var strList = List.of("hogehoge","aiued", "a", "aa", "aabd"); var newList = strList.stream().filter(str -> 3 < str.length() && str.startsWith("a")) .collect(Collectors.toList()); System.out.println(newList); }
別に悪い所はありませんが、これではStream APIの真の力は発揮出来ません。
具体的には以下のようなコードとした方が良いです。
public static void main(String[] arg) { var strList = List.of("hogehoge","aiued", "a", "aa", "aabd"); var newList = strList.stream() .filter(str -> 3 < str.length()) .filter(str -> str.startsWith("a")) .collect(Collectors.toList()); System.out.println(newList); }
先ほどとの違いは「&&」で結合していた判定文を分けただけです。
大した違いではありませんが、Stream APIの特性と可読性の両方を満たした良い実装です。
長くなるケースが出てきたら、変数やメソッドに処理を実装して呼ぶ方が良いです。
メソッド参照を上手く使おう
メソッド参照はLambda式と同じくしてJava8から登場した新しい書き方です。
使える条件を覚えるのが意外と大変なのと、使うことで分かりづらくなることもあるので少し難易度が高いです。
自分が主に使用する例としては
filter(Objects::nonNull) filter(StringUtils::isNotEmpty)
のようにNullやEmpty(apache common使用)でfilterさせる場合や
map(User::getName)
データクラスから値を取り出したい場合になります。
これらはすぐに何を処理させたいかが分かりやすいため、使います。
その他に関してはチームルールとして定めてしまうのが良いかと思います。
プロジェクトを進めながら自分たちにとって良い書き方を模索するぐらいの感じで良いと思います。
なお、コンストラクタ参照は使いどころが難しすぎるので、現状は使ってません。
staticメソッドを使いこなそう
Streamの追加と共に関数型インターフェースが実装されました。
そのインターフェース内に便利なstaticメソッドが多数実装されました。
これが使えていると使えていないとではまるで違った実装になります。
特にComparatorクラスで用意されているメソッドが強力です。
単体キーのソート、複数のキーのソートがとても分かりやすく楽に実装出来るようになったのが理由です。
分かりやすいサンプルコードを以下に用意しました。
【シングルキーによるソート】
var strList = List.of("hogehoge","aiued", "ab", "aba", "aabd"); var newList = strList.stream() .sorted(Comparator.comparing((String it) -> it)) .collect(Collectors.toList()); System.out.println(newList); // -> [aabd, ab, aba, aiued, hogehoge]
【複数キーによるソート】
var strList = List.of("hogehoge","aiued", "ab", "aba", "aabd"); var newList = strList.stream() .sorted(Comparator.comparing((String it) -> it).thenComparing(String::length)) .collect(Collectors.toList()); System.out.println(newList); // -> [aabd, ab, aba, aiued, hogehoge]
中に実装を記述することが出来るため、様々な形でのソートが実装出来るようになりました。
このようなstaticメソッドはバージョンが上がるたびに増えています。
実装をより楽にするという観点でチェックしていきたいですね。
Streamを書く上での注意点(終端処理編)
今まで中間処理について書いてきましたが、ここからは終端処理について記述していきます。
まずはプリミティブ型
プリミティブ型のStreamの終端処理はオブジェクト型と違いはありません。
しかし、数字を扱うというのが分かり切っているため、一部のメソッドで記述量が減っています。
例えば数値の合計を求める場合、オブジェクト型ではCollectors#summingInt(summingLong, summingDouble)を使用する必要があります。
var list = List.of(1,2,3,4,5,6,7,8); System.out.println(list.stream().collect(Collectors.summingInt(it -> it)));
プリミティブ型ではIntStream#sumがあらかじめ用意されているため、
var list = List.of(1,2,3,4,5,6,7,8); System.out.println(list.stream().mapToInt(it -> it).sum());
ちょっとだけ記述量を減らすことが可能となっています。
データクラスの中から値を取り出して合計を求めたりなんだりという場合によく使ったりします。
mapToIntで数字へ変換しているのが分かりやすい点もプラスです。
オブジェクト型
上にも書いた通りプリミティブ型との違いはありませんが、オブジェクト型を扱う上でjava.util.stream.Collectorsの存在は欠かせません。
Collectorsは終端処理向けのメソッドが揃っており、これを知っている知らないで大きく実装が異なります。
特にCollectorsで便利なのは、Mapを生成するメソッドだと個人的に思っています。
特定の条件でTrue/Falseに分けたMapを作成したり(partitioningBy)、特定の条件でグルーピングしたMapを作成したり(groupingBy)と幅広く使用することが出来ます。
特に素晴らしいのは、value側についても自在に操れる点になります。
そんな訳でデータクラスからid毎にグルーピングさせて求めた合計値のMapを作成する処理をサンプルで用意してみました。
public static void main(String[] arg) { var list = new ArrayList(); list.add(new Product(1,10)); list.add(new Product(2,100)); list.add(new Product(1,50)); list.add(new Product(4,15)); list.add(new Product(4,10875)); Map<Long, Long> result = list.stream() .collect(Collectors.groupingBy(Product::getId, Collectors.summingLong(Product::getAmount))); System.out.println(result); // -> {1=60, 2=100, 4=10890} } // 長くなったのでLombok使用してます @Data @AllArgsConstructor private static class Product { private long id; private long amount; }
ここまで簡単だとデータ加工がはかどります。
さいごに
Streamという新しい概念が出てきて最初は戸惑いましたが、とにかく量を書くことで色々と知ることが出来ました。
自分が書いた中で感じたこうした方が良いは一通り書きましたので、見ている方の参考になれば幸いです。
それでは良いStream Lifeを!
CONTACT
お問い合わせ
あなたの「想い」に挑戦します。
どうぞお気軽にお問い合わせください。
受付時間:平日9:00〜18:00 日・祝日・弊社指定休業日は除く