46.副作用のないストリーム処理を選択すべし
ストリームによるパラダイムの変化
ストリームは単なるAPIでなく、関数型プログラミングに根ざしたパラダイム(ものの見方?)の変化であり、そのパラダイムに適応しなければならない。
ストリームのパラダイムで最も重要な部分は、演算を純粋関数による変換の連続の結果、という構造にするところにある。純粋関数とは、結果がそのインプットにのみ依存し、mutableな状態には依存せず、他の状態を変更しないものを指す。
このパラダイムを達成するために、ストリームにおける中間操作、終端操作は副作用と無縁のものにせねばならない。
以下、ファイルに含まれる単語の頻度を求めるプログラムをみていく。
// Uses the streams API but not the paradigm--Don't do this!
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
}
ストリーム、ラムダ式、メソッド参照を使い、結果も正しいが、これはストリームAPIの利点を引き出せていない。
問題は、forEachの中で外部の状態(freq変数)を変更していることにある。一般に、ストリームのforEachで結果の表示以外をしているコードは、状態を変化させているコードなので、悪いコードである可能性がある。
以下が本来あるべき姿。
// Proper use of streams to initialize a frequency table
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words
.collect(groupingBy(String::toLowerCase, counting()));
}
forEachは、ストリームの演算の結果を示すために使うべきで、演算そのものに使うべきではない。
collectorの利用
上記の改良コードでは、collectorを利用しており、ストリームを使うには欠かせない。
Collectors APIは39個のメソッドがあり、また、最大で5つの引数をとるメソッドがあり、恐ろしげに見える。しかし、深く入り込まずともこのAPIを利用することはできる。最初はCollectorのインターフェースは無視して、リダクション(ストリームの要素を一つのオブジェクトにまとめる)を行うものと考える。
ストリームの要素をcollectionにするメソッドとして、toList()
, toSet()
, toCollection()
がある。これらはそれぞれ、list、set、任意のcollectionを返す。これらを使って頻度表のトップ10を抽出したのが以下になる。
// Pipeline to get a top-ten list of words from a frequency table
List<String> topTen = freq.keySet().stream()
.sorted(comparing(freq::get).reversed())
.limit(10)
.collect(toList());
上記コードでは前提となっているが、ストリームパイプラインの可読性のために、Collectorsのメンバーはstaticインポートしておくべきである。
toMapメソッド
上の3つを除いた残りの36メソッドは、ほとんどストリームをMapにするためのものである。
最もシンプルなのは、toMap(keyMapper, valueMapper)
で、ストリームをキーにする関数とストリームをバリューにする関数を引数にとる。例は以下のよう。
// Using a toMap collector to make a map from string to enum
private static final Map<String, Operation> stringToEnum =
Stream.of(values()).collect(
toMap(Object::toString, e -> e));
上記のコードは、同じキーが複数あった場合にはIllegalStateException
をスローする。
このような衝突を防ぐための方法の1つとして、引数にマージ関数(BinaryOperator<V>
でVはMapのバリューの型)を持たせることがある。
以下の例は、Albumオブジェクトのストリームからartist毎に最も売れたAlbumのMapを作成している。
// Collector to generate a map from key to chosen element for key
Map<Artist, Album> topHits = albums.collect(
toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));
3つの引数を取るtoMap
メソッドのそのほかの使用法は、キーが衝突したときに、最後に書き込まれたものが正となるような使い方である。この時のコード例は以下のようである。
// Collector to impose last-write-wins policy
toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)
4つの引数を取るtoMap
メソッドもあり、4つ目の引数には返り値の実装となるMapを指定する。
groupingByメソッド
Collectors APIにはtoMap
メソッドの他にも、groupingBy
メソッドなるものがある。
groupingBy
メソッドはclassifier関数をもとに要素をカテゴリー分けしたMapを生成する。
classifier関数とは、要素を受けて、その要素のカテゴリー(Mapのキー)を返す関数である。
Item45で例示したアナグラムプログラムの中で使われていたものがそれにあたる。
words.collect(groupingBy(word -> alphabetize(word)))
groupingByメソッドで、バリューがList以外のMapを生成するcollectorを返すには、classifier関数に加えて、downstream collectorを特定してやる必要がある。
最もシンプルな例では、このパラメータにtoSetを渡してやるとMapのバリューはListでなく、Setになる。
そのほかのgroupingByメソッドの2つ引数を取るシンプルな例としては、downstream collectorにcounting()を渡すことがある。
counting()では各カテゴリー内の要素数を集約できる。その実例が本章の最初で提示した頻度テーブルの例である。
Map<String, Long> freq = words
.collect(groupingBy(String::toLowerCase, counting()));
3つ引数を取るgroupingByメソッドでは、生成するMapの型を特定することができる。(ただし、2番目の引数にMapのファクトリが来て、3番目にdownstream collectorが来るようになる)
その他のメソッド
countingメソッドはdownstream collectorとしての利用に特化したものであり、同様の機能はStreamから直接得られるので、collect(counting())
のような呼び出しはしてはならない。
このような特性のメソッドはCollectorsの中にあと15個あり、それらの内9つはsumming,averaging,summarizingから始まるメソッド名である。
そのほかに、Streamのメソッドと似た、reducing、filtering, mapping, flatMapping、collectingAndThen メソッドがある。
Collectorsのメソッドでまだ言及していないものが3つあるが、それらはcollectorsとあまり関わりがない。
最初の2つはminBy
とmaxBy
メソッドである。これらは引数にComparatorをとり、ストリームの要素から最小、最大の要素を返す。
最後のCollectorsのメソッドはjoining
で、これはCharSequence
インスタンス(Stringとか)のストリームの操作のみを行う。
引数なしのjoiningは要素を結合するのみのcollectorを返す。
引数が1つのjoiningはdelimiterを引数に取り、要素間にdelimiterを挟みこむcollectorを返す。
引数が3つのjoiningはdelimiterに加え、prefixとsuffixを引数に取る。delimiterがコンマで、prefixが[で、suffixが]だと、
[came, saw, conquered].
のようになる。