Taste of Tech Topics

Acroquest Technology株式会社のエンジニアが書く技術ブログ

もしもラムダの中で例外が発生したら(後編)

こんにちは。アキバです。

ゴールデンウィークですね!
皆さんいかがお過ごしですか?

今年は間に平日が多めなので、大型連休!というよりは2回連休があるというイメージの方が強いかもしれません。
cero-tの奥さんは11連休だとか

f:id:acro-engineer:20140311021635j:plain


さて、前回に続いて、ParallelStreamで動かしているラムダ内で、例外が発生した場合の挙動について調べていきます。


まずは、軽くおさらいします。

以下のようなコードを書きました。

try {
    List<String> strArray = Arrays.asList("abc", "def", "xxx", "ghi", "jkl", "xxx", "pqr", "stu");
    strArray.parallelStream().forEach(s -> {
        System.out.println("ラムダ開始: id=" + Thread.currentThread().getId());
        try {
            Thread.sleep(100L);
            if (s.equals("xxx")) throw new RuntimeException("ラムダ内で例外: id=" + Thread.currentThread().getId());
        } catch (RuntimeException ex) {
            System.out.println("ラムダ内で例外発生: id=" + Thread.currentThread().getId());
            throw ex;
        } catch (InterruptedException e) {
            e.printStackTrace(System.out);
        }
        System.out.println("ラムダ終了: id=" + Thread.currentThread().getId());
    });
} catch (Exception th) {
    System.out.println("外側で例外をcatch");
    th.printStackTrace(System.out);
}


そうすると、こんな感じでいくつかのスレッドが終了しない時がありました。

ラムダ開始: id=1
ラムダ開始: id=14
ラムダ開始: id=15
ラムダ開始: id=13
ラムダ開始: id=16
ラムダ開始: id=17
ラムダ開始: id=12
ラムダ開始: id=18
ラムダ終了: id=16
ラムダ終了: id=15
ラムダ内で例外発生: id=13
ラムダ内で例外発生: id=1
ラムダ終了: id=14
ラムダ終了: id=17
ラムダ終了: id=12
外側で例外をcatch
java.lang.RuntimeException: ラムダ内で例外: id=13

それから、なぜかラムダ内では2回例外が発生するようにしているのに、ラムダの外側では1つしかcatchできていません

どうしてなんでしょうか?
というお話でした。

1. 例外のスタックトレースをのぞいてみる

さて、ラムダの中でcatchした例外(2つ)と、ラムダの外側でcatchできた例外(1つ)の違いを見てみましょう。

以下のようにコードを修正します。

try {
    List<String> strArray = Arrays.asList("abc", "def", "xxx", "ghi", "jkl", "xxx", "pqr", "stu");
    strArray.parallelStream().forEach(s -> {
        System.out.println("ラムダ開始: id=" + Thread.currentThread().getId());
        try {
            Thread.sleep(100L);
            if (s.equals("xxx")) throw new RuntimeException("ラムダ内で例外: id=" + Thread.currentThread().getId() + ", s=" + s);
        } catch (RuntimeException ex) {
            System.out.println("ラムダ内で例外発生: id=" + Thread.currentThread().getId() + ", s=" + s);
            // スタックトレースを出力させてみる(ラムダの中でcatchした例外)
            ex.printStackTrace(System.out);
            throw ex;
        } catch (InterruptedException e) {
            e.printStackTrace(System.out);
        }
        System.out.println("ラムダ終了: id=" + Thread.currentThread().getId() + ", s=" + s);
    });
} catch (Exception ex) {
    System.out.println("外側で例外をcatch");
    // スタックトレースを出力させてみる(ラムダの外でcatchした例外)
    ex.printStackTrace(System.out);
    System.out.println("ラムダの外でcatchした例外:ここまで");
}

これを動かすと、ラムダの中で2つ、ラムダの外で1つの例外がcatchできます。
そして、ラムダの外でcatchした例外は、ラムダの中で発生した例外のどちらかになるわけです。

実行した結果は、こうなりました。

ラムダ開始: id=1
ラムダ開始: id=14
ラムダ開始: id=15
ラムダ開始: id=16
ラムダ開始: id=13
ラムダ開始: id=12
ラムダ開始: id=17
ラムダ開始: id=18
ラムダ終了: id=12, s=stu
ラムダ終了: id=17, s=jkl
ラムダ終了: id=14, s=pqr
ラムダ終了: id=16, s=ghi
ラムダ終了: id=15, s=def
ラムダ終了: id=18, s=abc
ラムダ内で例外発生: id=1, s=xxx
ラムダ内で例外発生: id=13, s=xxx
java.lang.RuntimeException: ラムダ内で例外: id=13, s=xxx
	at StreamSample.lambda$parallelStreamExceptionSample2$2(StreamSample.java:121)
	at StreamSample$$Lambda$1/1555009629.accept(Unknown Source)
	at java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:183)
	・・・略・・・
	at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289)
	at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:902)
	at java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1689)
	at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1644)
	at java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:157)
java.lang.RuntimeException: ラムダ内で例外: id=1, s=xxx
	at StreamSample.lambda$parallelStreamExceptionSample2$2(StreamSample.java:121)
	at StreamSample$$Lambda$1/1555009629.accept(Unknown Source)
	at java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:183)
	・・・略・・・
	at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289)
	at java.util.concurrent.ForkJoinTask.doInvoke(ForkJoinTask.java:400)
	at java.util.concurrent.ForkJoinTask.invoke(ForkJoinTask.java:728)
	at java.util.stream.ForEachOps$ForEachOp.evaluateParallel(ForEachOps.java:159)
	at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateParallel(ForEachOps.java:173)
	at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:233)
	at java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:418)
	at java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:583)
	at StreamSample.parallelStreamExceptionSample2(StreamSample.java:117)
	at StreamSample.main(StreamSample.java:58)
外側で例外をcatch
java.lang.RuntimeException: ラムダ内で例外: id=13, s=xxx
	at StreamSample.lambda$parallelStreamExceptionSample2$2(StreamSample.java:121)
	at StreamSample$$Lambda$1/1555009629.accept(Unknown Source)
	at java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:183)
	・・・略・・・
	at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289)
	at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:902)
	at java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1689)
	at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1644)
	at java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:157)
ラムダの外でcatchした例外:ここまで
finished.


上記の例では、Parallelで動かした処理の1つはアプリケーションのmainスレッドでした。
もう一つのスレッドは、ForkJoinTaskのワーカースレッドとして起動しています。

そうです。ParallelStreamは、Fork/Join Frameworkで動いているんです。

Fork/Join Framework、というかForkJoinTaskの実行は、1つのmainスレッドと複数のワーカースレッドで行われます。
この辺の仕様はForkJoinTaskクラスのJavadocにも書かれているので、なにやら小難しいですが読んでみると参考になるかもしれません。

上記の例ではmainスレッドで実行中のTaskで例外が発生しましたが、当然、ワーカースレッドでだけ例外が発生する場合もあります。
(むしろ、その方が普通かもしれませんね)

実際に、上記のコードで"xxx"の位置を変えてやると、例外が発生するスレッドが変わります。

つまり、例外が発生したスレッドがメインかワーカーかに依存してはいけないということになります。


Fork/Joinの実行は、mainスレッドが各Taskの終了状態を見ていて、いずれかのスレッドで例外が発生すると、外側に再スロー(rethrow)する仕組みになっています。
なので、最初に発生した例外だけがラムダの外側でcatchできるということになるのです。

2. 例外が発生したワーカースレッドは、いつ終了するのか?

ラムダ内で例外が発生して外側でcatchした後も、他のワーカースレッドは動き続けています

例外が発生した場合、mainスレッドとしてはラムダの実行は終わっているのですが、それをワーカースレッドが知ることはできないわけですね。
この動作は、例外が発生するスレッドがmainかワーカーかによらず、同じになります。

3. "ラムダ終了"が出なかったワケ

ForkJoinTaskで動くワーカースレッドは、daemonスレッドです。
要は、daemonスレッド以外のスレッドが全て終了すると、プロセスが終了になるということです。


