問い:Java 8のStream APIは業務でどんな時に使うの? 答え:あなたがfor文使いたい時

※ サンプルがJDK7までとJDK8までで意味が変わっていてわかりにくいという指摘があったので、少し直しました。 ※ boxedを使う書き方だと無駄なAutoboxingが走るとの指摘を頂きましたのでmapToObjを利用するように変えました。

Java8の目玉機能の一つにStream APIがあります。

目玉機能だけあって、先日のJava Day Tokyo 2014を含めて色んな所で発表やブログの記事が公開されているので、どんなものかを知ってる人は多いと思います。

Stream APIといえば「".parallel()"と書くだけで並列化してスピードアップ出来る!」という魅惑的なキーワードで紹介されることが多いので、並列化のための仕様だと勘違いされそうですが、そうではありません。 ※ もちろんそういった記事の中をちゃんと読めばそう単純な話じゃないことも分かります。

むしろ、並列化に関しては、Webアプリケーションなど一つの処理が十分に小さく大量にさばくことを期待されるシステムだと積極的に忘れても良い気がします。

もちろん、それでも価値があります。

一般に内部イテレータと呼ばれるもので、Ruby, Python, JavaScript, C#, Scala, PHP, Hakell, LISPと出自である関数型言語系はもちろんのこと、RubyPython、JSといったLL系Javaと類似の用途で使われるC#も似たようなものを持っています。

現在業務で使われやすい言語では概ね対応しているのでその辺に親しみがある人は、ようやく素直に出来るようになったのかという感じでしょう。

一方で、Stream APIでどんな事ができるのか分かったけど、業務のどこで使えば良いのかが分からないという意見もチラチラ聞きます。 実際、私は初めてRubyで内部イテレータを使ったのですが、最初はfor文を使わない文化に戸惑ったものです。

Webの記事とか見ても「for文禁止!」とかセンセーショナルな書き方がされることが多くて、実際その通りと思いはするものの、Java8怖いとかになって普及が阻まれないかなぁとも思うので、今回は「for文禁止」とまで言われるその背景の説明をしたいと思います。

なお、Stream APIそのものの機能や詳細はすでに色々なところで語られているので、今回は省きます。

語彙によって意図を明確にする

Javaに拡張for文(foreach)が入った時にも同じような話をしたのですが、言語やFWの語彙を適切に使うことでコードの意図を明確に出来ます。

これは本質的には適切なクラス名やメソッド名を記載するというのと同じことです。

まずはこいつを見てくれ・・・どう思う?

gist182f1fc4863bf41018df

Java的に遺失呪文(予約語だがコンパイルエラーになる)なgotoを使ったFizzBuzzです。ループ構文が無い古い言語仕様で書く時は今でもこうかく必要がありますね。

一言で言えば「わけがわからないよ

gotoはご存知の通り極めて柔軟な仕様なので、ループという概念に限定されておらず、良く読まないと繰り返しであることが分かりません。そして、汎用性の高い構文なので、もちろん記述が冗長です。

つづいて同様にループを表現する構文であるWile文を使ってみましょう。

gist127ff4b858d89474bc71

さっきより大分見やすくなりましたね。Wileを使うことでループ表現であることが瞬時に理解出来ます。 ただ、これでは1から100までの順次表示であることが表現出来ていません。なので、普通はfor文で書きますよね?

gistfe59ae6d8465a24ac5df

ようやく普通の感じになってきました。落ち着きますね。

ここでfor文の良い所は1から100まで1個ずつインクリメントしてプログラムが処理されることをシンプルに保証したことです。

さて、ここまで読んでJavaのようにfor文が使える言語で、FizzBuzzをgotoやwhileを使って書きたいという方はいらっしゃるでしょうか?

え、書きたいんですか!? もの好きですね。。。 

でも、仕事では迷惑なので書かないでください。

長々と書いてしまいましたが、ここで言いたかったことは、そのプログラム言語が許す限り適切な表現で目的を記述しましょうということです。

殆どの場合、昔からある既存の書き方でも表現出来るでしょうが、新しい表現が出来たということはそれに特化したユースケースが有るということなので、そのケースでは新しい表現を使うことでより抽象度が高く、簡潔で理解しやすいコードになります。これはもちろん今回導入されたStream APIも同様です。

Loopではなくリスト操作

さて、前章は長かった割には完全にStream API関係無い話でした。ここからはそもそもどういうユースケースを想定しているかを話していきたいと思います。

ところで、前章で最後に書いたFIzzBuzzのコードが業務でコードレビューに上がってきたらどうしますか? 私は基本的にNGにします。

なぜなら、ロジックの中で標準出力である"System.out.println"を使ってるので、ファイル出力とかに変えようとしたり出力フォーマットを変えようとしたら、ロジック書き直すかコピーしないといけないし、戻り値がvoidなのでUnitTestも書けません。プロダクトコードとしては非常にイケてない部類に入るでしょう。

なので、普通は仕事で書くならこんな感じに書くと思います。

gist998edee7a06eb26b51f8

直接表示するのではなく、FizzBuzzロジック本体はListを返すことで出力先を容易に切り替えることが出来、テスタビリティも格段と向上しています。

