Java 25で追加されたGatherersでScalaのcollectのようなことを試してみた

はじめに

こんにちは。DTダイナミクスの切削サービスの開発をしております、大崎です。

普段、meviyの開発自体ではJavaを使っていませんが、筆者個人としては過去にJavaを使って開発していたこともあって、今年の Java 25(LTS)の興味をもった機能について調べてみました。
そのなかでも、Stream Gatherers がLTS入りしたことに注目し、今回はこれをScalacollect を使うようなユースケースで取り上げてみます。

Stream Gatherersとは

Stream Gatherers は、Stream にカスタムした中間操作を差し込むための新APIです。mapfilter のような既成の中間操作の組み合わせでは表現しにくい変換をストリーム内に組み込めます。
ちなみに中間操作とは、元のストリームを受け取って変換し、別の新しいストリームを返す操作を指します。既成の中間操作には例えば map (要素1つずつ変換) ・filter (条件に合う要素だけを残す) などがあります。

Gatherersを使うと、既成の中間操作にはないような独自に定義した中間操作をストリームパイプラインに差し込めます。

Oracleによると、以下のようなことが可能になるとのことです。引用元

  • Transform elements in a one-to-one, one-to-many, many-to-one, or many-to-many fashion
  • Track previously seen elements to influence the transformation of later elements
  • Short-circuit, or stop processing input elements to transform infinite streams to finite ones
  • Process a stream in parallel

それぞれ、要素を1:1だけでなく1:多・多:1・多:多で変換したり、状態を持つ中間処理を実装したり、短絡(=条件に該当したらそこで打ち切り)したり、並列ストリームに対応したりといった、中間操作の独自の実装が可能になる、といった意味です。

Scalacollectと似たことをしたい

そういったGatherersのユースケースの一つとして、Scalacollect のような「mapしつつfilterもする」操作Javaで実現したい、という例を紹介します。
Scalacollect は 部分関数を取り、フィルタとマップを同時に行うようなことができます(噛み砕いて説明すると、引数が条件に該当するときのみ要素を変換して結果を返し、条件に該当しない要素を取り除く)。
たとえば「Anyのリストから、StringInt だけ取り出してそれぞれ追加で条件判定、 String は大文字に、 Int は文字列に」といった処理が簡素に書けます。

// Scala
val xs: List[Any] = List("foo", 42, "bar", 3.14)
val res: List[String] = xs.collect { 
    case s: String if s.length >= 3 => s.toUpperCase
    case i: Int if i % 2 == 0 => s"Integer:$i"
}
println(res)
// => List(FOO, Integer:42, BAR)

ちなみにですが、Scalacollect については過去記事でも取り上げていますので、もし興味があればご覧ください。

Gathererを使わないで書いた例

同じことをJava25より前のLTS、21で表現してみると、以下のようになります。

// flatMapを使う場合
List<Object> xs = List.of("foo", 42, "bar", 3.14);

List<String> r =
    xs.stream()
      .flatMap(o -> switch (o) {
        case String s  when s.length() >= 3 -> Stream.of(s.toUpperCase());
        case Integer i when i % 2 == 0      -> Stream.of("Integer:" + i);
        default                             -> Stream.empty();
      })
      .toList();
// => [FOO, Integer:42, BAR]
// mapMultiを使う場合
List<Object> xs = List.of("foo", 42, "bar", 3.14);

List<String> r =
    xs.stream()
      .<String>mapMulti((o, down) -> {
        switch (o) {
          case String s  when s.length() >= 3 -> down.accept(s.toUpperCase());
          case Integer i when i % 2 == 0      -> down.accept("Integer:" + i);
          default                             -> { /* 何もしない */ }
        }
      })
      .toList();
// => [FOO, Integer:42, BAR]

細かな説明は割愛させていただきますが、上記の2つの例はどちらもScalacollect の例と同様の動作を実現しています。

ただし注意点としては、 flatMapを使う場合の方はStreamの生成コストがかさみます。
それにしても、Javaも約10年前の8と比べて、だいぶ簡潔になったなと改めて思いました。(Java8ですと型パターンのswitchもないですし、mapMultiもないですし...)

Gathererを使ってみた

まず実装例を以下に示します。

// Gathererを使う場合
List<Object>  xs = java.util.List.of("foo", 42, "bar", 3.14);

// 「mapしつつ条件に合う要素だけ下流へpushする」中間操作を定義
Gatherer<Object, ?, String> collectLike =
    Gatherer.of(
        (_, element, downstream) -> {
            // 条件を満たすときだけ加工して下流へ流す
            if (element instanceof String s && s.length() >= 3) {
            return downstream.push(s.toUpperCase());
            } else if (element instanceof Integer i && i % 2 == 0) {
            return downstream.push("Integer:" + i);
            } else {
            // 何もせず次へ
            return !downstream.isRejecting();
            }
        });

var result = xs.stream()
    .gather(collectLike)   // ← カスタム中間操作として差し込む
    .toList();
// => [FOO, Integer:42, BAR]

処理を順を追って説明します。
Gatherer.of(...) でGathererインスタンスを生成し、この Gatherer.of には、独自実装の中間操作ロジックをラムダ式で渡しています。
引数については、 element がストリームから受け取った要素で、downstream下流、つまりこの Gatherer で定義する中間操作の結果として出力するストリームです。
処理としては、要素 element を1つずつ受け取り、条件に合うときだけ加工して下流downstream.push(...) で流し、そうでないときは要素を下流push せずに次の要素に進む、という処理を定義しています。

しかし、このユースケースではちょっと冗長にも思いますね。この例ですと上記の3つの例のうち、flatMapmapMulti の方が分かりやすいかもしれません。

こちらのドキュメントに挙げられている例は、以下のようなものです。

  • Grouping elements into batches
  • Deduplicating consecutively similar elements
  • Incremental accumulation functions
  • Incremental reordering functions

それぞれ訳して解釈すると以下となります。

  • 要素を束にまとめる
    • 例: ログや一括投入するデータ群を100件ずつにまとめたListを作る
  • 連続する重複要素を除去する
    • 例:同じ値の要素が連続する場合、1つだけ残す (例: A A B B B AA B A)
  • 逐次的な累積処理
    • 例: 年度内の月次の売上枚数を累積で加算し月次の累積売上枚数を算出する(=ランニングトータル)
  • 逐次的な並び替え処理
    • 例: 一定数の要素のまとまりが順不同で流れてくるとき、それらをその中でソートしてから下流へ流す

このように、既成の中間操作にはない中間操作を柔軟に定義できるのがGatherersの強みであり、使い所となります。

まとめ

今回の、リストの要素を条件に応じて要素を変換したり省いたりする例だと、Scalacollect が最も簡潔になりました。
しかし、Javaも表現がかなり改善・拡充されてきていることが分かりましたし、今回LTSとなった Gatherer を使うと、JavaのStreamでより宣言的に中間操作を定義できるようになります。
Stream APIでできることが増え、表現力がさらに高まった印象を受け、Javaの進化を興味深く感じることができました。

興味深いテーマがあれば、また今後も取り上げてみたいと思います。