前回、"ラムダ終了"が出なかったスレッドがあると書いたのは、この辺にカラクリがあります。

ForkJoinで実行中のタスクの1つで例外が発生する

mainスレッドが外側に例外を再スローする

mainスレッドが先に終了する

他のForkJoinスレッドはdaemonスレッドで動いているため、実行中でもJavaVMが終了

メッセージ出ない(><

となるわけです。

4. まとめると

これまでの内容をまとめると、ラムダ内で例外が発生した場合の動作は以下の3点になるかと思います。

  • 1. 最初に発生した例外だけ、ラムダの外側でcatchすることが出来ます
  • 2. ラムダ内で例外をスローすると、全スレッドの終了を待つことはできません
  • 3. ワーカーはdaemonスレッドなので、mainスレッドが終了すると、途中でもワーカーの処理は終了します


上の動作から注意すべきなのは、
他のスレッドはラムダの例外終了を検知できないので、異常時に全ロールバックみたいな処理をParallelStreamでやってはいけない
ということでしょうか。

Webアプリケーションなど、処理が終了してもプロセスが終了しないようなサーバーでは、
どちらかというと、ラムダ内で例外は発生させないか、発生しても外側に投げない仕組みにした方がよいでしょう。


いかがでしたか?

また面白そうなネタがあれば調べてみます。


それでは!

Acroquest Technologyでは、キャリア採用を行っています。


  • 日頃勉強している成果を、Hadoop、Storm、NoSQL、HTML5/CSS3/JavaScriptといった最新の技術を使ったプロジェクトで発揮したい。
  • 社会貢献性の高いプロジェクトに提案からリリースまで携わりたい。
  • 書籍・雑誌等の執筆や対外的な勉強会の開催を通した技術の発信や、社内勉強会での技術情報共有により、技術的に成長したい。
  • OSSの開発に携わりたい。

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。
 キャリア採用ページ

もしもラムダの中で例外が発生したら(前編)

ある日、 id:cero-tJJUGの重鎮たちと話している中で、とある宿題をもらいましたとさ。

「Java8のラムダの中で例外が発生したら、どうなるんだろう?」


こんにちは、アキバです。
もう皆さんはJava8を使ってみましたか?
f:id:acro-engineer:20140311021635j:plain

とりあえずインストールしてみた人!

・・はーい (おまえか


という冗談はさておき、
今回は、id:cero-t に代わって私が冒頭のお題を調べてみました。

1. SerialStreamで動かしたラムダで例外が発生したら

まずは、小手調べにシングルスレッドの場合を見てみましょう。


検査例外が発生するようなコードをラムダに書いてみると、コンパイルエラーになります。
こんなコードです。

try (BufferedWriter writer = Files.newBufferedWriter(Paths.get(W_FILENAME))) {
    // writer.write() がIOExceptionをスローするので、catchしろと言われる
    lines.forEach(s -> writer.write(s + '\n'));
} catch (IOException ioex) {
    System.out.println("IOException in Writer-try.");
    ioex.printStackTrace(System.out);
    throw new UncheckedIOException(ioex);
}


そこで、ラムダの中で例外をハンドリングしてみると、確かにエラーが出なくなります。
こんなコードです。

try (BufferedWriter writer = Files.newBufferedWriter(Paths.get(W_FILENAME))) {
    lines.forEach(s -> {
        // ラムダの中で例外をcatchする
        try {
            writer.write(s + '\n');
        } catch (IOException ex) {
            System.out.println("IOException in lambda.");
            ex.printStackTrace(System.out);
            throw new UncheckedIOException(ex);
        }
    });
} catch (IOException | RuntimeException ex) {
    System.out.println("Exception in Writer-try.");
    ex.printStackTrace(System.out);
    throw ex;
}


動かしてみるとどうなるんでしょうね。

あ、今回は、writer.write()で例外を発生させるのも面倒なので、

writer.write(s + '\n');

throw new IOException("IOException in writer");

に変えて動かしています。


動かした結果、こうなりました。

IOException in lambda.
java.io.IOException: IOException in writer
	at study.java8.lambda.StreamSample.lambda$streamExceptionSample1$0(StreamSample.java:68)
	at study.java8.lambda.StreamSample$$Lambda$1/149928006.accept(Unknown Source)
	at java.util.ArrayList.forEach(ArrayList.java:1234)
	at study.java8.lambda.StreamSample.streamExceptionSample1(StreamSample.java:65)
	at study.java8.lambda.StreamSample.main(StreamSample.java:33)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:483)
	at com.intellij.rt.execution.application.AppMain.main(AppMain.java:120)
Exception in Writer-try.
java.io.UncheckedIOException: java.io.IOException: IOException in writer
	at study.java8.lambda.StreamSample.lambda$streamExceptionSample1$0(StreamSample.java:72)
	at study.java8.lambda.StreamSample$$Lambda$1/149928006.accept(Unknown Source)
           :
          (略)
Caused by: java.io.IOException: IOException in writer
	at study.java8.lambda.StreamSample.lambda$streamExceptionSample1$0(StreamSample.java:68)
	... 9 more

最初の行に出た例外のメッセージが「IOException in lambda.」となっていることから、ラムダ呼び出しの中で例外をcatchしていることがわかります。

次に、「Exception in Writer-try.」と出ています。

おぉ、ラムダ呼出しの外側で、ラムダがスローした例外をcatch出来ていますね。
UncheckedIOExceptionの中身は、ラムダ処理自体で発生した例外でした。


つまり、ラムダの中で検査例外が発生するようなコードは、
無名内部クラスと同じように例外をcatchして処理する必要があるということがわかりました。
ただし、無名内部クラスと大きく違うのは、メソッド定義ではないので、例外の宣言は出来ません。
よって、スロー出来るのは検査例外以外(RuntimeExceptionのサブクラスなど)でなくてはいけないということになりますね。


ふむふむ。


では、いよいよ本題です。

2. ParallelStreamで動かしたラムダで例外が発生したら

Parallelってことは、マルチスレッド動作なわけじゃないですか。

  • 発生した例外は誰が受け取るのか?どこまで伝播するのか?
  • 例外が発生したスレッド全てでcatch処理が行われるのか?
  • 例外が発生しなかった他のスレッドはどのような影響を受けるのか?
  • そもそもちゃんと全部のスレッド処理が終了するのか?

皆さんも気になりますよね?
私も気になります。
(だから、それが本題なんだってば)


まずは、こんなコードを書いてみます。

try {
    List<String> strArray = Arrays.asList("abc", "def", "xxx", "ghi", "jkl", "xxx", "pqr", "stu");
    strArray.parallelStream().forEach(s -> {
        System.out.println("ラムダ開始: id=" + Thread.currentThread().getId());
        try {
            Thread.sleep(100L);
            if (s.equals("xxx")) throw new RuntimeException("ラムダ内で例外: id=" + Thread.currentThread().getId());
        } catch (RuntimeException ex) {
            System.out.println("ラムダ内で例外発生: id=" + Thread.currentThread().getId());
            throw ex;
        } catch (InterruptedException e) {
            e.printStackTrace(System.out);
        }
        System.out.println("ラムダ終了: id=" + Thread.currentThread().getId());
    });
} catch (Exception th) {
    System.out.println("外側で例外をcatch");
    th.printStackTrace(System.out);
}

要するに、文字列が "xxx" だったら例外を吐く、それ以外は正常終了するラムダ処理です。
これをParallelStreamで実行するものです。

"xxx" は2つあるので、2回例外が発生するようになっていますよね。

さて、これを動かしてみるとこうなります。

ラムダ開始: id=1
ラムダ開始: id=15
ラムダ開始: id=14
ラムダ開始: id=16
ラムダ開始: id=12
ラムダ開始: id=17
ラムダ開始: id=13
ラムダ開始: id=18
ラムダ終了: id=18
ラムダ終了: id=13
ラムダ終了: id=17
ラムダ終了: id=14
ラムダ内で例外発生: id=12
ラムダ内で例外発生: id=1
ラムダ終了: id=16
ラムダ終了: id=15
外側で例外をcatch
java.lang.RuntimeException: ラムダ内で例外: id=12

ふむふむ、例外もラムダ呼出しの外側でcatchできました

…と思ったら、ラムダ呼出しの外側でcatchできたのは、id=12のスレッドのみでした。


id=1のスレッドで発生した例外はどこに行ったのでしょうか?


では、もう一回実行してみましょう。

ラムダ開始: id=1
ラムダ開始: id=14
ラムダ開始: id=15
ラムダ開始: id=13
ラムダ開始: id=16
ラムダ開始: id=17
ラムダ開始: id=12
ラムダ開始: id=18
ラムダ終了: id=16
ラムダ終了: id=15
ラムダ内で例外発生: id=13
ラムダ内で例外発生: id=1
ラムダ終了: id=14
ラムダ終了: id=17
ラムダ終了: id=12
外側で例外をcatch
java.lang.RuntimeException: ラムダ内で例外: id=13

ふむふむ...あれ?
今度は、id=18のスレッドで「ラムダ終了」が出ませんでしたよ!


頻度は高くないようですが、ParallelStreamの並列処理には、実はこんな不安定なところがあるようです。


まとめると、

  • なぜ、最初の例外しかラムダの外側でcatchできないのか?
  • なぜ、終了が出なかったスレッドが存在するのか?
  • 終了が出なかったスレッドは、実際には終了したのか、それとも残っているのか?
  • ParallelStreamって、Fork-Joinで待ってないの?

そんなところが疑問ですよね。



後編ではその謎に迫ります!

では。

Acroquest Technologyでは、キャリア採用を行っています。


  • 日頃勉強している成果を、Hadoop、Storm、NoSQL、HTML5/CSS3/JavaScriptといった最新の技術を使ったプロジェクトで発揮したい。
  • 社会貢献性の高いプロジェクトに提案からリリースまで携わりたい。
  • 書籍・雑誌等の執筆や対外的な勉強会の開催を通した技術の発信や、社内勉強会での技術情報共有により、技術的に成長したい。
  • OSSの開発に携わりたい。

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。
 キャリア採用ページ

あなたのJavaコードをスッキリさせる、地味に便利な新API 10選(後編)

こんにちは。
アキバです。

本日3/18、ついに、Java8が正式リリースされますね!
もうダウンロードされましたか?ってまだですかね?私はまだです(だって公開前にエントリ書いてるんだもんね)

2014/03/19追記:Oracleのページが更新されました!→こちら


さて、前回に続いて、Java8で追加された地味で便利なAPIを紹介していきます。

今回は、みんな大好きMapConcurrent、あとちょびっとComparatorです。

f:id:acro-engineer:20140311021635j:plain

3. Map操作編

(1) Map#getOrDefault()

これまでは、Mapから値を取得してnullだったらデフォルト値を使用する、みたいなコードを以下のように書いていたと思います。

Map<String, String> map;    // 何らかのMap

String value = map.get("key");
if (value == null) {
    value = defaultValue;
}

Map#getOrDefault()を使うと、この処理が1行になります。

String value = map.getOrDefault("key", defaultValue);

それだけです。ちょっとしたところからスッキリしますね。

(2) Map#replace()

Map#replace()を使うと、該当するキーに対する値を入れ替えられます。

キーが存在することが必須条件ですが、値の指定には2種類あります。

1つ目は、キーさえあれば、強制的に値を書き換えるもの。

Map<String, String> map;    // 何らかのMap
map.replace("key1", "newValue");

この場合は、key1が存在すれば、値はかならずnewValueになります。
key1が存在しないと、何も起こりません。

2つ目は、キーがあって値が一致したものだけ書き換えるもの。

Map<String, String> map;    // 何らかのMap
map.replace("key1", "oldValue", "newValue");

この場合、key1がoldValueである場合に限り、newValueになります。
それ以外の場合は、何も起こりません。

いずれの呼び出しも、戻り値として置換前の値が得られます。
置換しなかった場合は、nullとなるので、書き換わったかどうかを調べることもできます。

(3) Map#computeIfPresent()

Map#computeIfPresent()は、キーが存在している(かつnullではない)時に、値を加工するためのメソッドです。

...なんとなく便利そうですが、いまいちイメージしづらいですね。
長々と説明するよりも、実際のコードを見てみましょう。

例として、Mapのキーを探索して、存在する場合に先頭に「★」を追加する処理を書いてみます。


今までは、以下のように書いていたと思います。

Map<String, String> map;   // 何らかのMap

// キーが存在する名前だけ、先頭に「★」を追加する
String[] keys = new String[] { "a", "c", "d" };
for (String key : keys) {
    if (map.get(key) != null) {
        String oldValue = map.get(key);
        String newValue = '★' + oldValue;
        map.put(key, newValue);
    }
}


Java8では、以下のように書けます。

Map<String, String> map;   // 何らかのMap

// キーが存在する名前だけ、先頭に「★」を追加する
String[] keys = new String[] { "a", "c", "d" };
for (String key : keys) {
    map.computeIfPresent(key, (k, s) -> '★' + s);
}

イディオム的にコードを書くよりも、computeIfPresentという名前で何をしたいのか/何をしているのかがハッキリして良いと思います。
しかも、コードはスッキリ。

ラムダ式になっているところは、BiFunctionというJava8で追加された関数インタフェースです。
ちなみに、Map#computeIfPresent() は default メソッドになっていて、以下のコードであるとAPIドキュメントに書かれています。

if (map.get(key) != null) {
    V oldValue = map.get(key);
    V newValue = remappingFunction.apply(key, oldValue);
    if (newValue != null)
        map.put(key, newValue);
    else
        map.remove(key);
}

最初に書いたコードとほとんど同じですね。
また、これを見ると、BiFunctionに渡される2つの引数は、1つ目がキー、2つ目が(処理前の)値であることがわかります。

4. Concurrent編

(1) LongAdder

Javadocを見ると、「初期値をゼロとした、1つ以上の値の合計を扱う」とあります。
要は、値を合計するためのクラスなのですが、java.util.concurrent.atomicパッケージに属しているだけあって、マルチスレッドからのアクセスに対応しています。

シングルスレッドで使っているとつまらないのですが、こんな感じです。

long[] longArray = { 1, 2, 3, 4, 5 };

LongAdder adder = new LongAdder();
LongAdder count = new LongAdder();
for (long longValue : longArray) {
    adder.add(longValue);
    count.increment();
}

System.out.println("elements count=" + count.sum() + ", sum=" + adder.sum());
// → elements count=5, sum=15 と表示される

他にも、sumThenReset() があるので、一定期間毎の合計を出したりできるかもしれません。

似たような用途に AtomicLong も使えるのですが、APIドキュメントを見ると
「スレッドの競合が高い状況下では LongAdderの方がメモリを消費する代わりに、高速に動作する」
という主旨のコメントが書かれています。

Java8のソースを見る限りでは、合計値の算出タイミングの違いが影響しているようですね。

AtomicLongは、値を追加(addAndGet)する毎に計算を行っていますが、LongAdderの方はCellという内部用のオブジェクトを配列で保持するようになっていて、合計を参照する(sumなど)のタイミングで初めて合計値を計算する仕組みになっているようです。

ということは、複数スレッドが競合するタイミングで計算を含んだ処理でロックの取り合いにはならないということでしょうか。


どのような条件で、どれだけ高速なのでしょうか?
今回もベンチマークしようと思っていたのですが、海外のエントリでベンチマークをとった結果が出ていました。
→日本語訳の引用あり:Java 8ニュース:新しいアトミックナンバーを含むRC版を公開、モジュール化は外れる
 (「新しいアトミックナンバーの実装」という項を見てください)
→原文:Java 8 Performance Improvements: LongAdder vs AtomicLong - Palomino Labs Blog

これによると、シングルスレッドではAtomicLongの方が速いけど、マルチスレッドで競合アクセスさせた場合はLongAdderの方が性能が良いとなっています。純粋なカウントアップ処理を行っているとのことですが、特性としてはドキュメントに書かれている通りになりました。

(2) LongAccumulator

Javadocを見ると、「1つ以上の値の集合を提供された関数で更新する」とあります。
なんとなくLongAdderと同じようなクラスですが、コンストラクタにはLongBinaryOperatorを
指定することになっており、ちょっと凝った動作ができるようです。

簡単な例として、値の2乗を合計するようにしてみましょう。

long[] longArray = { 1, 2, 3, 4, 5 };

LongAccumulator accumulator = new LongAccumulator((x, y) -> x + y * y, 0L);
for (long longValue : longArray) {
    accumulator.accumulate(longValue);
}

System.out.println("square sum=" + accumulator.get());
// → square sum=55 と表示される
//    (1 + 4 + 9 + 16 + 25 = 55)

こちらも、マルチスレッドで処理する場合には利用を検討してみましょう。

5. Comparator編

(1) naturalOrder() / reverseOrder()

最後に、皆さんもよく使っているであろう Comparator で見つけたメソッドです。

唐突ですが、今までJava8の勉強をしてきて、たくさんラムダ式を見たりしてきましたよね。
なので、「文字列を自然順序付けの昇順/降順のソートをする」と聞くと、つい、以下のように書きたくなるかもしれません。

// 昇順にソートする
Arrays.sort(array, (s1, s2) -> s1.compareTo(s2));

// 降順にソートする
Arrays.sort(array, (s1, s2) -> s2.compareTo(s1));

このくらいの処理ならば、ラムダ式でも十分にわかりやすいとは思いますが、Comparator#naturalOrder() と Comparator#reverseOrder() を使うとよりシンプルに記述できます。

// 昇順にソートする
Arrays.sort(array, Comparator.naturalOrder());

// 降順にソートする
Arrays.sort(array, Comparator.reverseOrder());

メソッドの名前からソート順を理解しやすく、さらにコードもスッキリしましたね。


f:id:acro-engineer:20140311021635j:plain:small

いかがでしたでしょうか。

他にも追加されたAPIはまだまだたくさんあります。

前回書いたベンチマークのように、構文は便利になったが、果たして実用に耐えられるのか?ということは常に注意して使わなければなりませんし、その為に中身の動作をよく理解しておく必要があります。

それでも、知らなければ使おうとも思わないという面もあると思います。

ぜひ、皆さん自身でも試してみて、開発効率を上げていきましょう!


ではでは~

Acroquest Technologyでは、キャリア採用を行っています。


  • 日頃勉強している成果を、Hadoop、Storm、NoSQL、HTML5/CSS3/JavaScriptといった最新の技術を使ったプロジェクトで発揮したい。
  • 社会貢献性の高いプロジェクトに提案からリリースまで携わりたい。
  • 書籍・雑誌等の執筆や対外的な勉強会の開催を通した技術の発信や、社内勉強会での技術情報共有により、技術的に成長したい。
  • OSSの開発に携わりたい。

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。
 キャリア採用ページ

あなたのJavaコードをスッキリさせる、地味に便利な新API 10選(前編)

こんにちは!
アキバです。

...T3ブログは初登場かもしれません。ハジメマシテ。
以後お見知りおきを。

いよいよ、2014年3月、Java8が正式公開されますね。
f:id:acro-engineer:20140311021635j:plain

なんと言っても、Java8の注目機能はラムダ式ですので、ラムダ式型推論に関する記事は多いです。
世の中で「Java8」と検索すると、皆さんいろいろと記事を書かれているので、おおよその事はこれで分かっちゃうような気がします。

が、

実は地味に便利なAPIが追加されていたりすることを最近知りました。

これはあまり触れられていないぞ、と。

というわけで、このエントリでは、あまり日本語で情報の無い、しかし地味に便利なAPIに実際に触れてみます。

大事なところなので2回強調して書いてみました。


今回は、前編として4つ紹介します。

APIの紹介と言いつつ、コードにはラムダ式を使ったコードが普通に出てるので、ご了承ください。
 (むしろ、こういったコードを見ながらラムダ式の実際を理解するのもイイかも?)

1. 文字列結合編

これまでのコード

これまでの文字列結合は、おおよそ以下のような感じだと思います。
ここでは、Stringの配列からカンマ区切りの文字列を作る例を考えてみましょう。
(この例は配列ですが、通常はList<String>を使うことが多いと思います)

String[] strArray = { "abc", "def", "123", "456", "xyz" };
String separator = ",";

StringBuilder sb = new StringBuilder();
for (String str : strArrayt) {
    if (sb.length() > 0) {
        sb.append(separator);
    }
    sb.append(str);
}

System.out.println(sb.toString());
// → abc,def,123,456,xyz という文字列が表示される

こんな処理を簡単に書くためのAPIがJava8で追加されました。
(もっと昔からあっても良かったようなものですよね…)

主なものは次の2つです。

(1) String#join(CharSequence, Iterable)

1つ目の新APIは、Stringクラスに追加されたjoinメソッド
文字列を直接並べることもできますが、メインとなるのは配列やListなどのIterableを使う方でしょう。
String#join()を使うと、先ほどのコードは以下のように書き換えることが出来ます。

String[] strArray = { "abc", "def", "123", "456", "xyz" };
String separator = ",";

System.out.println(String.join(separator, strArray));
// → abc,def,123,456,xyz という文字列が表示される

なんと、ループもStringBuilderもなく一行で書けてしまいました。スッキリ。

(2) StringJoiner

2つ目の新APIは、StringJoinerというクラスです。
目的はString#join()とほぼ同じです。
というか、このクラスは、String#joinの中からも呼び出されていたりします

早速、同じように書き換えてみましょう。

String[] strArray = { "abc", "def", "123", "456", "xyz" };
String separator = ",";

StringJoiner sj = new StringJoiner(separator);
for (String str : strArray) {
    sj.add(str);
}

System.out.println(sj.toString());
// → abc,def,123,456,xyz という文字列が表示される

…ループもあるし、意外とスッキリしてませんね(笑)

使い方もStringBuilderと似ていますが、主な違いは以下の3点くらいですね。

  1. 区切り文字を自分で追加しなくなった
  2. 接頭辞と接尾辞を指定できる(CSV全体をカッコで囲むみたいなことができます)
  3. 要素を追加していない時の文字列を指定できる

れっつ、ベンチマーク

ところで、いつのバージョンでも「文字列結合の性能ネタ」はありますが、上記の新APIは、パフォーマンス的にはどうなんでしょうか?

ここでは、以前に @cero-t も紹介していた マイクロベンチマークツール「JMH」を使って、10000個の5バイト文字列("abcde")をカンマ区切りで連結する処理の実行時間を測定してみました。
(※正式リリース前のJava8で実施しているため、参考情報としてご覧ください)

パターンは以下の5つ。
(カッコ内は、マイクロベンチマーク用に記述したメソッド名になります)

  1. StringBufferを使って結合する (bufferJoin)
  2. StringBuilderを使って結合する (builderJoin)
  3. String#joinを使って結合する (joinJoin)
  4. StringJoinerを使って結合する (joinerJoin)
  5. 「+」で文字列を結合する(※いわゆるアンチパターン)(plusJoin)

※2014/03/18 ソースコードは、GitHubからどうぞ。
https://github.com/otomac/StringJoinBenchmark



実行条件:

java version "1.8.0"
Java(TM) SE Runtime Environment (build 1.8.0-b127)
Java HotSpot(TM) 64-Bit Server VM (build 25.0-b69, mixed mode)

JMH実行時のコマンドラインは「-wi 5 -i 10 -f 10」です。
つまり、以下のようになります。
  (A) ウォームアップ実行 5回
  (B) 計測のための実行 10回
  (C) 上記(A)+(B)の繰り返し 10回

計測のための実行は、各パターン100回ずつということになります。

さて、結果は以下のようになりました。

Result : 0.005 ±(99.9%) 0.001 ops/ms
  Statistics: (min, avg, max) = (0.003, 0.005, 0.005), stdev = 0.001
  Confidence interval (99.9%): [0.004, 0.006]


Benchmark                             Mode   Samples         Mean   Mean error    Units
s.j.b.StringJoinStudy.bufferJoin     thrpt       100        5.134        0.033   ops/ms
s.j.b.StringJoinStudy.builderJoin    thrpt       100        5.386        0.023   ops/ms
s.j.b.StringJoinStudy.joinJoin       thrpt       100        5.350        0.019   ops/ms
s.j.b.StringJoinStudy.joinerJoin     thrpt       100        5.357        0.044   ops/ms
s.j.b.StringJoinStudy.plusJoin       thrpt       100        0.005        0.000   ops/ms

この結果から、String#joinを使うパターン(joinJoin)とStringJoinerを使うパターン(joinerJoin)は、StringBuilderを使うパターン(builderJoin)とほぼ同等の速度が出せることがわかります。
StringBufferを使うパターン(bufferJoin)は、想定通り若干遅いくらいの結果になりました。
「+」を使うパターン(plusJoin)も、予想通り論外の性能でしたね(笑

このように、配列やリストのように、既に連結する文字列の要素が揃っている場合はString#joinやStringJoinerを使っても性能に遜色はないことがわかりました。
ぜひ使っていきたいところですね。

2. ファイル読み込み編

これまでのコード

これまでのテキストファイル読み込みは、おおよそ以下のような感じだと思います。
ただ読み込むだけだとつまらないので、XMLファイルの"<" と ">"を丸括弧 "(" と ")" に置換するサンプルを考えてみます。

※2014/03/18追記:うらがみ (id:backpaper0) さんからご指摘いただいて、Pathオブジェクトの生成コードを修正しました。コメントありがとうございます。

List<String> lines = new ArrayList<String>();

Path path = Paths.get("data.xml");
String line;
try (BufferedReader br = Files.newBufferedReader(path)) {
    while ((line = br.readLine()) != null) {
        String replaced = line.replaceAll("<", "(").replaceAll(">", ")");
        lines.add(replaced);
    }
} catch(IOException ex) {
    // 省略
}

whileの条件文に行読み込みの呼び出しがネストしていたりして、若干ややこしい感じがします。
かといって以下のように書くのも間延びする感じがしますね。

List<String> lines = new ArrayList<String>();

Path path = Paths.get("data.xml");
try (BufferedReader br = Files.newBufferedReader(path)) {
    while (true) {
        String line = br.readLine();
        if (line == null) {
            break;
        }
        String replaced = line.replaceAll("<", "(").replaceAll(">", ")");
        lines.add(replaced);
    }
} catch(IOException ex) {
    // 省略
}

さて、Java8ではそんなテキストファイル読み込みも便利にしてくれるAPIが追加されています。
ここでも、2種類見てみましょう。

(1) BufferedReader#lines()

InputStreamやReaderがあらかじめある場合は、こちらの方を使うことになるでしょう。
先ほどのコードは以下のようになります。
(書き換えた結果、結局Pathを作ってBufferedReaderを作っていますが、そのくらいなら良いのかなと)

List<String> lines = new ArrayList<String>();

Path path = Paths.get("data.xml");
try (BufferedReader br = Files.newBufferedReader(path, Charset.forName("UTF-8"))) {
    br.lines()
       .forEach(l -> { strList.add(l.replaceAll("<", "(").replaceAll(">", ")")); });
} catch(IOException ex) {
    // 省略
}

先ほどのコードに比べて、whileループが無いのでスッキリ。

ちなみに、上の例に書いたように、Filesクラスの多くのメソッドでは、Charsetで文字コードを指定することができます。
これも、今まではFileReaderでは文字コードを指定できず、InputStreamReaderを介して指定しなければならなかったのですが、Filesクラスで指定できるようになって、非常に便利になったところですね。
(※追記: UTF-8のような標準的な文字コードは、StandardCharsets.UTF_8でも可です)


読み込んだデータの加工をすることを考えると、ラムダ式やStream APIに慣れておかないといけませんが、単にリストに格納するだけなら以下のようにするのが簡単です(※これはJava7からある書き方です)。

※2014/03/13追記: id:nowokay さんから指摘をいただき、ファイル読み込みを Files#readAllLines() に変更しました。コメントありがとうございます。

Path path = Paths.get("data.xml");
List<String> allLines = Files.readAllLines(path, Charset.forName("UTF-8"));

ただし、メモリを大量に消費する危険性がありますので、大きいファイルを読み込む処理で使ってはいけません。

(2) Files#lines()

ファイルのパスが分かっているだけの場合や、Files#find()を使う場合などは、こちらの方を使うことになるでしょう。

先ほどのコードは以下のようになります。

 List<String> lines = new ArrayList<String>();
 
  Path path = Paths.get("data.xml");
 try (Stream<String> stream = Files.lines(path, Charset.forName("UTF-8"))) {
     stream.forEach(l -> { strList.add(l.replaceAll("<", "(").replaceAll(">", ")")); });
 } catch(IOException ex) {
     // 省略
 }

わざわざファイルからInputStreamやReaderを作らなくてよいので、非常にスッキリしてますね。

…なんとなく予想付いちゃってる方もいらっしゃるかと思いますが、こちらも、内部でFiles.newBufferedReader()を使っているので、ほとんど同じです。



f:id:acro-engineer:20140311021635j:plain:small

ここまで、前半として、文字列操作とファイルからのテキスト読み込みについての追加APIを紹介しました。
なんで、今までこんなメソッドが無かったのか?というくらい普通に使いたくなるものじゃないでしょうか?

ある意味、経験を積んだJavaコーダーさんなら、イディオムとして手が勝手に動くようなコードもあるかと思います。
でも、これからは自分の手じゃなくてコンパイラに仕事をさせて、短くなったコードでもうちょっと違うアイデアを考えていきたいものですね。

次回(後編)は、Map操作とConcurrent系のちょっとしたAPIの追加について、です。

では。


Acroquest Technologyでは、キャリア採用を行っています。


  • 日頃勉強している成果を、Hadoop、Storm、NoSQL、HTML5/CSS3/JavaScriptといった最新の技術を使ったプロジェクトで発揮したい。
  • 社会貢献性の高いプロジェクトに提案からリリースまで携わりたい。
  • 書籍・雑誌等の執筆や対外的な勉強会の開催を通した技術の発信や、社内勉強会での技術情報共有により、技術的に成長したい。
  • OSSの開発に携わりたい。

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。
 キャリア採用ページ

日経ソフトウエアのJava8連載の第3回は「新しい日付API」です!

みなさん、こんにちは!
インフルエンザやノロウィルスが猛威を振るっていますが、みなさん、いかがお過ごしでしょうか。
周りで感染者が増える中、風邪ひとつ引かない、無駄に丈夫なオカダです。
無事是名馬、いい言葉ですね。

■先取り! Java SE 8

Java8の新機能についてAcroquest社員が紹介する短期集中連載
「先取り! Java SE 8」
の第三回が掲載された、「日経ソフトウエア2014年3月号」が出版されました。

http://itpro.nikkeibp.co.jp/NSW
http://itpro.nikkeibp.co.jp/NSW

■第三回は「新しい日時API

日経ソフトウエア2014年1月号」からスタートした本連載。
1月号は「ラムダ式」2月号は「ストリームAPI」について紹介してきました。

今月は、Java8になって大きく変更が入った「日時APIについて紹介します。
題して、「これまでがいかに面倒だったか・・・「日時API」の改良が喜ばしい」です。

これまでのJavaの日時APIは、処理に制約があったり、直感的でなかったりと、色々と「残念なところ」がありました。
Java8では日時APIが完全に作り直されており、既存の問題を解消した非常に利用しやすいAPIになっています。
既存のコードと対比する形で、新しいAPIを利用した場合の利点を紹介していますので、ぜひ、ご一読下さい!

なお、Acroquestのサイトから最初の1ページ分のPDFを閲覧できますので、こちらも参考にしてくださいね。
http://www.acroquest.co.jp/company/press/2014/0124p1.html

■「先取り! Java SE 8」は今回が最終回

3回に渡って連載してきた「先取り! Java SE 8」は、今回が最終回です。
Java8の正式リリースは3月に予定されており、まもなく手元で動かせるようになるでしょう。
一歩進んだJavaプログラミングを身につけるために、今回の連載が助けになれば幸いです。

それでは!

Acroquest Technologyでは、キャリア採用を行っています。


  • 日頃勉強している成果を、Hadoop、Storm、NoSQL、HTML5/CSS3/JavaScriptといった最新の技術を使ったプロジェクトで発揮したい。
  • 社会貢献性の高いプロジェクトに提案からリリースまで携わりたい。
  • 書籍・雑誌等の執筆や対外的な勉強会の開催を通した技術の発信や、社内勉強会での技術情報共有により、技術的に成長したい。
  • OSSの開発に携わりたい。

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。
 キャリア採用ページ

日経ソフトウェアのJava8連載の第2回が出ました!

どうも、かっち です。
最近は随分と寒くなってきて、
みかんとこたつが恋しい季節になってきました。

先取り! Java SE 8

さて、以前 @cero_t がブログに書いていた、
日経ソフトウェアでのJava8連載。
本日2月号が無事に出版されました!!

http://itpro.nikkeibp.co.jp/NSW
http://itpro.nikkeibp.co.jp/NSW

第二回は「ストリームAPI

連載の第二回は「ストリームAPI」を取り上げました。

ストリームAPIの記法に慣れると、これまでのやり方に比べ、
簡単明瞭にデータを処理できることにきっと皆さんも驚きを覚えると思います。
また、これまた簡単に並行処理が実現できてしまうことにも驚きです。
しかし、その使い方には注意があります・・・


そんな内容について記事を書いています。

なお、Acroquestのサイトから2ページ分のPDFを閲覧できますので、こちらも参考にしてください。
http://www.acroquest.co.jp/company/press/2013/1224p1.html

今年の年末年始は、まとまった時間を確保できるまたとないチャンス。
日経ソフトウェアとみかんを片手に、こたつで勉強しましょう!!

以上

Acroquest Technologyでは、キャリア採用を行っています。


  • 日頃勉強している成果を、Hadoop、Storm、NoSQL、HTML5/CSS3/JavaScriptといった最新の技術を使ったプロジェクトで発揮したい。
  • 社会貢献性の高いプロジェクトに提案からリリースまで携わりたい。
  • 書籍・雑誌等の執筆や対外的な勉強会の開催を通した技術の発信や、社内勉強会での技術情報共有により、技術的に成長したい。
  • OSSの開発に携わりたい。

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。
 キャリア採用ページ

ラムダ禁止について本気出して考えてみた - 9つのパターンで見るStream API

こんにちは @ です。

今日のテーマは・・・ラピュタ禁止令!

バルス!

いや違う。ラムダ禁止令、です。


さて、なかなかの滑り出しですが、今日はただのラムダの紹介ではなく、禁止令に主眼を置いて語ります。

このエントリーは、Java Advent Calendar 2013の12/16分の投稿です。
http://www.adventar.org/calendars/145

前日は @sugarlife さんの JDK 8 新機能ダイジェスト (JDK 8 Features) です。
翌日は @setoazusa さんです。

ラムダ禁止令はあり得るのか?

勉強会やその懇親会などで、たびたび「ラムダ禁止令が出るのではないか」が話題に上ることがあります。
「そりゃ禁止する組織もあるでしょうね」というのがお決まりの答えなのですが、ただそれに従うだけでは面白くありませんし、要素技術の発展も滞ってしまうでしょう。そもそも新しい技術を食わず嫌いせず、必要に応じて利用できる文化にしたいですよね。

そんな事を考えながらも、ただStream APIについてはちょっと考えないといけないな、と思わされたのが、Java Advent Calendar 5日目の記事でした。

ToIntFunction<Map.Entry<Car, List<Sale>>> toSize = e -> e.getValue().size();
Optional<Car> mostBought;
mostBought = sales.collect(Collectors.groupingBy(Sale::getCar))
        .entrySet()
        .stream()
        .sorted(Comparator.comparingInt(toSize).reversed())
        .map(Map.Entry::getKey)
        .findFirst();
if(mostBought.isPresent()) {
    System.out.println(mostBought.get());
}

Java Advent Calendar 2013 5日目 - Java 8 Stream APIを学ぶ - kagamihogeの日記

ここで取り上げられている例は、特にStream APIに慣れていないうちは、パッと見ても何をしたいのかがよく分かりません。これを見て「可読性が低い」と捉える向きがいてもおかしくありません。

こういう事がエスカレートすると・・・

若者が複雑なStream APIを書く
 ↓
先輩がレビューができない or テスト不十分で見落としが起きる
 ↓
問題が発生する
 ↓
なぜなぜ分析でStream APIの可読性が槍玉にあがる
 ↓
Stream API全面禁止
 ↓
ついでにラムダも全面禁止

_人人人人人人人人人人_
> 突然のラムダ禁止 <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y ̄

こんな事態が起きるかも知れない、と思いました。特にStream APIが何なのかをよく分かっていない人たちが、Stream APIラムダ式を混同して「ラムダ式禁止」と言いだすことは十分にあり得ます。

今日はこの辺りの話、つまり「業務でStream APIをどのぐらいまでなら使えるか」「使うための注意点は何か」ということを本気出して考えたいと思います。

ラムダ式禁止」だけは抵抗しよう

まず最初に言っておくと「ラムダ式」の禁止はあり得ません。言ってしまえばほら、ラムダ式はただの匿名クラス(無名クラス)のシンタックスシュガーみたいなものであって、何も難しいところはないからです。

簡単なおさらいとして、Comparatorの例を見てみましょう。

Comparator<Emp> comp1 = new Comparator<Emp>() {
    @Override
    public int compare(Emp emp1, Emp emp2) {
        return emp1.getSal() - emp2.getSal();
    }
};

Comparator<Emp> comp2 = (emp1, emp2) -> emp1.getSal() - emp2.getSal();

Comparator<Emp> comp3 = Comparator.comparingInt(Emp::getSal);

このcomp1、comp2、comp3はいずれも同じ処理をするComparatorです。comp3はやや見慣れない形だとしても、comp2までなら昔ながらのエンジニアにもまだ理解できる記述だと思います。

そのため、匿名クラス(無名クラス)自体を禁止している組織ならまだしも、そうでない組織でラムダ式自体が禁止されることは賛同できません。それこそ「食わず嫌い」の典型なので、もし皆さんの組織(の標準化グループ)がラムダ式禁止だと判断しそうなら、ぜひ抵抗してください。

あるいは、ラムダ式禁止を逆手に取って、Stream APIを匿名クラスまみれで書くことで、抵抗しても良いかも知れません(笑)
# 半年後、そこには元気に走り回るStream API禁止令の姿が!

ただし、単に禁止に反対するだけでなく、相手の懸念している「可読性が下がって困る」というところに応えるためにも、「ここまでなら使っても問題は起きないでしょう」という提案も同時に行なうべきだと思います。

その辺りが、今日のテーマになります。

Stream APIは、どこまで許されるのか?

さて、繰り返しになりますが、今日の本題はラムダ式ではなく、Stream APIです。
ラムダ式自体は簡単なので禁止にする理由がないと書きましたが、Stream APIの方は簡単ではなく、これをむやみやたらに使って炎上すると、Stream API自体を禁止とする組織が出てきてもおかしくありません。

では、どこまでならサクサクと読めるのか、考えてみましょう。

今回は、特に「あまりStream APIに慣れていない人」をターゲットにして書きますので、「自分、streamの数珠つなぎ200行ぐらい普通に読めるんで」のようなマサカリを装備する必要ありません。


なお、今回紹介する機能や処理の「禁止度」について、以下のようにレベルづけをします。

C : 業務で使っても全く問題ないレベル。
B : やや疑問を呈されるけど、積極的に使いたいレベル。
A : 業務では使わない方が良いレベル。
S : 積極的に禁止したいっていうか、使ったら書き直させるレベル。

特に禁止度Bあたりは、古豪のエンジニアから「読めないから使うな」と言われかねないところなので、組織のレベルを見ながら利用するようにすべきです。

1. 同じオブジェクトに対する連続した操作(禁止度:C)

まずStream APIを使った典型的な例が、同じオブジェクトに対してフィルタやソートなどの連続した操作を行なうというものです。

List<Emp> filterAndSort1(List<Emp> list) {
    return list.stream()
            .filter(emp -> emp.getSal() > 1000)
            .sorted((emp1, emp2) -> emp2.getSal() - emp1.getSal())
            .collect(Collectors.toList());
}

このぐらいであれば、「給料が1000より大きい」ものを抽出して「降順にソート」していることを読み取ることは容易です。問題ありませんね。

2. Comparatorのstaticメソッドを使ったソート(禁止度:B)

上に書いたソースを少し修正して、ソートのところをComparator.comparingIntにすることもできます。

List<Emp> filterAndSort2(List<Emp> list) {
    return list.stream()
            .filter(emp -> emp.getSal() > 1000)
            .sorted(Comparator.comparingInt(Emp::getSal).reversed())
            .collect(Collectors.toList());
}

IntelliJ IDEA 12.1.6を使っていると、Emp::getSalのところで「cyclic inference」というエラーを出してくるのですが、実際には問題なく動作します。IDEがエラーを出してくる辺りに、少し不吉なにおいを感じますね。そういう背景もあって禁止度を一つ上げてBにしました。


さらに、OpenJDK8 build111では、以下のような記述は正常にコンパイルされるのですが、

.sorted(Comparator.comparingInt(emp -> emp.sal))
.sorted(Comparator.comparingInt(emp -> emp.getSal()))
.sorted(Comparator.comparingInt(Emp::getSal).reversed())

以下のようにすると、コンパイルエラーになってしまいます。

.sorted(Comparator.comparingInt(emp -> emp.sal).reversed())
.sorted(Comparator.comparingInt(emp -> emp.getSal()).reversed())

どうもreversedをつけると、comparingIntに渡すToIntFunctionの型解決ができなくなってしまうようです。

開発途中とは言え、コンパイラーですら理解できなくなるのだから、人間にも理解しにくい構文なのでしょうか。
昔ながらのJavaエンジニアに配慮するなら、Comparator.comparing*などは使わず、最初の例に挙げたように、

.sorted((emp1, emp2) -> emp2.getSal() - emp1.getSal())

こう書くのが、一番分かりやすいでしょう。

3. mapとreduceを使った操作(禁止度:B)

続いて、特定の項目のみを抽出して集計するような処理を考えます。
下の例は、1000より大きい給料のうち、その2倍の値の平均を取るという、やや謎の処理です。

Double averageSal(List<Emp> list) {
    return list.stream()
            .filter(emp -> emp.getSal() > 1000)
            .mapToInt(emp -> emp.getSal() * 2)
            .average()
            .getAsDouble();
}

これも「map*」メソッドの役割さえきちんと理解してもらえれば、前からスラスラ読むことができます。

続いて、reduceを使う例も見てみましょう。下の例は、1000より大きい給料のうち、給与から1000を引いたものを合計するという、これまたやや謎の処理です。もちろんsum()でも合計はできますが、あえてreduceを使ってみました。

int someCalc(List<Emp> list) {
    return list.stream()
            .filter(emp -> emp.getSal() > 1000)
            .mapToInt(emp -> emp.getSal() - 1000)
            .reduce(0, (x, y) -> x + y);
}

これも「reduce」メソッドの役割さえきちんと理解すれば、スラスラ読んでもらえるものでしょう。

しかしながら、いわゆる関数型プログラミングに慣れていないエンジニアには、mapやreduceという処理には親しみがないため、これも読みにくいと言われかねません。

この辺りを標準的に使いたいのであれば、社内勉強会などを開催して、まずは「filter」「sorted」「map」「reduce」あたりを説明すると良いのではないかと思います。ここまで使えれば、だいたい勝てます(何に?)

4. groupingByやtoMapを使ったMapへの変換(禁止度:B)

Stream APIを使っていて、便利だなと感じるのがこのgroupingByによる集計処理。SQLのgroup byと同じようなものです。
これは型を変えてしまうだけに処理がやや分かりにくくなるのですが、やはりこれも積極的に使いたいですね。2例続けて見てみましょう。

Map<Dept, List<Emp>> groupByDept(List<Emp> list) {
    return list.stream()
            .sorted((emp1, emp2) -> emp2.sal - emp1.sal)
            .collect(Collectors.groupingBy(emp -> emp.dept));
}
Map<Integer, List<Emp>> rankedBySal(List<Emp> list) {
    return list.stream()
            .sorted((emp1, emp2) -> emp2.sal - emp1.sal)
            .collect(Collectors.groupingBy(emp -> {
                if (emp.sal > 4000) return 1;
                if (emp.sal > 2500) return 2;
                if (emp.sal > 1000) return 3;
                return 4;
            }));
}

前者は部署(dept)によるグループ化を行い、後者は給与水準によるグループ化を行なっています。groupingByがCollectionからMapに変換するための処理であることさえ把握していれば、このソースもサクサクと読むことができます。


また、groupingByでは、SQLと同じように値に集計結果を入れることもできます。

Map<Dept, Long> groupByDeptAndAve(List<Emp> list) {
    return list.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept, Collectors.averagingInt(Emp::getSal)));
}

