Java8のStreamを使いこなす

さて、Java8で関数型っぽいことをやって遊んでみたわけですが、実際はそんな書き方しませんよね。
Java8で実際に使うのは、Streamです。
ということで、Streamの使い方をひととおり見てみます。
※5/17 仕様変更があったので、修正しました

基本

まずは、Iterableインタフェースに用意されたforEachメソッドを見てみましょう。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
names.forEach(s -> System.out.println(s));


これで次のように表示されます。

hoge hoge
foo bar
naoki
kishida


いままでの拡張forだと次のように書いてました

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
for(String s : names){
  System.out.println(s);
}


これで何がめんどくさかったかというと、namesが格納してる型Stringを、forのあとに書かないといけなかったんですね。先に要素の型を書くので、IDEでの補完もききません。
Lambda構文では型推論を行ってくれるので、わざわざListが格納してるとわかっている型を書く必要がありません。これはなかなか便利です。


また、今回のように、値をそのまま別のメソッドに渡すという場合にはメソッド参照が使えます。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
names.forEach(System.out::println);

streamしてmapやfilter

さて、上記のforEachは、すべての値にたいしてそのまま処理をする場合には使えるのですが、実際には値を加工したり、条件によって処理する値を絞ったりということが必要になります。
そのような場合、Java8ではListなどをstreamに変換して処理する必要があります。
たとえば、値を加工する場合にはmapメソッドを使って次のようになります。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
names.stream()
  .map(s -> "[" + s + "]")
  .forEach(System.out::println);


これで次のように表示されます。

[hoge hoge]
[foo bar]
[naoki]
[kishida]


条件によって処理する値を絞る場合は次のようにfilterメソッドを使います。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
names.stream()
  .filter(s -> s.length() > 5)
  .map(s -> "[" + s + "]")
  .forEach(System.out::println);


こうすると、長さが5文字より長い値だけ、「[〜]」で囲まれて表示されます。

[hoge hoge]
[foo bar]
[kishida]

コレクションの判定

プログラムを書いていると、コレクションのすべての要素が条件をみたしてるかとか、逆に条件をみたした要素がひとつもないかとか、ひとつでもあるかとかを判定することがあります。
Java7まででは、フラグを用意してループをまわしてとめんどうな記述が必要でしたが、streamには簡単に判定できるメソッドが用意されています。


