個人的には、関数型プログラミングにあまり詳しくはないですが、Java 1.8のStream APIは好んで使っています。他にもLambdaやOptionalといったAPIも好きですが、自分がJavaの資格を取った理由も、このStream APIについてもっと勉強したかったからと言っても過言ではないです。
そんな私ですが、Streamについて勉強している中、疑問が出来ました。Streamは確かにいいAPIですが、伝統的なJavaのAPIとはかなり違うものです。これをJavaに導入したことで得られるメリットがあるから導入されたはず、というのは難しくない推論ですが、逆の場合はどうでしょうか?Streamを使った場合のデメリットは?そして自分が使っているStreamの書き方は正しいのか?などなど。
今回のポストでは、そのような疑問について独自に調査したことを述べていきたいと思います。正解というより、こういう見解があるということとしてご理解ください。
Streamは万能か?
まず最初の質問です。Streamは万能か?つまり、既存のコードをすべてStreamに書き換えても問題はないか?そしてなるべくこれから書くコードはStreamに変えるべきなのか?という質問ですね。確かに新しいAPIが出て、既存のコードと同じ役割ができるとしたら、それにはなんらかの理由があります。Javaの場合は、NIOがそうでした。一般的なI/OではOSのカーネルの機能を利用できなかったため、それを改善するために登場しましたね。しかし、NIOもまた、全ての場面で既存のI/Oより優れているとは言えない面がありました。そしたらStreamの場合も、その可能性はあると思いました。
結論からいうと、「全てのコードをStreamに書き換える必要はない」です。その理由を、一つ一つ項目別に説明します。
性能は劣る場合も
Java 1.5では、伝統的なFor文意外にも、いわゆる拡張For文というものが登場しました。そして1.8では、Streamと共にforEach()というメソッドもできましたね。しかし、forEach()もStreamも性能は拡張For文より劣ります。また、とあるベンチマークでは、Streamを使った場合の処理はParallelを使っても拡張For文より性能は劣るという結果が出たらしいです。理由は簡単です。Streamを使うと、より複雑な処理が中に入るからです。特に配列をStreamに変換する時はラッピングが入るので、そこでもう処理が加えられるということを考えられますね。
特に、オブジェクトを扱う場合の性能の差はそんなに大きくないものの、プリミティブ型を扱う場合は性能の差がより大きいらしいです。なので無理やり配列をStreamに変えて処理をする必要はありません。StreamやforEach()は、それを持って安定したコードを書けられる場合に限定して使う必要があります。そしてStreamを使う場合もプリミティブ型を扱う場合はIntStreamやToIntStreamといった、それぞれの型に合わせたクラスを使った方がより良い性能を出すので、そこもちゃんと考慮すべきですね。
JVMが長い間伝統的なFor文に最適化されてきて、1.8になってやっと登場したStreamはそれほど最適化されてないので性能が劣るという話もありましたが、これは1.8がリリースされた当時の記事に書いてあったものなので14までバージョンアップがなされた今はどうかという疑問はあります。それでも伝統的なFor文の方がまだ性能では優秀ではないだろうかと思いますが。
途中でやめられない
Streamの処理は一般的なforループとは違って、continueやbreak、returnなどで一部の処理をスキップしたり途中で処理を止めることができません。基本的にStreamは全要素に対して処理をすることを前提にして設計されたからです。なのでそれぞれの目的に合わせて、Streamのメソッドを適切に使い分ける必要があります。例えばFor文での処理は以下のような変えられます。
- 条件に合致する要素だけを処理したい場合(if)
- filter()
- Collectionにしたい場合(add)
- collect()
- 要素を取り出したい場合(return)
- findAny() / findFirst()
ループ変数を使用できない
拡張For文ではなく、伝統的なFor文ではループ変数を使って、現在のループが何回目かを数えることができます。しかし、Streamではループ変数を使うことができません。例えば、以下のようにStreamの外部に変数を置いてもコンパイルエラーとなります。
int型のループ変数を使いたい場合はIntStreamを、ループ変数で処理をスキップしたい場合はskip()を使いましょう。もちろん、こういう場合は普通に既存のFor文を使った方が正解に違いです。
そもそもの関数型
実は、Streamの中でも外部変数をループ変数として使う方法がなくはないです。AtomicIntegerでループ変数を使う方法がありますが、そこまでしてStreamを使う理由もなければ、関数型プログラミングの目的に合いません。
関数型プログラミングのコンセプトの中では不変性(Immutability)というものがあります。以前Immutabilityについて述べたことがありますが、ここで重要なのはデータが変わることはない、ということです。データが変わらないならどうやって処理が行われるかというと、元のデータはそのままで、処理ではそのデータのコピーを作って作業することになります。
Streamを持って処理をする場合に、その結果が新しいインスタンスになるのもそれが理由です。Listをループさせる場合は、元の要素を編集できます。しかし、Streamで処理する場合は変更された要素で構成された新しいListを返すようになりますね。中間操作で元の要素を編集するとしても、Streamは終端操作が終わるとクローズされ再利用できなくなります。これで元のデータは変えずに済みますね。
なのでStreamを使う場面というのは、まず元のデータをどうするかによります。もちろん、Streamを使わない場合でも、関数型的なコードの作成がより場合も多いです。(ということで、まずは関数型プログラミングを勉強ですね…)
Streamをより活用する
次に、Streamを使う場合に、どうしたら正しく、より効率的に活用できるかに関する質問です。Streamは最初から再使用できないようになっていますが、場合によっては同じデータに対してそれぞれ違う処理を行う必要があるのでそれがどうやって実現できるか、という疑問がありました。例えば、普通のFor文だとループの中で分岐を置くことで二つのCollectionに要素を分配するような処理ができるのですが、Streamだと同じオブジェクトに対しての処理はできませんね。こういう場合は同じデータに対してどうやったらStream処理を2回以上できるか気になります。
もう一つは、自分だけなのかもしれませんが、Stream以外でも、メソッドチェーニングを使えるAPIは非効率的な処理が入ってもすごく場合があって、それをどうしたら効率的な書き方にできるかという疑問がありました。例えばCollectionや配列をStreamに変換してからforEach()を使うこともできますが、CollectionだとstreamなしでもforEach()は使えますね。こういう場合は直感的にCollectionのforEach()の方が良さそうだとは思いますが、それ以外の場合はどうなのかよくわかりません。
なので、この二つの疑問についても調査してみました。
再使用
Streamは何度も繰り返して中間操作が可能ですが、一度でも終端操作が行われるとクローズされ、再利用ができなくなります。なぜなら、Streamの目的はデータの処理であって、データの格納ではないからです。
しかし、たまには同じデータに対してStreamを利用し、それぞれ違う処理を行たい場合もありますね。そういう時はどうしたらいいでしょう。Javaでデータを格納するためのものは配列やCollectionがありますので、必要なデータを予め定義して、場合によってそれをStreamに転換して使う方法があります。配列の場合はArrays.stream()
やStream.of()
があり、Collectionだとstream()
がありますね。例えば以下のような方法です。
// Listで必要なデータを集めておく
List<String> names =
Stream.of("Eric", "Elena", "Java")
.filter(name -> name.contains("a"))
.collect(Collectors.toList());
// 1番目のStream
Optional<String> firstElement = names.stream().findFirst();
// 2番目のStream
Optional<String> anyElement = names.stream().findAny();
予め必要なデータはListとしてCollectし、必要な場合はそれをまたStreamに変換して使う例でした。データがそもそもCollectionや配列の場合は、必要に応じてstream()を呼び出すことでそれぞれ違う処理ができます。また、peek()を挟むことで違うCollectionにデータを追加することもできます。厳密にいうと再使用というよりはどうStreamを作るかに関する話となりますが、これで一つのデータから複数の処理結果を出すことは可能、ということになります。
短く書く
先に述べましたが、StreamはメソッドチェーニングのできるAPIなので、非効率的なコードを書きやすい傾向がありました。なのでケース別により効率的な書き方を集めてみました。自分はEclipseを主に使っているのですが、Intellijだと、こうした方がいいよとオススメしてくれる部分らしいです。
- Collectionのメソッドを使う
// CollectionのForEach
collection.stream().forEach()
→ collection.forEach()
// Collectionを配列に
collection.stream().toArray()
→ collection.toArray()
- Streamを作る
// 配列からStreamに
Arrays.asList().stream()
→ Arrays.stream() / Stream.of()
// 空のStreamを作成
Collections.emptyList().stream()
→ Stream.empty()
// 範囲指定で配列を作成
IntStream.range(expr1, expr2).mapToObj(x -> array[x])
→ Arrays.stream(array, expr1, expr2)
// 範囲指定でStreamを作成
Collection.nCopies(count, ...)
→ Stream.generate().limit(count)
- 要素の判定
// 条件に一致する要素が存在するかの判定(1)
stream.filter().findFirst().isPresent()
→ stream.anyMatch()
// 条件に一致する要素が存在するかの判定(2)
stream.map().anyMatch(Boolean::booleanValue)
→ stream.anyMatch()
// 要素が一つでも条件と一致しないかの判定
!stream.anyMatch()
→ stream.noneMatch()
// 全要素が条件と一致するかの判定
!stream.anyMatch(x -> !(...))
→ stream.allMatch()
// ソートして最も先にある値を探す
stream.sorted(comparator).findFirst()
→ Stream.min(comparator)
- 要素を集める
// 要素の数を数える
stream.collect(counting())
→ stream.count()
// 最も大きい要素を探す
stream.collect(maxBy())
→ stream.max()
// 要素を違うオブジェクトにマッピングする
stream.collect(mapping())
→ stream.map().collect()
// 要素を一つにまとめる
stream.collect(reducing())
→ stream.reduce()
// 要素を数字の合計にする
stream.collect(summingInt())
→ stream.mapToInt().sum()
- 要素の処理
// 要素の状態だけを変える
stream.map(x -> {...; return x;})
→ stream.peek(x -> ...)
最後に
関数型プログラミングに興味がないとしても、Streamそのものはかなり魅力的なAPIですので、皆さんにもぜひ使ってみて欲しいです。Java 1.8がリリースされた当時には性能も劣り読みにくいという批判も多かったのですが、もう時間は経ち、Javaのバージョンはすでに14となっているくらいです。もうそろそろStreamを使ってモダンな書き方を試してみても良いでしょう。
そしてStreamを通じて、関数型プログラミングを味わえるのも一つのメリットではないかと思います。もちろん、Streamが完璧な関数型プログラミングの例だとは言い切れませんが、少なくとも、オブジェクト指向だけでなく新しいプログラミングのトレンドはどういうものかを経験できるということだけでも十分価値があるのではないでしょうか。もう関数型プログラミングの概念が登場してからも数年が経っています。プログラミングの世界は常に変化と発達が伴うものなので、少なくとも最近のトレンドが何であるかくらいは把握しておきたいものです。
では、またあいましょう!