この例では部署ごとに平均給与を計算しています。とてもありそうな処理ですね。


しかしながら、SQLにおいても「集計関数がよく分からない勢」が一定数存在することが現在までに確認されています。
数学の勉強をしてから出直してこいと思うのですが 集計関数によく習熟したメンバでチームを作り、お互いにレビューをできるような体制を作れば、事故ることを減らせると思います。

5. groupingByからのentrySet.stream大作戦(禁止度:A)

続いて、部署(dept)を、人数の少ない順に並べるという処理です。

List<Dept> sortDept1(List<Emp> list) {
    return list.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept, Collectors.counting()))
            .entrySet()
            .stream()
            .sorted(Comparator.comparingLong(Entry::getValue))
            .map(Entry::getKey)
            .collect(Collectors.toList());
    }
}

Mapのstream処理自体が分かりにくいという事情はあるにせよ、もともとListであったものをMapにして、さらにentrySetを取り出してstream処理を続けるというのは、型が分かりにくくなり、可読性が落ちると言わざるを得ません。というか、私のこの日本語の説明自体も、よく分かりません

しかもこの辺りから、IntelliJ IDEAの自動補完はほぼ効かなくなりますし、エラーもたくさん出るようになります(でも実際にはコンパイルが通って実行できるので、余計に厄介)