すべての要素が条件をみたしているかどうか判定する場合にはallMatchメソッドを使います。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
System.out.println( names.stream().allMatch(s -> !s.isEmpty()); //true


ひとつでも条件を満たす要素があるかどうか判定するにはanyMatchメソッドを使います。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
System.out.println( names.stream().anyMatch(s -> s.length > 7); //true


条件を満たす要素がひとつもないことを判定するにはnoneMatchメソッドを使います。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
System.out.println( names.stream().noneMatch(s -> s.startWith("A")); //true

コレクター

streamからひとつのオブジェクトを得る場合に使えるのがコレクターです。
たとえば、すべての文字列を連結した文字列を得るには、toStringJoinerコレクターを使います。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
System.out.println( names.stream.collect(Collectors.toStringJoiner(":")) );


次のようにすると、文字数の合計がとれます。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
System.out.println( names.stream.collect(Collectors.sumBy(s -> (long)s.length()) ); // 28

sumByではLong値をとるので、long型にキャストしていることに注意が必要です。


実際には、数値を合計するとか平均をとる場合には、IntStreamに変換するほうが便利です。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
System.out.println( names.stream.mapToInt(s -> s.length()).sum()); //28


streamをListに変換する必要があることも多いと思いますが、その場合にはtoListコレクターを使います。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
List<String> converted = names.stream()
        .filter(s -> s.length() > 5)
        .map(s -> "(" + s + ")")
        .collect(Collectors.toList());
System.out.println(converted); // [(hoge hoge), (foo bar), (kishida)]


便利そうなのがgroupingByコレクターです。groupingByで得た値をキーにして、値を格納したListを割り当てたMapが作成されます。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
names.stream()
        .collect(Collectors.groupingBy(s -> s.length()))
        .forEach((k,v) -> System.out.println(k + ":" + v));


これは次のようになります。

5:[naoki]
7:[foo bar, kishida]
9:[hoge hoge]

ファイルとStream

いや、いままでもファイルを扱うにはInputStreamやOutputStreamを使ってたわけですが、そっちのStreamじゃなくて、今回話題にしてるStreamとの関係です。


Streamといえば、コレクションでの利用が注目されるのですが、BufferedReaderにもStreamを返すlinesというメソッドが追加されていて地味に便利です。
例えば、次のようなlines.txtというファイルがデスクトップにあるとします。

SCREEN 2;
FOR I=0 TO 100
LINE (RND(1)*128,RND(1)*96)-(RND(1)*128+128,RND(1)*96+96),15
NEXT I
GOTO 50


これを表示しようとすると、次のようになります。

String path="C:\\User\\naoki\\Desktop\\lines.txt";
try(FileReader fr = new FileReader(path);
    BufferedReader br = new BufferedReader(fr))
{
    br.lines()
        .forEach(System.out::println);
}catch(IOException ex){}


今回はサンプルなので行数の短いファイルですが、一般にはファイルの行数はためしに表示するには結構長いので、最初の10行などを表示したくなります。その場合、いままではちょっとまわりくどいコードが必要になっていましたが、streamになると、表示部を次のようにするだけです。

br.lines()
    .limit(10)
    .forEach(System.out::println);


ところで、BufferedReaderのサンプルとしてファイルを読み込みましたが、実際のファイルの表示の場合には、FilesクラスのreadAllLinesメソッドが使えます。

String path="C:\\User\\naoki\\Desktop\\lines.txt";
try{
    Files.readAllLines(Paths.get(path), Charset.defaultCharset()).stream()
        .limit(10)
        .forEach(System.out::println);
}catch(IOException ex){}

zipでふたつのStreamを統合する

プログラムを組んでいると、ふたつのListの同じ位置の値を統合して処理するということが、たまに必要になりました。Streamを使っていると、ふたつのStreamを統合したいということも多くなります。
そこで使えるのが、Streamsクラスのzipメソッドです。
※ 2017/3/23 追記 zipメソッドはJava 8には入りませんでした


例えば次のように使います。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
List<Integer> indexes = Arrays.asList(10, 20, 30, 40);
Streams.zip(indexes.stream(), 
        names.stream(),
        (idx, line) -> String.format("%3d %s", idx, line))
    .forEach(System.out::println);


結果は次のようになります。

10 hoge hoge
20 foo bar
30 naoki
40 kishida


このような場合、IntStream.rangeが使えます。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
//Streams.zip(Streams.intRange(10, 50, 10).boxed(), 
Streams.zip(IntStream.range(10, 50, 10).boxed(), 
        names.stream,
        (idx, line) -> String.format("%3d %s", idx, line))
    .forEach(System.out::println);

IntStream.rangeの2番目の引数は、終了値ではなく、この値より小さい値までが生成されるので注意が必要です。

無限ストリーム

ところで、今回はnamesの要素数がわかっているので、あらかじめ範囲を決めたIntStreamを用意することができました。けれども、ファイルから読み込んだStreamと対応づける場合などは、要素数がいくつあるかわからないので、あらかじめ範囲を決めておくことができません。もちろん、非常に大きな数値を範囲として設定しておけばいいのですが、ちょっとかっこ悪いです。
そこで使えるのが無限ストリームです。


次のように、limitなどと組み合わせて使います。

//Streams.iterate(10, i -> i + 10).limit(5).forEach(System.out::println);
Stream.iterate(10, i -> i + 10).limit(5).forEach(System.out::println);


このような表示になります。

10
20
30
40
50


zipメソッドはどちらかのStreamが終われば処理を終わるので、有限なStreamと無限ストリームを組み合わせると便利です。そうすると、ファイルから読み込んだStreamとの結合も要素数を気にすることなく次のように書けます。

String path="C:\\User\\naoki\\Desktop\\lines.txt";
try{
    Streams.zip(
            //Streams.iterator(10, idx -> idx + 10),
            Stream.iterate(10, idx -> idx + 10),
            Files.readAllLines(Paths.get(path), Charset.defaultCharset()).stream(),
            (idx, line) -> String.format("%3d %s", idx, line))
        .forEach(String.out::println);
}catch(IOException ex){}


次のような表示になります。

10 SCREEN 2;
20 FOR I=0 TO 100
30 LINE (RND(1)*128,RND(1)*96)-(RND(1)*128+128,RND(1)*96+96),15
40 NEXT I
50 GOTO 50


あと、無限ストリームでは次のような遊びをすることも多いですね。

//Streams.iterate(321, i -> (i * 211 + 2111) % 1999)
Stream.iterate(321, i -> (i * 211 + 2111) % 1999)
    .limit(10)
    .forEach(System.out::println);


ランダムっぽい値が出力されます。

321
1876
146
933
1073

線形合同法というアルゴリズムでの乱数生成です。

flatMap

いくつかのリストを組み合わせてひとつのリストを作りたい場合に使えるのがflatMapです。
たとえば、要素の文字列をスペースで分割してそれぞれを要素としたリストを作る、という場合には次のように書きます。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
names.stream()
    .flatMap(s -> Arrays.stream(s.split(" ")))
    .forEach(System.out::println);


次のような表示になります。

hoge
hoge
foo
bar
naoki
kishida

reduceでひとつにまとめる

ここまでで、たとえばanyMatchメソッドやコレクターなど、Streamの値をまとめてひとつの値を返すメソッドがありました。
このようなメソッドを一般化したものがreduceメソッドです。
たとえば、anyMatchメソッドをreduceメソッドで置き換えると次のように書けます。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
System.out.println( names.stream().map(s -> s.length > 7).reduce((l, r) -> l || r).get() );

戻り値はOptional型になっているので、getやorElseメソッドを使って値を取り出す必要があります。
もちろん、anyMatchメソッドはtrueが最初に来た時点で処理を打ち切るはずですが、結果としては同じものになります。


ところで、reduceメソッドは引数を1つとるもの、2つとるもの、3つとるものがあります。引数を1つとるものはサンプルをあげましたが、2つとるものもほぼ同様の書き方になります。戻り値はOptionalではなく値が直接返ります。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
System.out.println( names.stream().map(s -> s.length > 7).reduce(false, (l, r) -> l || r) );

ここでreduceメソッドの最初の引数には、「単位元」となるものを指定する必要があります。「単位元」というのは、続く演算の片方の引数に与えたとき、もう片方の引数がそのまま返るような値です。
たとえば、足し算の場合は0が単位元になります。掛け算の場合は1が単位元になります。ここでは論理和のor演算を行っているので、falseが単位元になります。


問題は引数を3つとるreduceメソッドで、これは少し使い方に気をつける必要があります。
先ほどの例を引数3つのreduceメソッドで書き換えると次のようになります。

List<String> names = Arrays.asList("hoge hoge", "foo bar", "naoki", "kishida");
System.out.println( names.stream()
    .reduce(
        false,
        (ident, value) -> ident || value.length > 7,
        (left, right) -> left || right));

引数3つのreduceメソッドの最初の引数には、引数2つのときと同じく単位元を渡します。
2つ目のメソッドには、途中過程のオブジェクトとStreamの要素オブジェクトが渡されるので、これらから新たな途中過程を生成します。ここで、渡された途中過程のオブジェクトの状態を変えてはいけません。
3つ目のメソッドには、途中過程のオブジェクトが2つ渡されるので、これらから新たな途中過程を返します。


この、2つ目の引数と3つ目の引数は、ほぼ同じ演算になります。


実際には、通常のStreamが渡されたときには、3つ目の引数が呼び出されることはないようです。
問題は、parallelStreamメソッドを使って並行Streamに対して引数3つのreduceメソッドが呼び出されたときで、このときはまず第一引数の値と各要素が第二引数に渡されます。
ふたつの要素が処理されると、3つめの要素の処理と並行して、第三引数の処理が走ります。


このように、通常のStreamと並行Streamで呼び出され方が違うので、使い方に気をつけていないと、通常のStreamでは正常に動作するのに並行Streamでは正しく動かないということになります。


ただ、コレクターが便利なので、あまり出番もない気がします。

まとめ

はよリリースして。

Javaによる関数型プログラミング ―Java 8ラムダ式とStream

Javaによる関数型プログラミング ―Java 8ラムダ式とStream