導入経緯から理解するストリームAPIとラムダ式

Java SE 8はなんといってもストリームAPIとラムダ式。変更が複数ヶ所にあるのでどこから手をつければよいのか困ってしまうが、導入経緯を探っていくと理解しやすいのでまとめた。「内部イテレーション」「defaultメソッド」「ラムダ式」もそれぞれ解説する。

並列処理にはそれが必要だった

導入経緯をまとめると、次のような話。(個人の主観的がかなり含まれています。時系列などの詳細はご自身でお確かめ下さい。)

  1. CPUがマルチコア化しているのに
    プログラム側が対応しきれていないのはもったいない。

  2. 並行処理用のAPI(Concurrency Utilities)があるじゃないか。

  3. 粒度の大きな処理は問題ないが、
    粒度の小さな処理を並列化する場合には使いにくい。

  4. じゃあイテレータに注目するしかないか。
    反復されている処理が簡単に並列化できればうれしい。
    イテレータを改良すればなんとかなるのでは。

  5. でも今の(外部)イテレータを崩さずに並列化するのは結局不便。

  6. じゃあ内部イテレータをできるようにするか。→ ストリームAPIの導入

  7. 内部イテレータ用のメソッドをコレクションに加えたりしたら
    以前のコードが軒並みコンパイルエラーになってしまうよ。→ defaultメソッドの導入

  8. それに内部イテレータができるようになっても
    処理を書くときに無名クラスを書いてるようじゃ、ごちゃごちゃしてしまうぞ。

  9. じゃあ、省略して処理を書く記述方法が必要だな。→ ラムダ式の導入

ざっとこんなかんじ。

CPUのマルチコア化の流れが大き後押ししたかたち。ムーアの法則も崩れ気味でハードの性能が今までのように上がらないとすると、CPUをもっと活用するため並列処理などを取り入れていく必要がある。そんなハード側の流れにおいてプログラム言語はそれをやりやすくしなくてはいけないは必然だった。

Javaにおいてはスレッドは言語仕様に組み込まれているし、Concurrency Utilitiesで大きな単位の処理は並行化できているので、残るは小さな処理ということになる。ちなみにここでいう粒度の小さい処理とは、1~数行の処理ぐらいのこと。粒度の大きい処理とは、10行以上の大きめのメソッド1つ分ぐらいのまとまった処理ってこと。

J2SE 5.0からのConcurrency Utilitiesは確かに強力だけどよほどの遅い処理ではない限りいちいちfor文内の処理1行(粒度の小さい処理)を並列化しようという発想はならない。「Executorの選定」「同期はどうするか」「しっかり例外処理できているか」など並列処理で考えることは多いから気合いを入れたところしかできないのが現実。小さな処理には向かない。

そうなるとイテレーションに注目する。実はこれは自然な流れで、これを簡単に行うことができる言語はいくつかある。ループ内の繰り返される処理をスレッドで分担して実行できればという発想。ただこれを行うには内部イテレーションが必須となるので、ストリームAPIの導入になり、defaultメソッドの導入になる。

ラムダ式についてはJavaでもできるようにしようって人は昔からいた。けど賛成派と反対派は五分五分で勢いは少なかった。ただここに来てストリームAPIの導入に乗っかり復活してきたかたち。匿名クラス頑張って書くではしんどい。

こんな流れでハード側の変化にプログラム言語が対応した結果「ストリームAPI」「defaultメソッド」「ラムダ式」導入された。

外部イテレーションと内部イテレーションを理解する

外部イテレーション
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
for (int number : numbers) {
    System.out.println(number);
}
内部イテレーション
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.stream()
       .forEach(number -> System.out.println(number));

違いは次のようになる。

  • 外部イテレーション

    • for文によるイテレーション

    • 利用者が反復処理を記述する

  • 内部イテレーション

    • ライブラリによるイテレーション

    • 利用者が反復処理を記述しない。ライブラリが反復処理をしてくれている

外部か内部とはライブラリの外側か内側かということ。

内部イテレーションは処理を渡しているだけで利用者は反復処理を記述しない。ライブラリが反復処理をしてくれている。ここがポイント。利用者側は反復処理を書く責任から逃れることになり、ライブラリに請け負ってもらえる。並列処理をライブラリ側でしっかり用意してくれれば、 利用者が直接並列処理を書かなくても、メソッドを呼び出すだけでライブラリ側で並列処理することが可能ってことになる。

一方、外部イテレーションは当然利用者が反復処理を行う。並列化したければ自分で責任持って行わなければならない。

これがストリームAPIの根幹になっている考え方。イテレーションの責任をライブラリが持つことで利用者がそこまで準備しなくても並列処理が可能になる。

下位互換を保つためのdefaultメソッド

内部イテレーションを行うためにはライブラリ側にストリームAPI用のメソッドを追加しなくてはいけない。ストリームの起点になるのはコレクションや配列などの集合。よってjava.util.Collectionなどにメスを入れる必要がある。