こうなってくると、禁止度はさらに上がってAクラスになるでしょう。


では、このソースのどこが可読性を落としているのでしょうか。ポイントは「entrySet().stream()」にあると私は思います。ここで、せめてcollectした後にローカル変数に代入すれば、まだしも読みやすくなるのではないでしょうか。

List<Dept> sortDept2(List<Emp> list) {
    Map<Dept, Long> map = list.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept, Collectors.counting()));

    return map.entrySet()
            .stream()
            .sorted(Comparator.comparingLong(Entry::getValue))
            .map(Entry::getKey)
            .collect(Collectors.toList());
}

部署ごとの人数を一度Mapに代入してから、人数でソートしてkeyのみ(部署のみ)取り出しています。このようにすれば、禁止度はBクラスまで落とせるように思います。

「collectした後は、一時変数に代入する」という新しい鉄則を作るという案、どうでしょうかね。


それにしても、Mapの(entrySetの)Streamは分かりづらいですよね。実は2012年前半の時点ではMapStreamというクラスがあり、分かりやすくMapのstream処理を記述することができました。
なぜ消えたのか、背景や理由はよく把握していませんが、いずれにせよMapStreamが消えたせいで、いまのようなstream処理しかできなくなり、可読性が低くなっているというのが現状です。

6. ネストしたstream(禁止度:A)