最初のコードだと下記のような思考パターンでコーディングをしていたと思います。

  1. 1から100までの数を繰り返し処理しよう
  2. 15で割りれるなら"FizzBuzz"を、5で割り切れるなら"Buzz"を、3で割り切れるなら"Fizz"を、そうでないならそのままListに追加しよう
  3. Listの中身を全部表示しよう

これがJava 7までの良くやる考え方ですね。ここでパラダイム・シフト。下記のように考えてみましょう。

  1. 1から100までの数を持ったリストを作ろう
  2. 1で作ったリストを15で割りれるなら"FizzBuzz"を、5で割り切れるなら"Buzz"を、3で割り切れるなら"Fizz"を、そうでないならそのままというリストに変換しよう
  3. 2で作ったリストの中身を全部表示しよう

一見同じように見えますが、1と2の意味が少し違います。最初からリストを作ることで「繰り返し処理」からリストの作成・変換といった「リスト操作」に置き換わっています。

これはリストの操作が得意な関数型言語に由来する考え方です。こうして「繰り返し処理」という制御構文を「リスト操作」という関数にしてしまうことで、戻り値のある小さなパーツとして扱いやすくなり、UnixのシェルなどのようなPipes and Filters アーキテクチャを適用出来ます

ようは、この処理を適用して、その結果をこの処理に適用して、それからこの条件で抽出してみたいなちょっとずつ処理を書き足していくやり方ですね。

gist52b41e5ed3d63dcd3997

Java7で書くとこんな感じですね。こうすることで

入力となるリスト -> 変換処理 -> 出力となるリスト

という形にすることが出来、先ほど言ったような恩恵を得ることが出来ます。ただ、あまり簡潔ではないですね。元々、関数型言語由来の考え方ということもあって、Javaで実現出来なくはないのですが、どうしてもストレートには行きません。

ちょうどC言語でもオブジェクト指向で書くことは書けますが、オブジェクト指向言語ではないので、書くのが面倒というのと同じです。

というわけでこのリスト操作スタイルをダイレクトにサポートするために最適な機能がStream APIとなります。

Stream APIによるリスト操作

驚くべきことに、こんなに長々とJava8の記事を書いてるのにまだJava8のコードが出てきていませんwでも、安心してください。ここからようやくJava8のコードが現れます。

まずは、先ほど書いたリスト操作スタイルのFizzBuzz(Java7版)をリスト操作スタイルのFizzBuzz(Java8版) に書き直しましょう。

gist4106ff067830296d5845

Java7版と比べるとかなりシンプルになりましたね。

mapの中で使ってるのがif文ではなく三項演算子なことにも注目です。たぶん、if文でも書けるのですがmapは各要素に引数で渡されたラムダ式を実行した結果を持ったリストを返す、という仕様なので値を返す三項演算子の方がずっとシンプルに書けたりします。

boxedを付けないとIntStreamはオブジェクトのStreamに変換できない面倒な仕様があるものの、概ね問題ありません。

さて、せっかくなのでPipes and Filtersっぽくメソッドチェインで処理を追加してみます。

gist8265b34f5598bafa0843

これはFIzzBuzzのルールで作り出した文字列のリストからさらに"FizzBuzz"という文字列を抽出してその数を数えるサンプルです。Unixのシェルっぽい感じですね。

ブコメで中間状態をログに出したりデバック目的で取り出したいという話もあったので、その例も追加してみました。こういうケースではpeekを使います。処理は実行するけどリストには変換を与えずそのまま返すメソッドです。

とはいえ、個人的にはログとか向上的に確認したいほど中間状態の粒度がハッキリしてるなら、変数に入れてしまうのがシンプルと思います。

Unixのシェルやワンライナーで書くときもそうですが、あまり長く書きすぎると書くときは問題無いのですが、読むのが大変になります。

また、Stream API重要なポイントとして、Java7のスタイルに較べてStream APIをはオーダー数が異なることです。Java7版では見て分かる通り、Listの作成処理や変換、そして表示とループが3回回っているため、オーダが3Nとなってしまうので、データが大きな時は非効率です。

その点、Java8のStreamはUnixのシェル等と同様に基本的にはループ数は1回に収まります。パフォーマンスを気にしなくて良くなるので、とても嬉しいですね。

まとめ

色々書いてきましたが、今日書いたことを整理すると以下の2点です。

  1. ループ処理はリストに変換することで保守性高く書ける
  2. ストリームAPIはリストスタイルを推奨するための語彙

for文禁止という一見苛烈に見えるコメントは、ほとんどのループ処理はリスト処理で表現可能、ということと、StreamAPIと組み合わせて使うなら、ソッチのほうがずっとシンプルで、安全という事があるからです。

本当はfor文でしか書きづらいケースは無くはないのですが、まずは一旦禁止して思考の仕方を変えるためのテクニックですね。ループ処理ではなく自然とリスト変換でデータ処理を考えられるようになった時、for文を解禁するとよいでしょう。

故に「Java 8のStream APIは業務でどんな時に使うの?」という問いには私はこう答えます。「あなたがfor文使いたい時」と。

それではHappy Hacking!

参考