ただこれをおこなうとなると下位互換が大きな問題になる。CollectionインタフェースはJ2SE 1.2から存在しているインタフェースで標準APIでもかなり利用されている部類になる。独自の実装が作られていることも多いインタフェースの一つである。java.util.Collectionなどにメスを入れるということはそれらが軒並みコンパイルエラーになるので、Java SE 8に変更しようなんて気になれなくなってしまう。そこで、下位互換を保つためのdefaultメソッドを導入に踏み切った。

Java SE 7までJavaのinterfaceは処理を書かない(書けない)ものとして頑張ってきたがそれを解禁することになった。interfaceにdefaultというキーワードをつけると処理を記述できるようにした。

java.util.Collectionで使われているdefaultメソッドの例
public interface Collection<E> extends Iterable<E> {
    default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }
}

これにより新たなメソッドをインタフェースに追加してもdefaultメソッドを記述しておけばコンパイルエラーにならないので、下位互換を保ちながらjava.util.CollectionにもストリームAPI用のメソッドを追加できた。

ところで、インタフェースは複数implements可能なのだから多重継承ができるようになったのかという気がするがそういうかんじでもない。defaultメソッドにより処理は書けるが状態は保持できないので通常考えられる(クラスにおけるメソッドのような)処理を書ききることができない。よって複数implementsによって処理を多重継承できるが気もするが不完全でもある。多重継承が可能になってしまうとこれまでのJava多重継承できないというポリシーを崩してしまうので、そこは抑えましたというようなところだろう。ちなみに複数のimplementsが同じ親インタフェースを継承していた場合にどの処理が優先されるのかというような菱形継承の問題については、ちゃんとルールはある。

ほぼ関数のように記述できるラムダ式

defaultメソッドの導入によって内部イテレータを実現する仕組みができた。残りは処理を渡す方法になる。ただJavaでは処理だけを渡す方法はない。

GUIなどの例をとると匿名クラスという書き方がある。

Runnable runnable = new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello World");
    }
};

GUIの経験がある方は感じたことがあると思うがごちゃごちゃする感が否めない。今まではGUIの人にガマンしてもらってきたが、ストリームAPIとなるとそうはいかない。あらゆる人が不快に思っては困るのでラムダ式の導入ということになる。

慣れてない(知らない)人にとってラムダ式はとても奇妙に見える。ただあまり恐れることはなくて、(ラムダ計算を本格的にするということではないのだから)とりあえずは単に処理を簡素に書く表現方法というとらえ方でいい。ラムダ式自体はJavaなんかより遥かに昔から存在する。

ラムダ式をつかむために次の例を考える。どんなプログラミング言語でも関数を書くときは、最低限このぐらいの記述が必須に思える。

int plus(int x,int y){
  return x + y;
}

しかしよく考えるとこれは冗長で、括弧といくつかの記号だけで十分表現することができる。ラムダ式はそれを極端に追求する書き方で、実際Lispのラムダ式ではこんな風に表現できる。

(defun plus (x y) (+ x y))
(plus 1 2)

Javaでもラムダ式を使うとこんなかんじまでいける。匿名クラスのコードより、余計なものがなくなって相当スッキリに書けるようになった。

(x,y) -> x + y;

このようにラムダ式の導入でほぼ関数のように記述できたり、処理を受け渡しできるようになった。

関数型言語になるには、(例えばJavaScriptのように)関数を変数に代入したり引数で渡せたりする第1級オブジェクトとして扱えることがほぼ必須と思えるがJavaはそこまではしなかった。ラムダ式で書いたかいた処理は匿名クラスのインスタンスになるだけなので関数ではない。

関数型言語になることを目的としてもしかたがないので、ラムダ式の導入によりコードのスタイルとして関数型的な記述ができるようになった部分が大きい。モダンなスタイルで書けるようになり他の言語に少し追いつけたイメージもあるし、使ってみるととても強力。もちろんストリームAPI以外でも使える。

まずはfor文 → 順次処理によるストリームAPI

「ストリームAPI」「ラムダ式」について理解できたら、どう使うかということを考えたいところ。

外部イテレーション→内部イテレーションにしていくということなので、これまでのfor文で書いていたところをストリームAPIで記述していくことになる。そして並列化に繋げるのが理想。

ただ並列化に関して言えば、並列化できるところは意外と少ないし注意が必要。なんでもかんでも並列化すればいいということではないし、全部並列化する必要もない。いくらストリームAPIが並列部分を受け持つといっても、使い方を誤れば並列処理特有のバグにハマることもある。最低限の並列処理に関する知識は必要になってくる。

なので、ストリームAPIとラムダ式で記述するが並列処理にはしないことをオススメする。その後に必要に応じて並列処理に変更すればいい。順次実行か並列実行かはstream()かparallel()かを切り替えるだけで簡単にできる。ストリームAPIとラムダ式で書くことによってより宣言的記述できるという効果もあります。

まずはfor文で書こうと思ったところを全部stream()で書くところからスタートしよう。いずれ慣れます。

Appendix B: 改訂履歴

  • v1.0, 2014-06-15: 初稿