Mapの集計を考えると、streamをネストさせたくなることがあります。これも2つ例を続けて掲載します。

Map<Dept, Long> groupByDeptAndFilter1(List<Emp> list) {
    return list.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept, Collectors.collectingAndThen(Collectors.toList(),
                    emps -> emps.stream()
                            .filter(e -> e.sal > 1000)
                            .count())));
}
Map<Dept, Long> groupByDeptAndFilter2(List<Emp> list) {
    return list.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept))
            .entrySet()
            .stream()
            .collect(Collectors.toMap(entry -> entry.getKey(),
                    entry -> entry.getValue()
                            .stream()
                            .filter(emp -> emp.sal > 1000)
                            .count()));
}

ソースコードの意味、分かりますか?

部署をキーにしたMapを作ったうえで、Mapの値のほうには給与が1000を超える社員の人数を入れています。フィルタ処理を入れたいがために、ただの集計処理が使えず、Streamを利用しています。
ここまで来るとかなり可読性が下がり、事故の原因にもなります。

※2013/12/20 修正 - 初出時のソースに誤りがあり、訂正しました。
Streamをネストせざるを得ない時は、たとえば昔ながらのfor文も併用するというのも、ひとつ読みやすくするための手段になると思います。

Map<Dept, Long> groupByDeptAndFilter3(List<Emp> list) {
    Map<Dept, List<Emp>> map = list.stream()
            .collect(Collectors.groupingBy(emp -> emp.dept));

    Map<Dept, Long> result = new HashMap<>();
    for (Entry<Dept, List<Emp>> entry : map.entrySet()) {
        long count = entry.getValue()
                .stream()
                .filter(emp -> emp.sal > 1000)
                .count();
        result.put(entry.getKey(), count);
    }

    return result;
}

ちょっと微妙? ただ、Java8時代には意外とこんなコードが出てくるかも知れません。


もしかすると、業務要件を考えて「あれ、事前にフィルタリングすれば良いだけじゃないの?」と思った方もいるかも知れません。

Map<Dept, Long> groupByDeptAndFilter4(List<Emp> list) {
    return list.stream()
            .filter(emp -> emp.sal > 1000)
            .collect(Collectors.groupingBy(emp -> emp.dept, Collectors.counting()));
}

事前にフィルタリングすれば、シンプルに記述することができます。しかしこうしてしまうと、処理の結果が少し変わってしまうのです。
「給与が1000未満の人しかいない部署」があった場合、groupByDeptAndFilter3までは「0人」として結果を取得できますが、groupByDeptAndFilter4ではそもそも結果のMapに当該の部署は表れません。
今回の例ではそれでも良いかも知れませんが、より複雑な業務処理になると、そのような差分が問題になることも多々あるでしょう。

このように、集計結果が分かりづらくなってくるところも、SQLとよく似ていますね。SQLのエキスパートが必要なことと同様に、Stream APIのエキスパートも必要だと思いますね。

7. streamの外に結果を残す(禁止度:A)

給与の平均と合計を同時に算出したい、という場合です。

void averageAndSum1(List<Emp> list) {
    final Map<String, Integer> dummy = new HashMap<>();
    dummy.put("RESULT", 0);
    double ave = list.stream()
            .mapToInt(emp -> {
                int sal = emp.getSal();
                dummy.put("RESULT", dummy.get("RESULT") + sal);
                return sal;
            })
            .average()
            .getAsDouble();
    System.out.println("ave=" + ave + ",sum=" + dummy.get("RESULT"));
}

ラムダ式がアクセスできる対象はfinalのみなので、ここではdummyというfinalのオブジェクトを定義して、そのオブジェクトに対して値の出し入れをしています。
特にparallelStreamで並列化した時には、確実に問題が起きることも踏まえると(たとえConcurrentHashMapにしていてもです)この記述は避けるべきでしょう。

ちなみに合計と平均の両方を計算したいだけであれば、summaryStatisticsメソッドを利用することで、代表的な集計結果をできます

void averageAndSum2(List<Emp> list) {
    IntSummaryStatistics statistics = list.stream()
            .mapToInt(Emp::getSal)
            .summaryStatistics();
    System.out.println("ave=" + statistics.getAverage() + ",sum=" + statistics.getSum());
}

IntSummaryStatisticsから集計結果を取得することができるのです。

このクラスから取得できないような独自の計算をしたい場合は、大人しくfor文で書くか、自分でCollectorを作るという方法がありそうです。自分でCollectorを作る方法は、また改めて紹介します。

8. stream中に元のオブジェクトを操作(禁止度:S)

やる人がいそうなので、念のため。

Double someOperation(List<Emp> list) {
    return list.stream()
            .filter(emp -> {
                list.remove(emp);
                return emp.getSal() > 1000;
            })
            .mapToInt(Emp::getSal)
            .average()
            .getAsDouble();
}

listのstream処理中に、listに対して要素の追加や削除をするというものです。この例のlist.remove(emp)は恣意的なものなので業務的な意味はありませんが、このように元のオブジェクトに対して操作をするという人は必ず出てくると思います。

元のListがArrayListなのかCopyOnWriteArrayListなのかで動きも変わりますし、ただでさえ動きの掴みづらいstream処理で、このような危険な実装は避けるべきでしょう。
どうしても元のオブジェクトに手を入れながら処理するのであれば(最適なstream処理が思いつかないなら)大人しくfor文で書くべきでしょう。

9. parallelStreamを使ってDB/Webアクセス(禁止度:S)

parallelStreamの効果を試すために、Webにアクセスをするようなサンプルもありますが、実案件ではそのような実装は避けましょう。
マルチスレッドでDB/Webにアクセスをしたいのであれば、スレッド数や、各スレッドの状態をきちんと管理・把握できる、ExecutorService使うべきです。

っていうか、parallelStreamを使ってDBやWebにアクセスなんかしたら絶対に問題が起きるし、むしろ運用中に発現して大問題になってStream API禁止令の引き金になるから、ホントやめて!(><)

まとめ

1. ラムダ式は禁止される理由がない!
2. filter/sort/map/reduce/collect/groupingByあたりの勉強会を行うべし!
3. Comparator.comparing* の使用は少し控えよう!
4. collectをしたら一度ローカル変数に入れよう!
5. Stream APIのエキスパートが近くにいたほうがいい!
6. streamからのstreamとか、ネストしたstreamとかは避けよう!
7. streamの親オブジェクトや、外の変数を触るんじゃない!
8. DBとかWebにアクセスするんじゃない!

まめ知識

「ラムダ」で画像検索したらラピュタのロボット兵が出てきたので、なにごとかと思って調べてみたら、ルパン三世「さらば愛しきルパンよ」に、ロボット兵が「ラムダ」という名前で登場していたようですね。
http://ja.wikipedia.org/wiki/%E3%81%95%E3%82%89%E3%81%B0%E6%84%9B%E3%81%97%E3%81%8D%E3%83%AB%E3%83%91%E3%83%B3%E3%82%88

そう、最初に敢えて滑ったのは、この伏線だったのですよ。敢えての、滑りだったんですよ。敢えての(←しつこい)

それでは皆さん、良いラムダライフを! バルス!

Acroquest Technologyでは、キャリア採用を行っています。


  • 日頃勉強している成果を、Hadoop、Storm、NoSQL、HTML5/CSS3/JavaScriptといった最新の技術を使ったプロジェクトで発揮したい。
  • 社会貢献性の高いプロジェクトに提案からリリースまで携わりたい。
  • 書籍・雑誌等の執筆や対外的な勉強会の開催を通した技術の発信や、社内勉強会での技術情報共有により、技術的に成長したい。
  • OSSの開発に携わりたい。

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。
 キャリア採用ページ