ラベル Java の投稿を表示しています。 すべての投稿を表示
ラベル Java の投稿を表示しています。 すべての投稿を表示

2024/12/25

Stream Gatherer 基礎編

このエントリーをはてなブックマークに追加

本エントリーはJava Advent Calendarの最終日です。昨日はzoosm3さんのDBFlute Examples有志募集中でした。

今年もJava Advent Calendarが無事完了してなによりですね。

 

さて、今日、紹介するのはJava 24で正式に導入されるStream Gathererです。JEPはこちら。

Stream APIはJava 8で導入されて、はや10年。もうStream APIなしにJavaのプログラムを書けといわれても、ムリとしかいえないぐらいですね。少なくともさくらばはそうなのですが、みなさんはどうでしょう。

とはいうものの、Stream APIが万能というわけではありません。文句をつけたくなるところも多々あります。本題とは違いますが、例外が扱いにくいのはほんとどうにかしてほしい...

 

中間操作の制限

さて、どうにかしてほしかったものの1つに中間操作があります。

ごぞんじの通り、Stream APIは以下の3つの段階で処理が行われます。

  1. ソースからStreamオブジェクト生成
  2. 中間操作
  3. 終端操作

この流れをパイプラインと呼びますが、パイプの中をデータが流れてきてそれに応じて処理を加えていくイメージです。

この流れ作業の中で、中間操作は流れてきたデータに対しmapなどの処理を行い、処理後のデータを次に流していきます。重要なのが、中間操作は状態を持たない関数として表されることです。

状態を持てるのは、最後の終端操作だけです。とはいうものの、Collectorsクラスで提供されているユーティリティメソッドを使うだけであれば、状態を保持していることを意識する必要はありません。。

ここで、少しだけStreamパイプラインがどのように処理されるか考えてみましょう。

たとえば、中間操作としてAとB、終端操作としてCがあったとします。疑似的なコードで書くと、次のようになります。

    var stream1 = ソースからStreamオブジェクト生成();
    var stream2 = stream1.A();
    var stream3 = stream2.B();
    var result = stream3.C();

この時、stream2やstream3を生成した段階では、ストリームのパイプライン処理は行われません。

最後のCの段階で、A、B、Cをパックした1つの処理を作ります。

そして、ソースから生成された要素に対して、このA-B-Cを施していきます。そして必要に応じて、Cの段階で前後の要素の処理結果や、並列処理の場合であれば他スレッドとの結果の統合処理を行います。

このため、A-B-Cのうち、Cの処理の統合処理以外は、要素ごとに処理が独立しています。

逆にいうと、処理を独立させるために中間処理のAとBは状態を持たせないことが必要になってくるわけです。

 

ところが、処理によっては中間処理に状態を持たせたいことがあります。

たとえば、移動平均を考えてみましょう。

移動平均とは時系列データの平滑化のために使用される処理です。

直近のデータからさかのぼっていき、n個のデータで平均をとる処理です。

移動平均は、時系列データのノイズを取り除くことや、株価の移動平均線など様々な分野で使われる処理です。

しかし、現状のStream APIでは移動平均を実装するのが難しいのです。やるとしたら、終端操作でn個分のデータで平均をとるというCollectorインタフェースの実装クラスを作る必要があります。

もし中間操作で状態を持たせることができたら、もっと直感的に移動平均処理を実装できるはずです。

このようなニーズを解決するために導入されるのがStream Gathererなのです。

 

Gatererはgatherする、つまりストリームを流れるデータを集めるものです。このイメージは中間操作でのCollectorという感じ。名前もgatherとcollectで似ていますし。

もちろん、Collectorと同じく、単にデータを集めるだけでなく、それに対して何らかの処理を行うことも可能です。

中間操作を柔軟にするのがStream Gathererなのです。

 

Stream Gatherer

中間操作で状態を保持することができるようになるのがStream Gathererですが、その動作もCollectorと似ています。

Collectorと同様、ユーティリティクラスのGatherersクラスも提供されています。

まずは、このGatherersクラスを使って、Stream Gathererがどのようなものか理解していきましょう。

 

ウィンドウ

先ほどの移動平均を行うには時系列データをn個のデータの並びに変化させる必要があります。n個だけ見えるような処理なので、それを窓に見立てて統計では窓関数(Window Function)と呼ばれます。

Gatherersクラスではn個に区切っていく処理が提供されているので、まずそれを使ってみましょう。

n個に区切っていく手法として2種類が提供されていますが、例を見ればすぐに分かるはずです。

たとえば、0から9までの数列[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]を考えましょう。

1つ目の区切りはデータの被りがない手法です。

0から9までの数列を3個ずつ区切った場合、[[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]]となります。

GatherersクラスではwindowFixedメソッドがこれに相当します。

もう1つの区切りはデータの被りがあり、直近までのn個のデータで構成されるようにする方法です。

この方法で0から9までの数列を3個ずつで区切ると、[[0, 1, 2], [1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6], [5, 6, 7], [6, 7, 8], [7, 8, 9]]となります。

GatherersクラスではwindowSlidingメソッドになります。

 

それぞれを実際のコードをJShellで確かめてみましょう。

まずはwindowFixedメソッドです。

jshell> List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9).
   ...> stream().
   ...> gather(Gatherers.windowFixed(3)).
   ...> toList()
$1 ==> [[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]]

jshell>

JShellでストリームパイプラインを行を分けて書く時には、行の最後をピリオドにするのがコツです。ピリオドで終わらせないとJShellは行端にセミコロンを省略できるので、そこで行が終わったと解釈してしまうためです。

さて、Stream Gathererは前述したように中間操作ですが、メソッドとしてはgatherを使用します。

引数の型はGatherインタフェースです。GatherersクラスのwindowFixedメソッドの戻り値がGatherオブジェクトになります。

この使い方もcollectメソッドの引数の型がCollectorインターフェスで、Collectorsクラスのメソッド群がGollectorオブジェクトを戻すのと同じですね。

windowFixedメソッドの引数には、データをいくつで区切るかを指定します。上記のコードでは3で区切っています。

結果は先ほど示したのと同じで、[[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]]となります。

windowFixedメソッドで作成されるGathererオブジェクトはストリームを流れてきたデータを保持するためのリストを内部に持っています。

ストリームに0が流れてくると、Gathererオブジェクトはそれをリストに追加します。1が流れてきた時も同様にリストに追加します。

2が流れてきた時に、内部のリストに保持したデータ数が3になったので、リストをストリームに流します。そして、データ保持用のリストは初期化して、次のデータを保持できるようにします。

このようにして、Gathererオブジェクトはデータを保持、また保持したデータを処理してストリームに流す処理を行います。

 

次にwindowSlidingメソッドを使ってみましょう。

jshell> List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9).
   ...> stream().
   ...> gather(Gatherers.windowSliding(3)).
   ...> toList()
$2 ==> [[0, 1, 2], [1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6], [5, 6, 7], [6, 7, 8], [7, 8, 9]]

jshell>

windowSlidingメソッドの引数も、windowFixedメソッドと同じく、データの区切り数です。

結果が、データの被りがあり、1つずつずれていくことが分かります。

 

このwindowSlidingメソッドを使用すれば、移動平均も簡単に記述できます。

jshell> List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9).
   ...> stream().
   ...> gather(Gatherers.windowSliding(3)).
   ...> map(fragments
   ...>       -> fragments.stream().
   ...>           collect(Collectors.averagingDouble(x -> x))).
   ...> toList()
$3 ==> [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]

jshell>

windowSlidingメソッドで3つずつに区切られたリストがストリームに流れることになります。そこで、次のmapメソッドで3つの平均を求める処理を記述しています。ストリームの中にストリームを書いているのでちょっと分かりにくいかもしれませんが、mapメソッドの結果として要素が3つのリストの平均がストリームを流れるようになります。

これでもcollectメソッドで移動平均を記述するより、かなり分かりやすくなっているはずです。

結果は1.0から8.0までのリストになりますが、要素数が元のリストから減っていることに注意してください。

 

Gatherersクラスの他のメソッド

GatherersクラスではwindowFixed/windowSlidingメソッドの他に、以下の3種類のメソッドを提供しています。

  1. fold
  2. scan
  3. mapConcurrent

mapConcurrentメソッドだけはちょっと毛色が違います。

通常、パラレルストリームは中間操作と終端操作のまとまりをパラレルに処理します。これに対し、mapConcurrentメソッドは中間操作のmap処理をパラレルに処理できます。もちろん、パラレルストリームと併用することも可能です。

これに関しては、次回のエントリーでStream Gathererの内部動作と合わせて、もう少し詳しく説明する予定です。

他の、foldメソッドとscanメソッドは動作としては似ています。ちょうど、終端操作のreduceメソッドのような動作になります。

foldメソッドもscanメソッドも引数は同じで、第1引数の型がSupplierインタフェース、第2引数の型がBiFunctionインタフェースです。

要するに、第1引数が引数なし、戻り値ありのラムダ式です。そして、第2引数が引数が2つ、戻り値ありのラムダ式になります。

第1引数のラムダ式の戻り値の型が、第2引数のラムダ式の第1引数の型および戻り値の型と同じです(厳密にはsuperとextendsが含まれていますが)。第2引数のラムダ式の第2引数の型はストリームを流れてくるデータの型です。

文章で書くと、ちょっと分かりにくいですね。実際にコードで確かめてみましょう。

たとえば、0から9までの数値のストリームを文字列に統合していくことを考えてみましょう。

jshell> List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9).
   ...> stream().
   ...> gather(Gatherers.fold(
   ...>    () -> "",
   ...>    (text, value) -> text + value)).
   ...> toList()
$1 ==> [0123456789]

jshell>

foldメソッドの第1引数のラムダ式で、空の文字列を戻します。

この文字列に数値の文字表現を連ねていくのが、第2引数のラムダ式です。ラムダ式の第1引数で前回の結果が渡されるので、そこに現在のストリームの値valueを追加しています。

結果的に"0123456789"という文字列が作成できます。

これはcombinerのないreduceメソッドと同じような動きですね。

foldは畳み込むという意味なので、ストリームに流れてきたデータをまとめていくといった感じになります。

 

scanメソッドはfoldメソッドと引数は同じなので、foldメソッドをscanメソッドに置き換えて試してみましょう。JShellだと履歴が使えるので、こういう時に簡単に実行できますね。

jshell> List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9).
   ...> stream().
   ...> gather(Gatherers.scan(
   ...>    () -> "",
   ...>    (text, value) -> text + value)).
   ...> toList()
$2 ==> [0, 01, 012, 0123, 01234, 012345, 0123456, 01234567, 012345678, 0123456789]
    
jshell>

foldメソッドと違って、データの統合処理のその時点での値をストリームに流しているのが分かります。

こういう英語のニュアンスが非ネイティブには分かりにくいのですが、データを1つずつ処理していくことをscanしているという意味でしょうか。

 

Gathererを自作する

Gatherersクラスのユーティリティメソッドを使用して、Stream Gathererがどういうことをやるかのイメージはつかめたでしょうか。

それでは、このエントリーの最後に、Gatherersクラスを使用せずにGathererインタフェースを実装したクラスを作ってみましょう。

本エントリーでは何度もGathererとCollectorが似ているということを言及してきましたが、インタフェースで実装するメソッドも似ています。重要なメソッドの対応関係を次表にまとめました。

なお、ジェネリクスの型パラメータは省略しています。

  Gatherer Collector
内部状態の初期化 Supplier initializer() Supplier supplier()
新しいデータの処理 Gatherer.Integrator integrator() BiConsumer accumulator()
パラレル処理時の状態統合 BinaryOperator combiner() BinaryOperator combiner()
最後の処理 BiConsumer finisher() Function finisher()

これらのメソッド群の中で、Collectorインタフェースと戻り値の型が異なるのがintegratorメソッドとfinisherメソッドです。

これらのメソッドの型が異なるのは、ストリームの上流から流れてきたデータを下流に流すという処理が必要になるためです。

特にintegratorメソッドはjava.util.functionパッケージで提供されている関数型インタフェースではないというところが着目すべきポイントです。

 

また、initializerメソッド、combinerメソッド、finisherメソッドはdefaultメソッドなので、状態を扱わないのであれば実装する必要はありません。

そこで、状態を扱わない単純なGathererインタフェースの実装クラスから作ってみましょう。

 

何もしないGatherer

まずはじめに作るのは、ストリームの上流から流れてきたデータを何もせずに下流に流すというGathererです。

状態を扱わないので、上述したようにintegrateメソッドだけを実装するクラスを作ればOKです。

Gathererインタフェースにはファクトリメソッドとして複数のofメソッドと、ofSequentialメソッドが用意されています。ofメソッドとofSequentialメソッドの違いについては次回説明するとして、ここではofメソッドを使用します。

ofメソッドの引数にはinitializeメソッドやintegrateメソッドで戻すオブジェクトをラムダ式で指定します。

ofメソッドは3種類のオーバーロードがあります。ここでは、integratorメソッドで戻すオブジェクトだけを指定するオーバーロードを使用します。

integratorメソッドで戻すのはGatherer.Integratorインターフェスを実装したオブジェクトです。Gatherer.Integratorインターフェスは関数型インタフェースで、integrateメソッドをラムダ式で記述します。

そのままラムダ式で記述できるのですが、ここではGatherer.Integratorインターフェスが手いきゅしているファクトリメソッドを使用します。

ファクトリメソッドにはofメソッドとofGreedyメソッドの2種類ありますが、ここではofGreedyメソッドを使用します。

関数型インタフェースなのにファクトリメソッドを提供していることや、ofメソッドとofGreedyメソッドの違いについては後述します。

    Gatherer.Integrator<Void, Integer, Integer> noEffectIntegrator =
            Gatherer.Integrator.ofGreedy(
                    (_, value, downstream) -> {
                        // 下流にデータを流す
                        downstream.push(value);

                        // ストリーム処理を続けるので、trueを戻す
                        return true;
                    });

    var list = Stream.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
                     .gather(Gatherer.of(noEffectIntegrator))
                     .toList();

ofGreedyメソッドの引数には引数が3種類、戻り値がbooleanのラムダ式を記述します。

第1引数: 状態を保持するオブジェクト
第2引数: ストリーム上流からのデータ
第3引数: 下流に流すためのオブジェクト
戻り値: ストリーム処理を継続するかどうか

第1引数の状態保持用のオブジェクトは、initializerメソッドのSupplierインタフェースで作成されるオブジェクトです。

第2引数がストリームの上流から流れてくるデータになります。

上流からのデータを処理して、下流に流すために使用するのが第3引数になります。第3引数の型はGatherer.Downstreamインタフェースです。

 

上記のコードでは状態を使用していないので、第1引数の型はVoidです。

なお、この第1引数の型は、Gatherer.Integratorインタフェースの1つ目のジェネリクスの型パラメータに相当します。

上記のコードでは状態は使用しないので、ラムダ式の引数はアンダースコアの"_"で記述しています。

本題とは外れますが、未使用の変数をアンダースコアで書けるようになったのはJava 22で導入されたJEP 456: Unnamed Variables & Pattersによるものです。

続いて第2引数です。ここでは数値(Integerクラス)のストリームを使用するので、ラムダ式の第2引数の型もIntegerクラスです。Gatherer.Integratorインタフェースの2つ目のジェネリクスの型パラメータがこれを表しています。

このGatherer.Integratorインタフェースのラムダ式では何も処理をせずに、上流から流れてきたデータを単に下流に流すだけです。そのために使用するのが、Gatherer.Downstreamインタフェースのputメソッドです。

putメソッドの引数の型は、Gatherer.Integratorインタフェースのジェネリクス型パラメータの最後の型パラメータです。上記のコードでは数値をそのまま使い続けるので、Integerクラスとなります。

もし、下流に流すデータの型を変更する場合は、型パラメータで指定するようにします。

 

さて、Gatherer.Integratorオブジェクトが生成できたので、ストリームパイプラインに適用してみましょう。GathererインタフェースのofメソッドでGatherer.Integratorオブジェクトを指定します。

実行したら、リストの[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]が得られるはずです。

 

フィルター処理を行うGatherer

次に、もうちょっと複雑な(といっても簡単ですけど)Gathereを作成してみましょう。

ストリームの中間処理にfilterメソッドがありますが、これと同じような処理を作ってみます。ここでは偶数だけをフィルターするGathererを作ることにします。

数値のストリームで、Gathererでは型変換を行わないので、型パラーメタなどは先ほどの例と同じです。

    Gatherer.Integrator<Void, Integer, Integer> oddFilterIntegrator =
        Gatherer.Integrator.ofGreedy(
            (_, value, downstream) -> {
                // 偶数であれば下流にデータを流す
                if (value % 2 == 0) {
                    downstream.push(value);
                }

                // ストリーム処理を続けるので、trueを戻す
                return true;
            });

    var list = Stream.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
                     .gather(Gatherer.of(oddFilterIntegrator))
                     .toList();

 

変更している点は、ラムダ式の中でif文を追加したところです。

if文で偶数かどうかを判定し、偶数であれば下流にデータを流します。

奇数の場合はデータを下流に流さなくてもいいのかと思いますよね。だいじょうぶなのです。

データを下流に流すかどうかはGathererに任されています。

たとえば、前述したGatherersクラスのwindowFixedメソッドはwindowSlidingメソッドでも結果はソースの要素数よりも減っていましたね。

どのようなデータを流すか、流さないかはGathererの内部処理で決めればよいことになっています。

さて、実行すると[0, 2, 4, 6, 8]が得られます。ぜひ試してみてください。

 

では、ストリームの処理を途中でやめたい場合はどうでしょう。

たとえば、ある特定の数値より大きい数値が上流から流れてきた場合、ストリーム処理をやめることにしましょう。

以下のコードでは8以上の数値が流れてきたら、そこでストリーム処理を停止させます。

    Gatherer.Integrator<Void, Integer, Integer> limitIntegrator =
        Gatherer.Integrator.of(
            (_, value, downstream) -> {
                if (value < 8) {
                    // 8より小さい数であれば下流にデータを流し
                    // ストリーム処理を続ける
                    downstream.push(value);
                    return true;
                } else {
                    // ストリーム処理を停止
                    return false;
                }
            });

    var list = Stream.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
                     .gather(Gatherer.of(limitIntegrator))
                     .toList();

 

ラムダ式の中で8と比較し、8より小さければ下流にデータを流し、trueを戻り値にしています。

8以上の場合、戻り値をfalseにします。こうすることで、これ以上のストリーム処理を停止させることができます。

パッと見、if文の部部分だけ変更したように見えますが、その他の部分で大きな違いがあります。

Gatherer.IntegratorインタフェースのファクトリメソッドとしてofGreedyメソッドではなく、ofメソッドを使用している点です。

ofメソッドとofGreedyメソッドの使い分けは以下のようになっています。

  1. ofメソッド: ストリーム処理を途中で停止することがある場合
  2. ofGreedyメソッド: ストリーム処理を必ず最後まで処理する場合

メソッドのシグネチャーをチェックするとofGreedyメソッドは引数も戻り値も型がGatherer.Integrator.Greedyインタフェースになっています。

Gatherer.Integrator.Greedyインタフェースはメソッドを定義していません。つまり、Cloneableインタフェースのような、ある特徴を持っているということを表すマーカーインタフェースになっているということです。

そして、その特徴がストリーム処理が途中で停止することがないということです。

ストリーム処理の停止することがなければ、最適化が行いやすくなります。この最適化については、次回紹介する予定です。

greedyは「貧欲な」とか「強欲な」といった意味の形容詞ですが、正規表現で最長マッチさせるときにGreedyといいますね。こういう英語のニュアンスはほんとよく分からないです。

分からないついでですが、ストリーム処理を停止させることをShort-Circuitと呼びます。電気回路のショートもしくは短絡のことですが、回路をショートさせたらやばいような気がするんですよね。英語だとニュアンスが違うんですかねぇ??

 

さて、上記のコードを実行させた結果は[0, 1, 2, 3, 4, 5, 6, 7]になります。

 

状態を扱うGatherer

ここまでは状態を保持せずに、流れてきたデータを処理するだけでした。しかし、これだと従来の中間操作と変わりません。

そこで、次に状態を保持するGathererを作ってみましょう。

まずは単純に流れてきたデータを文字列として追加していくGathererです。ようするに、Gatherers.scanメソッドのサンプルコードと同じ動作をするGathererです。

 

ここまでのGathererのサンプルコードではファクトリーメソッドのofメソッドを使用してきました。ofメソッドを使用すると、gatherメソッドの処理がパラレルに実行されることがあります。

しかし、文字列を結合していく場合はデータの順番が維持されることが期待されます。そこで、ファクトリーメソッドのofSequentialメソッドを使用して、パラレル処理されないようにします。

    var integrator =
        Gatherer.Integrator.<StringBuilder, Integer, String>ofGreedy(
            (builder, value, downstream) -> {
                builder.append(value);
                downstream.push(builder.toString());
                return true;
            });

    var list = Stream.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
                     .gather(Gatherer.<Integer, StringBuilder, String>ofSequential(
                             StringBuilder::new,
                             integrator))
                     .toList();

 

ここまでの例とはジェネリクスの型パラメータの指定方法を変更しました。

メソッド名の前に型パラメータを記述する方法は普段は使わないのとは思います。とはいうものの、ファクトリーメソッドで変数宣言の方に型パラメータを記述するのは、ちょっと煩雑です。

「こういう書き方もできるんだ」ぐらいに思ってください。

Gatherer.Integratorインタフェースのファクトリーメソッドと、Gathererインタフェースのファクトリーメソッドで型パラメータの順番が違うので、ちょっと分かりにくいのが難点。

 

さて、ここでは状態を保持させるためにStringBuilderクラスを使用しました。StringBuilderオブジェクトに流れてきたデータをアペンドし、下流にはStringBuilderオブジェクトから文字列を生成して流しています。

GathererインタフェースのofSequentialメソッドには4種類のオーバーロードがあります。

この例では、ストリームの最後になにか処理することはないので、finisherは必要ありません。そこで、initializerとintegratorを引数にとるofSequentialメソッドを使用しています。

ofSequentialメソッドの第1引数であるinitializerは、型がSupplier<StringBuilder>です。ここでは、StringBuilderオブジェクトの生成をメソッド参照を使用して指定しています。

integratorのラムダ式の第1引数がinitializerで生成したStringBuilderオブジェクト(変数builder)になります。

上流から流れてきたvalueをbuilderにアペンドし、下流にはbuilder.toStringメソッドで文字列を流しています。

 

このコードを実行すると、先ほどのscanメソッドのサンプルと同様に["0", "01", "012", ... , "0123456789]となるリストが生成されます。

 

次にfinisherを使用するようなGathererを作ってみましょう。

ここでは、Gatherers.windowFixedメソッドと同じ動作をするGathererを作成してみます。

前のサンプルでは状態を保持させるのにStringBuilderクラスを使用しましたが、ここではStateというクラスを作成します。Stateクラスは数値を保持するリストをフィールドとして持つようにしました。

    class State {
        // ウィンドウ用の数値を保持するリスト
        List<Integer> list = new ArrayList<>();

        static Gatherer.Integrator<State, Integer, List<Integer>> integrator
            = Gatherer.Integrator.ofGreedy(
                (state, value, downstream) -> {
                    // listにデータを追加
                    state.list.add(value);

                    // listの要素数が3になったら下流に流し
                    // 新たにリストを生成して、listに代入する
                    if (state.list.size() >= 3) {
                        downstream.push(state.list);
                        state.list = new ArrayList<>();
                    }
                    return true;
                });
	
        static void finisher(State state,
                             Gatherer.Downstream<? super List<Integer>> downstream) {
            // ストリームの最後になったら、要素数が3に満たなくても下流に流す
            downstream.push(state.list);
        }
    }

    var result = Stream.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
                     .gather(Gatherer.<Integer, State, List<Integer>>ofSequential(
                             State::new,
                             State.integrator,
                             State::finisher))
                     .toList();

 

Gather.ofSequentialの第1引数であるinitializerは、メソッド参照でStateオブジェクトを生成させています。

integratorでは、まずStateオブジェクトが保持するリストに、上流から流れてきた数値データを追加します。

ここではウィンドウの区切り数を3としています。Stateクラスのリストの要素数が3以上になった時、リストを下流に流します。

そして、新たにリストを生成しています。

finisherでは、Stateオブジェクトが保持するリストを下流に流します。finisherはストリームの最後でコールされ、これ以上データは流れてこないため、要素数が3に満たなくても下流にリストを流します。

finisherメソッドの第2引数のGatherer.Downstreamは型パラメータにワイルドカードが必要な点に注意してください。

後は、これらをGatherer.ofSequentialメソッドの引数にするだけです。

 

実行した結果のresultは[[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]]となります。

 

移動平均

最後に移動平均を求めるGathererを作ってみましょう。

ここまでやってくれば、そんなに難しくはないですね。1つ前のリストを区切るGathererと同じように作れますが、finisherは必要ありません。

ここでは、ちょっと汎用にするためにGathererオブジェクトを生成するmovingAverageメソッドを作成しました。movingAverageメソッドの引数には何個の数値を使用して移動平均を求めるかを指定します。

    Gatherer<? super Number, ?, Double> movingAverage(final int n) {
    
        Gatherer.Integrator<List<Double>, ? super Number, Double> integrator
            = Gatherer.Integrator.ofGreedy(
                (list, value, downstream) -> {
                    list.add(value.doubleValue());

                    if (list.size() >= n) {
                        // 引数で指定された個数以上の場合
                        // listを使用して平均を求め
                        // 下流に流す
                        var ave =
                            list.stream()
                                .collect(
                                    Collectors.averagingDouble(x -> x));
                        downstream.push(ave);

                        // 先頭の要素を削除
                        list.removeFirst();
                    }

                    return true;
                });

        return Gatherer.ofSequential(ArrayList<Double>::new, integrator);
    }

 

movingAverageメソッドの戻り値の1つ目の型パラメータが上流からのデータの型です。数値であれば何でもよいので、Numberクラスのワイルドカードを使用しています。

2つ目の型パラメータがワイルドカードになっているのは、戻り値のGathererオブジェクトを使う側からすると内部状態は気にしなくてもよいからです。

実際には内部状態としてDoubleクラスのリストを使用します。

最後の型パラメータが下流に流すデータの型です。移動平均値には、浮動小数点のDoubleクラスを使用します。

では、movingAverageメソッドの中を見ていきましょう。

ここでも、Gatherer.Integrator.ofGreedyメソッドを使用してGatherer.Integratorオブジェクトを生成しています。

型パラメータはmovingAverageメソッドと同じですが、内部状態を表す第1型パラメータだけはワイルドカードではなく、実際に使用するList<Double>を指定します。

上流からデータが流れてきたら、listに追加するのは先ほどの例と同じです。

listの要素数がmovingAverageメソッドの引数で指定された数より大きい場合、listの要素で平均の計算を行います。

そして、平均を求めたら、下流に流します。

if文の最後で、listの先頭要素を削除しておきます。

これでintegratorができました。後はGatherer.ofSequentialメソッドでGathererオブジェクトを生成するだけです。

ofSequentialメソッドの第1引数でリストを生成し、第2引数は作成したintegratorを指定します。

 

では、このmovingAverageメソッドを使用してみましょう。

    var list = Stream.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
                     .gather(movingAverage(3))
                     .toList();

 

結果は[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]になります。

 

Gathererの使い方のまとめ

最後にGathererの使い方をまとめておきましょう。

  • Gathererを使用することで、状態を用いた中間操作が可能
  • ユーティリティクラスとしてGatherersクラスが提供されている
  • Gahtererを自作する場合、ファクトリーメソッドを使うと便利
    • パラレル処理をしない(順番を保持): ofSequential
    • パラレル処理が可能: of
  • Gathererの核となるのは、Gatherer.Integrator
  • Gatherer.Integratorのファクトリーメソッドは2種類
    • ストリーム処理の停止がある場合: of
    • ストリーム処理の停止がない場合: ofGreedy
  • 下流にデータを流すためにはGatherer.Downstreamのpushを使用する
  • ジェネリクスの型パラメータが多いのだが、恐れる必要はない

本エントリーではGathererの使い方を紹介しました。次回は、Gathererがどのように動作するのかについて紹介する予定です。

2024/12/12

Null-Restricted Typeとオブジェクト初期化の変更
 で、ValhallaのValue Classってどうなったの? その3

このエントリーをはてなブックマークに追加

本エントリーはJava Advent Calendarの12日目です。昨日はmackey0225さんのイレイジャってなんじゃ?でした。

 

Project Valhallaを紹介するエントリーも3回目になりました。

本エントリーではNull-Restricted Type(Null非許容型)について紹介していきます。いわゆるNon-Nullです。

Null-Restricted Typeに関するJEPは2つありますが、現状はドラフトなので番号がついていません。

 

Null非許容型にまつわる小史

Javaの開発者であれば誰もが1度は遭遇したことがあるNull Pointer Exception例外ですが、これを防ぐための取り組みが行われてきました。

ここではOpenJDKおよびJCPによる標準APIでの取り組みについて簡単に紹介します。

 

型アノテーションを使ったDefect Detection

変数にNullを許容するかしないかをアノテーションで修飾する取り組みは、多くライブラリやフレームワークでも導入されてきました。

たとえば、IntelliJ IDEAの設定で[Compiler]の項の1つに[Add runtime assertions for notnull-annotatedmethods and parameters]があり、その[Configure annotations...]をクリックすると、どのライブラリ/フレームワークのアノテーションを使用するか選択できます(下図参照)。

 

この設定ダイアログを見ると、AndroidやJakarta EEなどが@NonNullアノテーションもしくは@NotNullアノテーションを導入していることが分かります。

たとえば、メソッド引数にnullを禁止したいのであれば、次のように書けます。

    String readContext(@NonNull String filename) throws IOException {
        ...
    }

 

ところが、このアノテーションだと書けないことがあります。たとえば、リストの要素にnullを許さない場合はどうでしょう。これを解決するためにJava 8で導入されたのが、型アノテーション(JSR 308 Annottations on Java Type)です。

型アノテーションは型に対してアノテーションで修飾します。たとえば、リストの要素にnullを許さないという場合は次のように記述できます。

    List<@NonNull String> texts = ...;

 

そして、この型アノテーションを使用してNull非許容性を表そうとしたのが、JSR 305 Annotations for Software Defect Detecctionです。

このJSRのスペックリードはFindBugsの作者のBill Pughだったのですが、Bill Pughに連絡がとれなくなり、JSRも中断してしまいました。ご存じの方もいらっしゃると思いますが、FindBugsの開発が停滞してしまったのもこの頃です。

同様にJSR 308の型アノテーションを使って@NonNullを表そうとしたのが静的解析ツールのChecker Frameworkなのですが、こちらもそこまで流行らず...

うまく活用すればよかったのですが、標準にならなかったのが痛かったのが型アノテーションを使ったNull非許容性でした。

 

Optional

OptionalもJava 8で導入されました。

Optional自体はNull許容性を表すというよりは、値の有無を扱うために使われるクラスです。

しかし、値がないことをnullで表す場合が多かったため、Optionalを使うことでnullの使用を避けることができました。

ところが、Optional型を使ったとしても次のように書けてしまうのが...

    Optional<String> option = null;

 

つまり、値の有無を扱うことはできても、自分自身のNull非許容性は表せないのです。

 

ということで、Null非許容性を表すための取り組みはあったものの、成功したとはいえないのがJavaの現状でした。

 

Null-Restricted Type/Nullable Type

さて、Project ValhallaのNull-Restricted Typeです。

今までのNullに対する取り組みは、ソフトウェアの堅牢性を高めるためのものでした。これに対しValhallaのNull-Restricted Typeはパフォーマンス向上のためという大きな違いがあります。

前回、説明したValueクラスの平坦化やスカラー化は、行われるためのいくつかの条件があります。そのうちの1つが、値にnullが入らないことです。

Valueオブジェクトはプリミティブ型のようにふるまいますが、プリミティブ型の変数にはnullが値として入ることがありません。もし、平坦化やスカラー化で値を埋め込む時にnullが入るかもしれないのであれば、それを示すためのフラグなどが必要になります。しかし、それではせっかくの最適化の効果が低くなってしまいます。

なので、nullを許さないというのが最適化の条件になっているわけです。

 

ただし、Null非許容性はValueクラスでなくても有用です。そこで、Value Classとは独立して仕様を策定しようというのがJEPのNull-Restricted and Nullable Typesです。

そして、ValueクラスのNull非許容性はNull-Restricted Value Class Typesで仕様策定されます。

 

Null-Restricted Type/Nullable Typeの書き方

Null非許容/Null許容型の変数は次のように記述します。

    // null非許容
    String! nonnullText = "...";

    // null許容
    String? nullableText = "...";

 

他の言語でもNon-NullとNullableに!と?を使うことが多いので、理解しやすいですね。!と?はnullnessマーカーと呼ばれます。

nullnessマーカーはジェネリクスの型パラメータでも使用することができます。

    // null非許容
    class Foo<T!> { ... }

    // null許容
    class Bar<T?> { ... }

    // null非許容
    Foo<String!> foo = ...;

    // null許容
    Bar<String?> bar = ...;

 

!や?を指定していない型は未指定(Unspecified)です。未指定、つまり従来の型については仕様を変更していないので、nullが入ることもあります。実質的には?と未指定は同じような動作になりますが、型としては異なります。

また、nullnessの型を変換することもできます。Foo!をFoo?に代入するようなwide変換はOKです。しかし、Foo?をFoo!に代入するようなnarrow変換の場合、コンパイル時に警告が出るようです。

ただし、このJEPに対応するEalry Accessがないので、実際にどのような警告が出るのか、実行させるとどうなるのかなどは、よく分かりません。キャストすればいいのか、nullチェックをした後でないと代入できないのかなどは、Early Accessが出たら確かめてみたいと思います。

 

配列の初期化

Null-Restrictedな変数は、変数の宣言時に初期化を行う必要があります。ただし、クラスのフィールドであれば、コンストラクターやイニシャライザーでも初期化できます。

ここで困るのが配列です。要素も含めて初期化する必要があるからです。

たとえば、"a", "b", "c"を要素に持つString!の配列であれば、次のように書けます。

    String![] texts = new String![] { "a", "b", "c" };

では、初期値として""で埋めた、長さ10の配列はどうでしょう。また、配列のインデックスを使った初期化はどうでしょう? もちろん、Stream APIを使えば書けますが、それではちょっとおおげさですね。

現状のJEPのドラフトでは以下の書き方が提案されていますが、あくまでも現状であり、文法については変わる可能性も高いのですが、とりあえずこういうことが書けるようなことが考えられています。

    String![] texts1 = new String![10] { "" };
    String![] texts2 = new String![10] { i -> "s" + i };

 

この他にもメソッドをオーバーロードする場合、nullnessの違いだけではオーバーロードできないなど、いろいろとルールがありますが、実際にやってみないと具体的にどのようになるのかがJEPだけではよく分からないことが多々あります。

Early Accessが出て、JEPもドラフトではなく正式なものになったら、再度取り上げてみたいと思います。

 

オブジェクト初期化の変更

クラスのフィールドがNull-Restrictedな型の場合、宣言時に初期化するか、コンストラクターもしくはイニシャライザーで初期化する必要があります。

では、次に示すコードは実行したらどのようにふるまうでしょう。

フィールドの初期化ははまりどころが多いので、よくクイズになるところですね。Javaのクイズといえば、JavaOneの名物セッションだったJoshua BlochとNeal GafterによるJava Puzzlersです。

短いコードを提示して実行したらどうなるかを4択で選ぶというセッションなのですが、彼らのウィットに富んだセッションはさくらばもとても影響を受けています。

ということで、ここでもJava Puzzlersをまねて、実行したらどうなるかを4択で選んでみてください。

class Cat {
    String meow = "Meow";

    Cat() {
        meow = ((Lion)this).roar;
    }
}

class Lion extends Cat {
    final String roar;

    Lion() {
        roar = "Roar";
    }
}

public class DoLionMeow {
    public static void main(String... args) {
        System.out.println(new Lion().meow);
    }
}

選択肢は以下の4つ

  1. Meow
  2. Roar
  3. null
  4. 例外発生

 

ちなみに、Meowはネコの鳴き声(ニャーオ)で、Roarはライオンの鳴き声(ガオー)です。

このDoLionMeowクラスでは、Lionオブジェクトを生成して、そのスーパークラスであるCatクラスのフィールドのmeowを表示させています。

Catクラスのコンストラクターでは、サブクラスのLionのroarをmeowに代入しています。roarはLionのコンストラクターで"Roar"を代入しています。

 

さて、どうでしょう。

答えは 3. の null です。

 

それほど難しくはないですよね。

roar変数はfinalなので一度しか初期化できません。しかし、実際には初期化する前の状態があり、その時の値はnullになります。

そして、Lionクラスのコンストラクターでは省略されていますが、super()をコールしているということです。つまり、Lionクラスのコンストラクターは省略しないで記述すると、次のようになります。

    Lion() {
        super();
        roar = "Roar";
    }

 

このため、roarを初期化する前にCatクラスのコンストラクターがコールされてしまい、初期化されていないroarにアクセスしてしまっているということです。

初期化していないのでroarの値はnullになり、meowに代入するので、結果的にnullが表示されてしまいます。

 

ここで重要なのはfinal変数でも、初期化前の状態にアクセスできてしまうということです。もし、meowの型がString!だったらどうでしょう。nullはとらないはずなのに、実際はnullになってしまうのは問題です。

これを解決するために、オブジェクトの初期化を変更するというのがJEP 492: Flexible Constructor Bodiesです。

JEP 492はProject Valhallaではなく、Javaの言語仕様をアップデートするProject Amberで策定されています。Value ClassやNull-Restricted Typeとは独立に仕様策定できるので、Value Classにさきがけてアップデートしてしまおうということなのかもしれません。

JEP 492はJava 24で3rd Previewになっているので、次のLTSのJava 25に標準で取り込まれる可能性が高いです。

 

Lionクラスのように今までのコンストラクターは、必ず先頭でスーパークラスのコンストラクターを呼び出していました。デフォルトコンストラクター以外のコンストラクターをコールするのであれば、明示的にsuper(...)をコールする必要がありますが、デフォルトコンストラクターであれば記述を省略できます。

このため、スーパークラスからはサブクラスの初期化していないフィールドにアクセスできてしまいます。

そこで、JEP 492ではsuper(...)をコールする前にフィールドの初期化を行えるように言語仕様を変更しています。

 

とはいえ、super(...)をコールする前にどういう処理でもできるわけではありません。たとえば、thisは使うことはできません。他にもルールはありますが、要するにフィールドの初期化以外の処理をsuper(...)の前に記述しないということが重要です。

また、デフォルトコンストラクターのsuper()を省略した場合は、現在の使用と同じくコンストラクターの先頭でコールされます。

 

さて、DoLionMeowクラスで"Roar"を出力させるためには、Lionクラスのコンストラクターを次のように記述すればよいことが分かります。

    Lion() {
        roar = "Roar";
        super();
    }

 

ただし、JEP 492はPreview JEPなので、コンパイルや実行する時にはオプションの--enable-previewが必要です。

 

ところで、前述したJava Puzzlersのセッションではフィールドの初期化に関するパズルは必ず1問は出題される頻出分野だったのですが、JEP 492が導入されるとそれらのパズルは通用しなくなってしまいますね。まぁ、どちらにしろずっと昔の話なので、どうでもいいといえばどうでもいいのですけど。

 

最後に

3回に渡ってValue Classに関連したトピックを紹介してきました。

Value Classを作成するのは簡単ですが、最適化されることを考慮して使う必要があります。

サイズが小さいことと、フィールドがNull-Restrictedであることが最適化の条件です。このように考えると、Recordクラスで記述していたデータで、サイズが小さければValue Classにするというのがいいと思います。

とはいうものの、いつからValue Classが使えるようになるのかはまだまだ分かりません。Project Valhallaが発足して10年。ここまで待ったのですから、もうちょっとだとは思いますが気長に待ちましょう!

2024/12/02

Valueクラスによる最適化
 で、ValhallaのValue Classってどうなったの? その2

このエントリーをはてなブックマークに追加

本エントリーはJava Advent Calendarの2日目です。昨日はHatanoさんのこんにちは、世界でした。

 

さて、前回のエントリーに続き、Project ValhallaのValue Classについて紹介していきます。

本エントリーでは、Value Classを使用した場合に可能になる最適化について説明します。

 

前回のエントリーで紹介したようにValue Classの導入の背景にあったのが、ヒープ使用効率の最適化にあります。

そこで説明したのが、配列の領域に参照ではなく、直接データを埋め込む手法です。この最適化を平坦化(Flattering)と呼ぶようです。まずは、この平坦化から紹介していきましょう。

 

なお、Value Classはまだ策定中の仕様なので、今後変化する可能性があります。本エントリーで説明していることも変わる可能性が高いので、その点はご了承ください。

 

平坦化

Value ClassのオブジェクトをIdentityオブジェクトと同じようにヒープに配置するのではなく、Valueオブジェクトをフィールドとして持つオブジェクトのフィールド領域に埋め込んでしまうのが平坦化です。

前回は配列で説明しましたが、value record Point(double x, double y){}のような小さなValue Classであれば、その配列であるPoint[]にPointオブジェクトが保持すべきxとyを直接埋め込むこと最適化が可能です。

とはいっても、配列なんか使わないからなぁ... と思いますよね。

配列ではなくてリストで平坦化してくれればと思いますよね。でも、ほとんどの場合、リストといえばArrayListクラスを使っているはず。

ArrayListクラスのArrayは配列のこと。ArrayListクラスが内部で保持している配列が平坦化できれば恩恵は大きいはず。

ただ、ArrayListクラスが内部で保持しているのはObjectクラスの配列なのが気になります。今のジェネリクスは、型パラメータで指定された型の配列を作成することができません。

これができれば、平坦化することも可能なはず。というようなことをProject Valhallaの人たちが考えていないわけがないので、今後何らかの進展があると予想しているのですが、どうなんでしょうね。

また、平坦化は配列以外にも適用されます。たとえば、以下のようなレコードはどうでしょう。

value record Point(double x, double y) {}
    
record Rectangle(Point topLeft, Point bottomRight) {}

 

RectangleレコードクラスはValueクラスのPointオブジェクトを2つフィールドに保持します。Valueクラスであれば、フィールドに参照を保持させるのではなく、直接値を保持できるようになります。

さらにRectangleレコードクラスがValueクラスであれば... というように考えていくこともできるはずです。

ただし、平坦化が常に行われるとは限りません。Value ClassがPreview機能で提供されたとしても、当初は最適化される部分は少ないはずです。リリースが進むにつれ、徐々に最適化の範囲が増えていくことが予想されます。

 

スカラー化

もう1つの最適化がスカラー化(Scalarized)です。スカラー化というと多目的計画法で使う言葉だと思っていたのですが、JVMの最適化でも使うんですね。

それはそうとして、以下のようなコードを考えてみます。

    record Score(int score) {}
    
    record Adder(int sum) {
	Adder() { this(0); }

	Adder add(int v) {
	    return new Adder(sum + v);
	}
    }

    int calcTotal(List<Score> scores) {
	Adder adder = new Adder();

	for (var s: scores) {
	    adder = adder.add(s.score());
	}

	return adder.sum();
    }

 

通常は意識しないとは思いますが、Javaのコードはjavacコンパイラでバイトコードに変換され、JVMはバイトコードを実行します。

バイトコードの実行にはスレッドごとにJava Stackという特殊なスタックが作成されます。スタックにはメソッドごとにフレームが積まれます。このフレームにはオペランドスタックというスタックとローカル変数用の領域を持っており、これらを利用してバイトコードを実行します。

オペランドスタックは実行中の状態を保持させるスタックで、演算やメソッドコールはこのオペランドスタックに積まれた値に対して行われます。

ローカル変数領域も実際に使用する時には、オペランドスタックにロードし、処理の結果は再びローカル変数領域にストアされます。

ローカル変数領域もプリミティブ型の値であれば直接保持されますが、参照型の値の場合はヒープに存在するオブジェクトへの参照が保持されます。

つまり、上記のcalTotalメソッドの場合、scores変数はListオブジェクトへの参照、adder変数はAdderオブジェクトへの参照が保持されるわけです。

このcalcTotalメソッドのバイトコードは以下のようになります。

  int calcTotal(java.util.List<Score>);
    descriptor: (Ljava/util/List;)I
    flags: (0x0000)
    Code:
      stack=2, locals=5, args_size=2
         0: new           #7                  // class Adder
         3: dup
         4: invokespecial #9                  // Method Adder."<init>":()V
         7: astore_2
         8: aload_1
         9: invokeinterface #10,  1           // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
        14: astore_3
        15: aload_3
        16: invokeinterface #16,  1           // InterfaceMethod java/util/Iterator.hasNext:()Z
        21: ifeq          48
        24: aload_3
        25: invokeinterface #22,  1           // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
        30: checkcast     #26                 // class Score
        33: astore        4
        35: aload_2
        36: aload         4
        38: invokevirtual #28                 // Method Score.score:()I
        41: invokevirtual #32                 // Method Adder.add:(I)LAdder;
        44: astore_2
        45: goto          15
        48: aload_2
        49: invokevirtual #36                 // Method Adder.sum:()I
        52: ireturn

全体を解説することはしませんが、注目していただきたいところは色付きにしました。

オレンジの0から7の行はAdderオブジェクトを生成して、ローカル変数の[2]に保存しているバイトコードになります。

インデックス0にはthis、インデックス1には引数のListオブジェクトの参照が保持されており、その後にAdderオブジェクトの参照が保持されるわけです。

赤で示した35から44がJavaのコードでいうところのforループの内部の処理に当たります。

aload_2でローカル変数[2]からAdderオブジェクトの参照をオペランドスタックに積み、次のaloadでローカル変数[4]をスタックに積んでいます。このインデックス4には、Scoreオブジェクトの参照が保持されています。

その後の38のinvokevirtualがコメントにあるようにScoreクラスのscoreメソッドをコールしています。その結果はそのままスタックに積まれます。この時点でスタックにはAdderオブジェクトとscoreメソッドの戻り値のint値が積まれています。

そして、41のinvokevirtualでAdderクラスのaddメソッドをスタックに積まれたint値を引数にコールします。addメソッドの戻り値は新たに生成されたAdderオブジェクトで、スタックに積まれるので、44のastore_2でローカル変数[2]に保存されます。

 

 

このように、forループの内部では毎回Adderオブジェクトを生成し、ローカル変数のオブジェクト参照を更新するということを繰り返します。また、そのオブジェクトが保持している値はやはり毎回アクセスする必要があります。

毎回のオブジェクト生成や、値の取得処理が省略できるのであれば、パフォーマンが向上します。

もし、メソッド内で使用していたオブジェクトが、戻り値などでメソッドの外に逃げ出さないのであれば、この最適化をすることができます。

メソッドから逃げ出さないというのは、当該メソッド以外の部分でオブジェクト参照するということです。オブジェクトにどこから参照されるか分からないので、たとえValueオブジェクトであっても通常のIdentityオブジェクトと同じようにヒープにオブジェクトを配置しなければなりません。

逆に、オブジェクトがメソッド内だけで使われるのであれば、Valueオブジェクトをヒープに作るのではなく、Valueオブジェクトが保持する値をローカル変数領域に直接保持させてしまえばいいわけです。

 

 

ローカル変数領域に値を直接保持させることで、オブジェクト生成や参照の張替えが不要になります。

繰り返しになりますが、この最適化はValueオブジェクトがメソッド内にとどまっていることが条件になります。このため、オブジェクトがメソッド内だけで使用されているかどうかを調べる必要があります。

これをエスケープ解析(Escape Analysis)と呼びます。オブジェクトがメソッドの外に逃げ出さないかどうかを解析するということですね。エスケープ解析でオブジェクトが逃げ出さないと分かれば、スカラー化以外にも最適化が可能になります。

 

ここでは2種類の最適化を紹介しましたが、Valueクラスが提供された時にはじめから両方の最適化が行われるとは限りません。まずはValueクラスを使えるようになり、そこから徐々に最適化が導入されていくことが予想されます。

また、最適化を行うにはエスケープ解析でオブジェクトが逃げ出さないことが条件になりますが、他にもいくつか条件があります。その1つにnullの扱いがあります。

たとえば、Valueオブジェクトが保持する値にnullが紛れてしまうと、平坦化もスカラー化もできなくなってしまいます。

このため、Project Valhallaでは、null非許容性とnull許容性、つまりNon-NullとNullableを導入することになりました。

Non-NullとNullableは以前から要望がありましたが、まさかProject Valhallaによって仕様策定されることになるとは思いもよりませんでした。

そこで、次のエントリーではNon-Null/Nullableと、それに関連してオブジェクト初期化処理の変更について紹介する予定です。

2024/11/10

で、ValhallaのValue Classってどうなったの? その1
(JJUG CCC 2024 Fall)

このエントリーをはてなブックマークに追加

10月27日にJJUG CCC 2024 Fallが開催されました。

久しぶりのベルサール新宿グランド。前回までの野村コンファレンスプラザ新宿に比べると、部屋も増えて、参加者も大幅に増えたようです。

で、さくらばはProject Valhallaで策定されているValue Classについてプレゼンしてきました。資料はこちら。

なお、Value Classはまだ策定中の仕様なので、今後変化する可能性があります。本エントリーで説明していることも変わる可能性が高いので、その点はご了承ください。

Value Classを説明する前に、そもそもProject Valhallaとは何なのか?

Project ValhallaはJavaの型システムを見直して、整理するためのOpenJDKのサブプロジェクトです。

Valhallaとは北欧神話に出てくる主神オーディンの宮殿のことです。戦士の魂が最終的に集められるのがValhallaで、日本人的な感覚だとあまり縁起のいい場所ではないような気がするんですけど、どうなんでしょう。

ちなみに、上の資料の表紙の背景にある道は、スゥエーデンのストックホルムにあるヴァルハラ通りです。今年の2月にストックホルムで開催されたJfokusに参加したので、ついでにValhallaにも行ってみたわけですw

そんなこんなで、資料の背景の写真はすべてストックホルムで撮った写真を使ってます。

 

さて、Project Valhallaです。

Valhallaでは型の再整理を行っているのですが、主な論点としては以下の4つがあります。

Value ClassがValhallaのメインとなる論点で、この後説明していきます。

Value Classの導入過程で必要となったのが、Null-Restricted/Nullable Typeです。

Specialized Genericsというのは、ジェネリクスの型パラメータにプリミティブ型も使用できるようにしようというものです。

Primitive拡張とSpecialized Genericsは、Value ClassとNull-Restricted/Nullable Typeに比べると、仕様策定にまだまだ時間がかかりそうなので、ここでは触れません。

 

Value Class導入の背景

Value Classの解説をする前に、まずValue Classの導入の背景について説明しましょう。

ご存じの通り、Javaには2種類の型があります。一方がプリミティブ型、もう一方が参照型です。

クラスでオブジェクトを作ってというのは、すべて参照型ですね。また、Javaでは配列も参照型となります。

Javaの言語仕様的にはプリミティブ型と参照型には以下のような違いがあります。

最後の初期化はプリミティブ型ではデフォルト値(たとえば数値型であれば0)があり、初期化しなくてもデフォルト値で使用できます。一方の参照型では必ずnewをしてオブジェクトを生成して初期化しなくてはならないということです。参照型変数のデフォルト値としてnullがありますが、これは変数のデフォルト値であってオブジェクトのデフォルト値ではないです。

言語仕様的なこのような違いはありますが、Valhallaで着目しているのは2つの型がヒープでどのように扱われているかということです。

たとえば、doubleの配列を考えてみます。

配列も参照型のオブジェクトなので、ヒープに生成する場合、オブジェクトヘッダーとフィールド用の領域を確保します。

プリミティブ型の値を配列に格納する場合、フィールド用の領域に直接値が書きこまれます。

一方、参照型の配列の場合、フィールド用の領域には要素のオブジェクトへの参照が格納されます(これが参照型と呼ばれる理由です)。

たとえば、2つのdoubleの要素を持つPointレコードの場合を示したのが、以下の図です。

Pointオブジェクトがヒープ上のどこに配置されるのかについては、JVMまかせでユーザーは指定できません。このため、Pointオブジェクトが離れた位置に配置されることもあります。

 

CPUのメモリアクセス

ここで、CPUがどのようにメモリにアクセスするかを紹介しておきましょう。

CPUの内部ではALU (Arithmetic Logic Unit、演算ユニット)が演算を行うのですが、そのためのデータはレジスターに格納します。

レジスターは高速ですが小容量なので、他のデータはメインメモリーに配置されます。必要に応じて、メインメモリーからレジスターにデータをロードします(もちろん、その逆方向もあります)。

しかし、メインメモリーは速度が遅いため、メインメモリーに直接アクセスするとCPUがアイドル状態になってしまいます。このため、現代のCPUではレジスターとメインメモリーの間にはキャッシュを配置しています。

その構成を表したのが以下の図です。

キャッシュはL1, L2, L3の3レベルあり、数字が少ないほど高速ですが、容量は少なくなります。

L1にデータがあれば数クロックでアクセスできますが、キャッシュにないデータをロードする場合桁違いに遅くなるわけです。

これをキャッシュミスと呼びます。

データにアクセスする時に、なるべくキャッシュミスが起こらないようにするのが、パフォーマンスを向上させる秘訣になります。

メインメモリーからキャッシュにデータをロードする時は、1つ1つのデータではなく、ある程度まとまった単位(チャンク)でロードします。これは、あるデータを使用する時、その近くにあるデータにもアクセスする傾向があるからです。たとえば、ループで配列をイテレートする場合などですね。

 

参照型配列のヒープ使用効率

前節で一緒に使うデータをなるべく近くに配置すれば、キャッシュミスが発生しないことを説明します。ところが、参照型の配列だとどうでしょう。

Pointオブジェクトはヒープ上で固まって配置されるとは保証されません。つまり、キャッシュミスを引き起こす可能性が高いということです。

デフォルトで使用されるG1GCの場合、メモリを領域で区分し、Young領域とOld領域に分けられます。そして、新しいオブジェクトは基本的にYoung領域に配置されます。

このため、上記のPointオブジェクトがヒープ上で遠く離れた位置に作られる可能性は少ないのですが、複数のYoung領域に分かれて生成させることはあるかもしれません。このような場合に、キャッシュミスを引き起こしてしまうわけです。

では、どうすればよいでしょう?

プリミティブ型の値と同じようにデータを直接フィールド領域に格納してしまえばいいということです。つまり、下図のようになります

しかし、参照型を使用する限り、このようなデータ格納を行うことができません。

そこで、プリミティブ型に近い新たな型の導入が望まれたわけです。

キーとなるのは、"Codes like a class, works like an int"です。

クラスのように書けるけども、intのようにふるまうということです。

Project Valhallaでは、これを実現させるために10年に渡って議論を続けてきました。数年前までは、新たに様々な型を導入するという複雑な実現方法が提案されていました。しかし、あまりにも複雑すぎました。

そこで、去年ぐらいから、もっとシンプルな方法が検討され、やっと議論が収束してきたのです。

そして、新たに提案されたのがValue Classです。

Value Typeではなく、Value Classだというのがポイントです。

つまり、新たな型を導入するのではなく、既存の参照型の枠組みの中で特殊なクラスを導入することでCode like a class, works like an intが実現できるということです。

では、そのValue Classというのは、どのようなクラスなのでしょう?

 

Value Class

Value Classの仕様はJEP 401: Value Classes and Objectsに記述されています。

端的にいうと、Value Classをインスタンス化したオブジェクト(Value Object)にはIdentityがありません。といわれても、「Identityって何?」と思いますよね。私もそうでした。

このIdentity、Java Language SpecificationにもJVM Specificationにも明確な定義はありません。

Identityはオブジェクトを区別するために使われるオブジェクトの名前もしくはアドレスのようなものです。

具体的な値としてはSystem.identityHashCodeメソッドが返す値になります(もしくはオーバーライドしていない場合のObject.hashCodeメソッド)。

この値は、==でオブジェクト同士を比較する場合に使用されます。

また、Identityでオブジェクトを区別することが、オブジェクトの状態変更を可能にします。また、synchronizedを使用したモニタロックもIdentityを利用して実現しています。

しかし、なぜIdentityなのでしょうか?それはオブジェクトのヘッダーに関係があります。

オブジェクトヘッダー

オブジェクトヘッダーは、ヒープ上に存在するオブジェクト領域の先頭にあります。実をいうと、オブジェクトヘッダーは、JVMの実装依存でJVM Specificationには定義されていません。ここでは、OpenJDKの64bitのHotSpot VMでのオブジェクトヘッダーについて紹介します。

HotSpot VMのオブジェクトヘッダーはマークワードとクラスワードの2つのパートから構成されます。マークワードはハッシュ値、GC Age、Tagからなります。GC AgeはGCを何度経てきたかを表す回数を示します。また、Tagはマークワードがポインターで上書きされてしまうことがあるので、それを区別するために使われます。

一方のクラスワードはクラスへのポインターが格納されます。

こう見てみると、GC AgeとTagを除けば、ヘッダーによってクラスとオブジェクトを区別するための情報が格納されていることが分かります。クラスはともかく、Identityがないということはオブジェクトヘッダーがなくても大丈夫ということです。

このことから、オブジェクトヘッダーを省略してしまって、そのオブジェクトをフィールドに持つクラスに直接データを埋め込むことが可能であることを示しています。

 

ちなみに、オブジェクトヘッダーはそれなりにサイズが大きいので、小さいオブジェクトだとヘッダーの方が大きいということが起こります。そこで、Project Lilliputでオブジェクトヘッダーを小さくする仕様を策定しています。

ちょうど、次のJava 24で、LilliputのJEP 450: Compact Object HeadersがExperimentalとして導入予定です。

 

あらためてValue Class

identityが分かったところで、あらためてValue Classの定義について説明しましょう。

Value ClassはIdentityがないクラスですが、もう1つの特徴としてイミュータブルであることがあります。

Value Classではフィールドを定義すると、そのフィールドはすべて暗黙的にfinalになります。

また、Identityがないことにより、Identityを使用していた操作はできません。

たとえば、オブジェクトの比較を行う==演算は、オブジェクトの同一性ではなく、フィールドの等価性の結果を返します。つまり、equalsメソッドで比較する場合と同様になるということです。

他にも、synchronizedを使用したモニターロックや、参照を使用する弱参照(WeakReference)、ファントム参照(PhantomReference)なども使用できません。

 

Value Classの書き方

では、Value Classをどのように定義すればよいのでしょう。

これはとても簡単でclassもしくはrecordの前にvalueをつければよいだけです。

value record Point(double x, double y) {}

value class Rectangle {
    Point topleft;
    Point bottomright;

    Rectangle(Point tl, Point br) {
	topleft = tl;
	bottomright = br;
    }
}

Record Classはもともとイミュータブルなので、valueを付加しても特に問題なくコンパイルできます。

通常のクラスの場合、状態を変更するようなコードがあるとコンパイルエラーになります。

たとえば、Rectangleクラスを以下のようにセッターを追加してコンパイルしてみます。

value class Rectangle {
    Point topleft;
    Point bottomright;

    Rectangle(Point tl, Point br) {
	topleft = tl;
	bottomright = br;
    }

    public void setTopLeft(Point tl) {
	topleft = tl;
    }

    public void setBottomRight(Point br) {
	bottomright = br;
    }
}

 

> javac --release 23 --enable-preview Rectangle.java
Rectangle.java:13: エラー: final変数topleftに値を割り当てることはできません
        topleft = tl;
        ^
Rectangle.java:17: エラー: final変数bottomrightに値を割り当てることはできません
        bottomright = br;
        ^
ノート: Test.javaはJava SE 23のプレビュー機能を使用します。
ノート: 詳細は、-Xlint:previewオプションを指定して再コンパイルしてください。
エラー2個

前述したように、Value Classのフィールドは暗黙的にfinalになるため、そこに再代入しているためコンパイルエラーになっています。

なお、ここでコンパイルに使用しているJDKはjdk.java.netで公開されているValhallaのEarly Access版です。

 

Vlaue Classが作成できるようになったので、Value Classの特徴の1つでもある==での比較を行ってみましょう。

通常のクラス(Value Classに対応してIdentity Classと呼びます)とValue Classで、JShellを使用して比較してみました。

jshell> record IDPoint(double x, double y) {}
|  次を作成しました: レコード IDPoint

jshell> var idp1 = new IDPoint(1, 2)
idp1 ==> IDPoint[x=1.0, y=2.0]

jshell> var idp2 = new IDPoint(1, 2)
idp2 ==> IDPoint[x=1.0, y=2.0]

jshell> idp1 == idp2
$4 ==> false

jshell> idp1.equals(idp2)
$5 ==> true

jshell> value record VPoint(double x, double y) {}
|  次を作成しました: レコード VPoint

jshell> var vp1 = new VPoint(1, 2)
vp1 ==> VPoint[x=1.0, y=2.0]

jshell> var vp2 = new VPoint(1, 2)
vp2 ==> VPoint[x=1.0, y=2.0]

jshell> vp1 == vp2
$9 ==> true

jshell> vp1.equals(vp2)
$10 ==> true

jshell>

Identity Classだと、フィールドの値が同一のオブジェクトであっても、==はIdentityが同じかどうかを調べるので、falseになります。その一方、Value ClassではIndentityがなく、フィールドの同値性を調べるので、==の結果はtrueになっています。もちろん、equalsメソッドで比較してもtrueです。

 

では、継承についてはどうでしょう。

Value Classは、コンクリートクラスの場合、finalクラスになるためサブクラスを作ることはできません。ただし、Value Classの抽象クラスであればサブクラスを作ることができます。

jshell> value class A {}
|  次を作成しました: クラス A

jshell> value class B extends A {}
|  エラー:
|  final Aからは継承できません
|  value class B extends A {}
|                        ^
|  エラー:
|  The concrete class A is not allowed to be a super class of the value class B either directly or indirectly
|  value class B extends A {}
|  ^------------------------^

jshell> class C extends A {}
|  エラー:
|  final Aからは継承できません
|  class C extends A {}
|                  ^

jshell> abstract value class X {}
|  次を作成しました: クラス X

jshell> value class Y extends X {}
|  次を作成しました: クラス Y

jshell> class Z extends X {}
|  次を作成しました: クラス Z

jshell>

最後のコードは抽象クラスのValue Classを継承してIdentity Classを作れるということです。まぁ、作れたとしても使うことはないでしょうけど。

もちろん、インタフェースを実装したValue Classを作ることは可能です。

jshell> interface I {}
|  次を作成しました: インタフェース I

jshell> value class J implements I {}
|  次を作成しました: クラス J

jshell>

ここで一度Value Classについてまとめておきましょう。

Value Classは以下のような特徴を持つクラスです。

  • identityのないクラス
  • イミュータブル
  • finalクラス
  • ==はフィールドの状態の比較
  • identityに依存した操作は不可
    • synchronizedを使用したモニタロック
    • 弱参照、ファントム参照などの参照

書き方に関しては以下のようになります。

  • classもしくはrecortdの前にvalueを付加して宣言
  • 抽象クラスでもValue Classにすることが可能
  • 抽象Value Classのサブクラスを定義可能
  • インタフェースの実装も可能

 

長くなってしまったので、最適化やNull-Restricted Typeについては次のエントリーで紹介していきます。

2024/09/17

JEPでは語れないJava 23

このエントリーをはてなブックマークに追加

毎度おなじみ半年ぶりのJavaのアップデートです。

Java 23はLTSのちょうど中間のリリースということもあって、それほど変化があるわけではないです。

Java 23のJEPは12ありますが、Previewばかり。PreviewでないStandard JEPは3つですがAPIの変更が伴うものはありません。

Java 23のJEPは以下の通り。

  • 455: Primitive Types in Patterns, instanceof, and switch (Preview)
  • 466: Class-File API (Second Preview)
  • 467: Markdown Documentation Comments
  • 469: Vector API (Eighth Incubator)
  • 473: Stream Gatherers (Second Preview)
  • 471: Deprecate the Memory-Access Methods in sun.misc.Unsafe for Removal
  • 474: ZGC: Generational Mode by Default
  • 476: Module Import Declarations (Preview)
  • 477: Implicitly Declared Classes and Instance Main Methods (Third Preview)
  • 480: Structured Concurrency (Third Preview)
  • 481: Scoped Values (Third Preview)
  • 482: Flexible Constructor Bodies (Second Preview)

Standard JEPの1つめ。JEP 467はJavadocにマークダウンが使えるというものです。

これは地味にうれしいかも。ただ、マークダウンにする時にはスラッシュ3つというのはちょっと面倒かもしれません。

IDEがマークダウンのJavadocに対応して、ショートカットでスラッシュ3つを簡単に使えるようになってほしいですね。

2つめのJEP 471は、UnsafeクラスのヒープではないネイティブメモリまわりのAPIをDeprecated for RemovalにするというJEPです。通常の用途ではUnsafeクラスは使わないとは思いますが、昔は高速化のためにヒープではなくUnsafeを使って直接メモリにアクセスしていたフレームワークやライブラリがそれなりにあったのです。

しかし、FFMが導入されたので、Unsafeを使わずにメモリアクセスできるようになったので、ようやく削除できるようになったということですね。ただ、ほんとに削除されるのがいつになるのかは微妙なところです。

Standard JEPの最後のJEP 474は、ZGCのデフォルトを世代別ZGCに変更するというものです。もともとZGCは世代別GCではなかったのですが、Java 21で世代別GCをサポートするようになりました。これに伴い、ZGCのデフォルトが世代別GCの方に変わるというものです。

ただ、これはあくまでもZGCの話で、JVMのデフォルトのGCの話ではないことに注意が必要です。

 

残りのJEPはすべてPreviewです。

JEP 455はパターンマッチングにプリミティブ型が使えるようになるというJEPです。ちょっとおもしろいのが、プリミティブ型の値と型のcaseを同じswitch式の並べて書けるところですね。まぁ、そんなに使うことはないと思いますが...

ちなみに、JEP 455は1st Previewなので、次のLTSであるJava 25に入らないんじゃないかなぁ。

 

JEP 466はバイト操作を行うためのClas-File APIです。バイトコードを直接編集してしまうわけですが、一時期ちょっとだけはやったAOPなどで使われる技術です。Class-File APIはAOPというよりは、ラムダ式の実行時に動的にクラス生成するなどJVM内部での用途が主目的のようです。

 

JEP 469 Vector APIは8回目のPreview。Project Valhallaが導入されるまで、ずーーーーっとPreviewのままです。7thからの変更点もありません。

 

JEP 473 Stream Gatherersはストリームの中間処理をもうちょっとどうにかしようというJEP。簡単にいえば、終端処理のcollectと同様のことを中間処理でもできるようにしましょうというAPIです。

これで、今までのストリームではできなかった移動平均なんかも簡単に書けるようになります。

JEP 473は2nd Previewで、すでにPreviewが外されたJEP 485が提案されているので、Java 24で正式導入ということになりそうです。

 

JEP 476 Module Import Declarationsは、インポート文をクラス単位で書くのではなくて、moduleで書けるようにしてしまいましょうというJEPです。これができると、import module java.base;で基本的なAPIは全部使えます。

これはかなり便利になりますけど、使っているクラスがどのパッケージで定義されているのか調べるにはIDEの力に頼らなければいけないという負の側面がなきにしもあらず...

JEP 476は1st Previewなので、Java 25にはまにあわないかもしれません。

 

JEP 477 Implicitly Declared Classes and Instance Main Methodsはmainメソッドを含むクラスを書かなくても、mainメソッドだけ書けばいいんじゃないというJEPです。

ちなみに、このJEPでは動的にクラスを作成していますが、その生成はバイトコード操作ではなくて、実行中にJavaのコードを動的に生成して、コンパイルとクラスロードも行うという実装になっています。

 

JEP 480 Structured ConcurrencyとJEP 481 Scoped ValuesはProject LoomでVirtual Threadsと一緒に仕様策定されていたAPIです。

両方とも3rd Previewですが、Java 23でStandardになると予想していたので、外してしまいました😱

JEP 480は複数の非同期タスクの結果を待つような処理を簡単に書けるというAPIです。CompletableFutureクラスを使えば同様の処理は書けるのですが、CompletableFutureクラスの宣言的な書き方ではなくて、手続き的な記述でも使えるよというのがポイントでしょう。

JEP 481はThreadLocalクラスの置き換えになるAPIです。ThreadLocalクラスはいろいろと問題があり、特にVirtual Threadのように多くのスレッドを使うようになるとその問題が顕現しやすくなります。

これを解決するために、JEP 481ではScopedValueクラスを導入しています。

Scoped Valueは4th PreviewがJEPのドラフトに上がっているので、Java 25に間に合うかは微妙なところです。

 

最後のJEP 482 Flexible Constructor Bodiesは、Java 22の時のJEP 447 Statements before super()の名前が変更されたJEPです。

JEP 447ではコンストラクターで、親クラスのsuper()をコールする前に処理を書けるようにしましょうというJEPでした。JEP 482ではこれに加えて、super()をコールする前に子クラスのフィールドの初期化も行えるようにしましょうというJEPになりました。

これはクラスの初期化のスキームの大きな変更なのですが、使う側からするとフィールドを先に初期化したいというニーズはほとんどないはずです。

JEPには書いてないのですが、これはProject ValhallaのVlue Classに関連しているのです。Value Classの露払いとなるJEPなのでした。

 

軽くJEPを説明したところで、本題のAPIの変更について紹介していきましょう。

 

例によって、セキュリティ関連のAPIは省略します。本バージョンでも、java.baseモジュール以外にもAPIの変更はありますが、使用頻度が低いAPIであるため、解説を省略します。

 

廃止になったAPI

Java 23では4つのクラス、5つのメソッドが削除されました。ただし、削除された4つのクラスはjava.managementモジュールのJMXに関するクラスなので、ここでは省略します。

 

メソッド

Java 22でもThreadクラスのメソッドが削除されましたが、Java 23でもスレッド関連のメソッドが削除されてインす。

  • java.lang.Thread.resume()
  • java.lang.Thread.suspend()
  • java.lang.ThreadGroup.resume()
  • java.lang.ThreadGroup.suspend()
  • java.lang.ThreadGroup.stop()

これらのメソッドはJava 14でforRemovalがtrueになっていたので、とうとう削除されたという感じですね(ThreadGroup.stopメソッドだけはJava 16です)。

それ以前からスレッドのresume/suspendは使うべきではないメソッドだったので、ようやくです。

ThreadクラスのforRemovalがtrueのメソッドは残り2つ。1つはstopメソッドですが、ThreadGroupクラスで削除されたので、Threadクラスも近いうちに削除されるような気がします。

もう1つはcheckAccessメソッドですが、こちらもいつ削除されても不思議はない感じ。

 

廃止予定のAPI

Java 23では6つのメソッドと2つのコンストラクタが廃止予定に追加されました。

  • java.io.ObjectOutputStream.PutField.write(ObjectOutput out)
  • java.net.DatagramSocketImpl.getTTL()
  • java.net.DatagramSocketImpl.setTTL(byte ttl)
  • java.net.MulticastSocket.getTTL()
  • java.net.MulticastSocket.setTTL(byte ttl)
  • java.net.MulticastSocket.send(send(DatagramPacket p, byte ttl)

PutFieldクラスのwriteメソッドの代わりは、ObjectOutputStreamクラスのwriteFieldsメソッドです。

DatagramSocketImplクラスとMulticastSocketクラスのTTLに関するメソッドは、TTLではなくTimeToLiveを使うようにします。たとえば、getTTLメソッドではなく、getTimeToLiveメソッドを使用します。

最後のMulticastSocketクラスのsendメソッドは、MulticastSocketクラスの親クラスのDatagramSocketクラスで定義されているsend(DatagramPacket p)メソッドを使用するようにします。TimeToLiveを指定するにはDatagramSocketクラスのsetOptionメソッドを使用します。

 

削除予定のコンストラクタは以下の2つです。

  • java.net.Socket(InetAddress host, int port, boolean stream)
  • java.net.Socket(String host, int port, boolean stream)

このメソッドはDatagramSocketクラスが提供される前に使われていたメソッドなのですが、DatagramSocketクラスを使うようにしましょうということです。

 

なお、java.desktopモジュールのjava.bean.beancontextパッケージもforRemovalがtrueになりました。benacontextパッケージでは18のクラスが定義されていますが、すべてforRemoval=trueになっています。

また、java.desktopモジュールに含まれるSwingのBasicSliderUI()コンストラクタも削除予定に追加されています。

 

追加/変更されたAPI

いつもの通り、Preview JEPに関するAPI変更はここでは省略します。ということで、Class-File APIなどはまた別の機会に。

 

java.base/java.ioパッケージ

JEP 477でjava.io.IOクラスが導入されるのですが(Preview APIなので、ここでは省略します)、そのIOクラスの実装で使わるConsoleクラスに多くのメソッドが追加されました。

 

Consoleクラス

Consoleクラスには7つのメソッドが追加されました。

  • Console format(Locale locale, String format, Object... args)
  • Console print(Object obj)
  • Console printf(Locale locale, String format, Object... args)
  • Console println(Object obj)
  • Console readLine(Locale locale, String format, Object... args)
  • Console readPassword(Locale locale, String format, Object... args)
  • Console readln(String prompt)

追加されたメソッドの多くは、メソッドをオーバーロードしてLocaleを指定できるようにしたものです。だいたい使い方は分かりますよね。

 

java.base/java.langパッケージ

Java 22のPreview JEPだったString Templatesがやり直しになったので、StringTemplateクラスは削除されています。また、上述したThreadクラスとThreadGroupクラスのメソッドが削除されています。

また、JEP 481 Scoped ValueのAPIが変更になっていますが、ここでは省略します。

 

java.base/java.lang.foreignパッケージ

Java 22でStandard JEPになったFFMですが、メソッドが2つ追加されました。

 

MemorySegmentインタフェース

MemorySegmentインタフェースで定義されるメソッドが1つ追加されました。

  • long maxByteAlignment()

メモリーセグメントの最大アライメントを返すメソッドです。しかし、この値を何らかの処理に使うというよりは、MemoryLayoutインタフェースのbyteAlignmentメソッドで得られる値と比較してメモリーセグメントの最大アライメントの方が小さいときには例外処理をするという使い方になります。

 

SymbolLookupインタフェース

SymbolLookupインタフェースで定義されるメソッドが1つ追加されました。

  • default MemorySegment findOrThrow(String name)

SymbolLookupインタフェースは、基本的にはfindメソッドでライブラリ内のシンボルのアドレスを探索するために使用します。findメソッドの戻り値の型はOptionalクラスで、見つからなかった場合はOptionalオブジェクトでどうにかしていました。

これに対し、Java 23で追加されたfindOrThrowメソッドを使用すると、見つからなかった場合にNoSuchElementException例外をスローします。

個人的にはOptionalクラスで見つからなかった場合に対処する方がいいとは思いますが、お好みで使い分けてください。

 

java.base/java.lang.reflectパッケージ

毎度のことですが、新しいリリースを表す定数が追加されています。

 

ClassFileFormatVersion列挙型

Java 23に対応する定数の追加です。

  • ClassFileFormatVersion RELEASE_23

 

java.base/java.lang.runtimeパッケージ

プリミティブ型の値を他の型に変換する場合に、正確であるかを調べるExactConversionsSupportクラスが追加されました。

 

ExactConversionsSupportクラス

ExactConversionsSupportクラスで定義しているメソッドは以下の21メソッドです。いずれもstaticメソッドになります。

  • static boolean isDoubleToByteExact(double n)
  • static boolean isDoubleToCharExact(double n)
  • static boolean isDoubleToFloatExact(double n)
  • static boolean isDoubleToIntExact(double n)
  • static boolean isDoubleToLongExact(double n)
  • static boolean isDoubleToShortExact(double n)
  • static boolean isFloatToByteExact(float n)
  • static boolean isFloatToCharExact(float n)
  • static boolean isFloatToIntExact(float n)
  • static boolean isFloatToLongExact(float n)
  • static boolean isFloatToShortExact(float n)
  • static boolean isIntToByteExact(int n)
  • static boolean isIntToCharExact(int n)
  • static boolean isIntToFloatExact(int n)
  • static boolean isIntToShortExact(int n)
  • static boolean isLongToByteExact(long n)
  • static boolean isLongToCharExact(long n)
  • static boolean isLongToDoubleExact(long n)
  • static boolean isLongToFloatExact(long n)
  • static boolean isLongToIntExact(long n)
  • static boolean isLongToShortExact(long n)

ExactなConvertって何だろうという感じですが、これはコードを見てみればすぐに意図が分かります。たとえば、isIntToByteExactメソッドの実装を見てみましょう。

    public static boolean isIntToByteExact(int n) {
        return n == (int)(byte)n;
    }

キャストが2つつなげて書いてあります。つまり引数のintの値をbyteにキャストして、その後にintに戻した時に元の値と同じかどうかを調べているわけです。もし、変換の時に情報が欠落するような変換であれば、2回キャストすると元の値と異なってしまうはずです。これが、Exactかどうかということです。

 

動作は分かりましたが、なぜこんなクラスが今になって追加されたのですね。理由は単純でJEP 455 Primitive Types in Patterns, instanceof, and switchのためです。

パターンマッチングでプリミティブ型が使えるようになりましたが、その時に値を正確に変換できるかどうかが重要になるからです。たとえば、下のコードで考えてみましょう。

    int x = ...;
	
    var y = switch (x) {
        case 0 -> "0";
        case byte a -> "Byte " + a;
        case int b -> "Int " + b;
    };

このコードでは、xの値が-128から127であれば、byteのcaseにマッチします。それを超える範囲、たとえば128だとbyteの範囲を超えるので、intのcaseにマッチします。

つまり、正確に変換が行えるのであれば、型が異なっていてもマッチするわけです。この正確な変換ができるかどうかをチェックするためにExactConversionsSupportクラスが使われるのです。

型の変換に関してはJEP 455にも記述があるので、参考にしてみてください。

 

java.base/java.netパッケージ

Java 22でInet4Address/Inet6Addressクラスのファクトリメソッドが追加されましたが、Java 23ではInet4Addressクラスにファクトリメソッドがさらに追加されました。

 

Inet4Addressクラス

Inet4AddressクラスにアドレスをPosixのリテラルで指定できるファクトリメソッドが追加されました。

  • static Inet4Address ofPosixLiteral(String posixIPAddressLiteral)

ofLiteralメソッドでは10進数でアドレスを表記しますが、ofPosixLiteralメソッドでは8進数や16進数も使用することができます。

 

java.base/java.textパッケージ

数値をフォーマットするNumberFormatクラスと、そのサブクラスにフォーマットの厳密さを指定するメソッドが追加されました。

 

NumberFormatクラス

NumberFormatクラスのパースはデフォルトでは寛大になっています。これに対し、厳密なパースに関するメソッドが追加されました。

  • boolean isStrict()
  • void setStrict(boolean strict)

NumberFormatクラスでは、これらのメソッドをコールするとUnsupportedOperationException例外がスローされます。

厳密なパース処理は、以下の3種類のサブクラスで使用することができます。

  • ChoiceFormat
  • CompactNumberFormat
  • DecimalFormat

それぞれのクラスのparseメソッドのAPIドキュメントに厳密な場合について記述されているので、参考になさってください。まぁ、それほど使うとは思わないですけどw

 

java.base/java.timeパッケージ

時点を表すInstantメソッドにメソッドが1つオーバーロードされました。

 

Instantクラス

Instantクラスには時間量を調べるuntilメソッドがありましたが、オーバーロードされています。

  • Duration until(Instant endExclusive)

既存のuntilメソッドはもう一方の時点をTemporalオブジェクトで指定し、戻り値はlongで表されます(どの時間量なのかは第2引数で指定します)。

これはちょっと使いにくいので、時点をInstantオブジェクトで表し、戻り値は時間間隔を表すDurationオブジェクトで表されるuntilメソッドのオーバーロードが追加されたわけです。

 

その他

なんとAPIの変更はこれだけなのです。なのですが、他にちょっとだけ気になる変更があったので、それも一緒に紹介しておきます。

 

COMPATロケールプロバイダーの廃止

ロケールプロバイダーってなんだという感じですが、ロケールのデータベースのようなものです。

Javaでは歴史的経緯から3種類のロケールプロバイダーを提供していました。しかし、Java 9からは世界的な標準であるCommon Locale Data Repository (CLDR)がデフォルトになっています。

そして使われなくなった残り2つのロケールプロバイダー(JREとCOMPAT)が削除されることになりました。

Java 21から、JREかCOMPATを使っていると警告が表示されていたのですが、早々に削除されることになりました。

詳しくはJava Bug Systemの JDK-8325568 をご覧ください。

 

標準Docletの変更

DocletというのはJavaのソースコードからJavadocを生成するためのツールです。

JEP 467でJavadocの見直しがあったためなのかどうか分かりませんが、標準Docletが変更されJavadocの見た目が変わりました。

具体的には下図のように左側にサイドバーが出るようになっています。以前のように階層をたどるためのサイドバーではなく、右側に表示しているクラスやインタフェースの目次的なサイドバーになっています。

しかし、これが微妙なんですよね。メソッドの一覧がソートされておらず、書いてある順になっているので探しにくいのです。Method Summaryの表のようにソートされないですかねぇ。

 

というわけで、Java 23のAPI変更について紹介してきました。

Java 23のAPIの変更は少なかったのですが、次のJava 24では大幅にAPIが追加されそうです。

というのも、すでにPreviewではなくなったJEP 484 Class-File APIやJEP 485 Stream Gatheresが提案されているからです。この2つのJEPはまだターゲットリリースが記述されていませんが、Java 24になるのは既定路線でしょう。

2024/03/19

JEPでは語れないJava 22

このエントリーをはてなブックマークに追加

毎度おなじみ半年ぶりのJavaのアップデートです。

Java 22は、LTSであるJava 21の次のバージョンですが、意外と新機能盛りだくさんです。

Java 22のJEPは以下の12。しかも、スタンダードJEPが4もあります。

  • 423: Region Pinning for G1
  • 447: Statements before super(...) (Preview)
  • 454: Foreign Function & Memory API
  • 456: Unnamed Variables & Patterns
  • 457: Class-File API (Preview)
  • 458: Launch Multi-File Source-Code Programs
  • 459: String Templates (Second Preview)
  • 460: Vector API (Seventh Incubator)
  • 461: Stream Gatherers (Preview)
  • 462: Structured Concurrency (Second Preview)
  • 463: Implicitly Declared Classes and Instance Main Methods (Second Preview)
  • 464: Scoped Values (Second Preview)

注目すべきは、長らくIncubatorやPreviewだったJEP 454。FFMと省略して呼ぶことがおおいですが、Project Panamaのメインとなる機能です。

JNIの代わりに、ネイティブコードをコールしたり、ヒープ外のメモリにアクセスするためのAPIです。

モジュールはjava.baseで、パッケージはjava.lang.foreignになります。

APIなので、本来であれば本エントリーでも取り上げるのですが、ちょっと量が多いですし、差分を紹介してもしかたありません。そこで、別エントリーで使い方についてまとめて紹介する予定です。

ちなみに、同じくProject Panamaで仕様策定しているVector APIはまだIncubatorのままですが、次のバージョンで正式にリリースされるのではないかというのが、さくらばの予想です。

言語仕様の変更がJEP 447, 456, 459, 463と4種類もあります。JEP 456だけがスタンダードJEPで使用しない変数やパターンを _ (アンダーバー)で省略して記述できるというものです。

スタンダードJEPであるJEP 423はG1GCのアルゴリズム改良、JEP 458はjavacでコンパイルすることなく複数のJavaコードを実行できるというものです。この機能は、JEP 330の拡張ですね。

あらたにPreview JEPになったのが、JEP 457とJEP 461です。

JEP 457はバイトコードを扱うためのAPIです。今までバイトコード操作というと、ASMなどが使われていましたが、標準のAPIで可能になります。

JEP 461はStream APIの拡張です。今まで中間操作はストリームの流れてくる1データに対する処理に限定されていましたが、Gathereを使用するとかなり柔軟に中間操作を記述することができるようになります。

 

と、軽くJEPを説明したところで、APIの変更について紹介していきましょう。JEPは多いのですが、意外にもAPIの変更は少ないです。ほとんどがJEP 454とJEP 457に関する変更です。ただし、今回もPreviewやIncubatorの変更は省略するので、JEP 457に関連したAPI変更はStandard JEPになった時に紹介します。また、前述したようにJEP 454 FFMは別エントリーで紹介する予定です。

例によって、セキュリティ関連のAPIは省略します。本バージョンでも、java.baseモジュール以外にもAPIの変更はありますが、使用頻度が低いAPIであるため、解説を省略します。

 

廃止になったAPI

Java 22では1つのメソッドが廃止になりました。しかし、もともと使用しても例外をスローする実装になっているので、廃止されても問題はないはずです。

 

メソッド

  • java.lang.Thread.countStackFrames()

スタックフレームをカウントするメソッドですが、Java 21まではUnsupportedOperationException例外をスローする実装になっています。

 

廃止予定のAPI

Java 22で追加された廃止予定のAPIはありません。

 

追加/変更されたAPI

Java 22のjava.baseモジュールで追加されたAPIは約300なのですが、そのうちの200以上がJEP 457 Class-File APIで、約30がJEP 454 FFM APIです。8割ぐらいは、この2つのJEP由来の変更ということになります。本エントリーでは残りの2割を紹介していきます。。

 

java.base/java.ioパッケージ

java.ioパッケージのConsoleクラスで1つだけメソッドが追加されました。

 

Consoleクラス

Consoleオブジェクトで扱っているデバイスがターミナルかどうかを調べるメソッドが追加されました。

  • static boolean isTerminal()

ターミナルというのは標準入出力に対応したデバイス(POSIXでいうところのtty)です。ターミナルであればtrueが返ります。逆にいうと、JShellやIDEのコンソールだとfalseになります。

jshell> System.console().isTerminal()
$1 ==> false

jshell>

 

java.base/java.langパッケージ

Java 22では、Unicode 15.1に対応したのでそれに応じたブロックの追加が行われました。これ以外にClassクラスとStackWalker.Option列挙型に追加があります。

 

Character.UnicodeBlockクラス

Unicode 15.1で追加されたブロックの定数が追加されました。

  • static final Character.UnicodeBlock CJK_UNIFIED_IDEOGRAPHS_EXTENSION_I

 

Classクラス

プリミティブ型に対応するClassオブジェクトを取得するメソッドが追加されました。

  • static Class<?> forPrimitiveName(String primitiveName)

引数にはプリミティブ型を表す文字列、たとえば"int"とか"double"を指定します。引数がnullの場合、NullPointerException例外がスローされます。

 

StackWalker.Option列挙型

StackWalkerクラスはスレッドごとに作成されるスタックフレームを操作するクラスです。Option列挙型はStackWalkerオブジェクト生成時に使用する列挙型ですが、定数が1つ追加されました。

  • StackWalker.Option DROP_METHOD_INFO

StackWalkerオブジェクトがスタックフレームを操作する時にメソッドの情報を扱わないように指定します。

 

java.base/java.lang.reflectパッケージ

いつものことですが、新しいリリースを表す定数が追加されています。

ClassFileFormatVersion列挙型

Java 22に対応する定数の追加です。

  • ClassFileFormatVersion RELEASE_22

 

java.base/java.netパッケージ

IPv4/IPv6のアドレスを表すInet4Addressクラス/Inet6Addressクラスは直接生成することはできず、スーパークラスのファクトリメソッドを使用していました。これに対し、それぞれのクラスにファクトリメソッドが追加されました。

 

InetAddressクラス

"127.0.0.1"などに対応するInetAddressオブジェクトを生成するにはセグメントの配列を使用するgetByAddressメソッドか、ホスト名も使用できるgetByNameメソッドを使用してきました。これに対し、アドレスを文字列で指定するファクトリメソッドが追加されました。

  • static InetAddress ofLiteral(String ipAddressLiteral)

ofLiteralメソッドでは、引数の文字列をまずIPv4と仮定してパースを行います。失敗した場合、IPv6としてパースします。パースに失敗するとIllegalArgumentException例外がスローされます。

実際の処理はInet4AddressクラスおよびInet6Addressクラスに委譲します。

 

Inet4Addressクラス

Inet4Addressクラスにもアドレスを文字列で指定するファクトリメソッドが追加されました。

  • static Inet4Address ofLiteral(String ipv4AddressLiteral)

アドレスの表記は今まで使用してきたのと同じです。d.d.d.d形式だけでなく、d.d.dからd.d、そしてd形式もパース可能です。

jshell> Inet4Address.ofLiteral("127.0.0.1")
$1 ==> /127.0.0.1

jshell> Inet4Address.ofLiteral("127.0.1")
$2 ==> /127.0.0.1

jshell> Inet4Address.ofLiteral("127.0.257")
$3 ==> /127.0.1.1

jshell>

 

Inet6Addressクラス

Inet6Addressクラスも同様にファクトリメソッドが追加されました。

  • static Inet6Address ofLiteral(String ipv6AddressLiteral)

アドレスの表記も従来と同じで、::や::d.d.d.d形式なども使用できます。

jshell> Inet6Address.ofLiteral("::1")
$1 ==> /0:0:0:0:0:0:0:1

jshell>

 

java.base/java.nio.charsetパッケージ

UTF-8などの標準的な文字セットを定数に持つStandardCharsetsクラスに定数が追加されました。

 

StandardCharsetsクラス

StandardCharsetsクラスではUTF-8やUTF-16系の定数は定義されていましたが、UTF-32系がなかったので追加されました。

  • static final Charset UTF_32
  • static final Charset UTF_32BE
  • static final Charset UTF_32LE

 

java.base/java.nio.fileパッケージ

Pathインタフェースにデフォルトメソッドが追加されました。

 

Pathインタフェース

Pathインタフェースにresolveメソッドのオーバーロードが2種類追加されました。いずれもデフォルトメソッドです。

  • default Path resolve(String first, String... more)
  • default Path resolve(Path first, Path... more)

実際の動作はfirstに対しresolveを行い、得られたPathオブジェクトに対しmoreを順々にresolveしていきます。

実際のコードは以下のようになっています。

    default Path resolve(Path first, Path... more) {
        Path result = resolve(first);
        for (Path p : more) {
            result = result.resolve(p);
        }
        return result;
    }

 

java.base/java.textパッケージ

リストをフォーマットするクラスが追加されました。

 

ListFormatクラス

ListFormatクラスはリストのフォーマッタークラスです。なぜになって導入されたのか、いまいち謎です。

他のフォーマッターと同様にスタイルなどを指定する列挙型も導入されています。

  • enum ListFormat.Style { FULL, SHORT, NARROW }
  • enum ListFormat.Type { STANDARD, OR, UNIT }

ListFormatクラスの使い方は他のフォーマッタークラスと同じです。getInstanceメソッドでListFormatオブジェクトを生成し、フォーマットするのであればformatメソッド、パースをするのであればparseメソッドを使用します。

主なメソッドを以下に示します。

  • static ListFormat getInstance()
  • static ListFormat getInstance(Locale locale, ListFormat.Type type, ListFormat.Style style)
  • String format(Object obj)
  • String format(List<String> input)
  • List<String> parse(String source)
  • Object parseObject(String source)

Objectクラスを引数にするformatメソッドと、parseObjectメソッドはFormatクラスで定義されたメソッドです。>

また、引数のないgetInstanceメソッドはデフォルトロケール、STANDARD、FULLとなります。

StyleとTypeによるフォーマットの違いはListFormatクラスのJavadocにまとめられているので、参考にしてください。

個人的には日本語ロケールだと、ちょっと使いものにならない気が...

jshell> import java.text.*

jshell> var format = ListFormat.getInstance()
format ==> ListFormat [locale: "日本語 (日本)", start: "{0}、{1}", ... }", three: "{0}、{1}、{2}"]


jshell> format.format(List.of(0, 1, 2, 3))
$3 ==> "0、1、2、3"

jshell> format.format(List.of("a", "b", "c"))
$4 ==> "a、b、c"

jshell>

ここで示したようにデフォルトの日本語ロケールだと、リストの区切り文字に全角の"、"が使われます。それはちょっとなぁと思うわけです。

これに対し、たとえばUSロケールでSTANDARD/FULLだと次のようになります。

 jshell> var format = ListFormat.getInstance(Locale.US, ListFormat.Type.STANDARD, ListFormat.Style.FULL)
format ==> ListFormat [locale: "英語 (アメリカ合衆国)", start: "{0},  ... ree: "{0}, {1}, and {2}"]


jshell> format.format(List.of(0, 1, 2, 3))
$6 ==> "0, 1, 2, and 3"

jshell>

英語的には最後の要素が", and "となるのは分かるのですが、これを使いたいことがあるのでしょうか。

結局、よく使うのはTypeをSTANDARDではなくUNITにし、StyleはFULLかSHORTのような気がします。

 jshell> var format = ListFormat.getInstance(Locale.of("c"), ListFormat.Type.UNIT, ListFormat.Style.FULL)
format ==> ListFormat [locale: "c", start: "{0}, {1}", middl ... , three: "{0}, {1}, {2}"]

jshell> format.format(List.of(0, 1, 2, 3))
$8 ==> "0, 1, 2, 3"

jshell>

parseメソッドは戻り値の型がList<String>となることに注意してください。

 

java.base/java.util.concurrentパッケージ

Fork/Join Framework関連でメソッドが追加されました。いずれも割り込みに関するメソッドです。

 

ForkJoinPoolクラス

割り込みがかからないタスク実行のメソッドが追加されています。

  • <T> List<Future<T>> invokeAllUninterruptibly(Collection<? extends Callable<T>> tasks)

複数のタスクをまとめて実行する時に使用するのがinvokeAllメソッドですが、それに割り込みがかからないようにしたのがinvokeAllUninterruptibly()メソッドです。

このメソッドではタスクをjoinする時に、quitelyJoinメソッドを使用しているため、割り込みがかからないようになっています。

 

ForkJoinTaskクラス

ForkJoinPoolクラスとは逆に、割り込みがかかるタスクのファクトリーメソッドが追加されました。

  • static <T> ForkJoinTask<T> adaptInterruptible(Callable<? extends T> callable)
  • static <T> ForkJoinTask<T> adaptInterruptible(Runnable runnable, T result)

今までのadoptメソッドでタスクを生成した場合、タスクに割り込みをかけることができませんでした。これに対し、adaptInterruptibleメソッドではタスクに対して割り込みを書けることができます。

戻り値の型はForkJoinTaskクラスですが、実際には派生クラスのInterruptibleTaskクラスのさらに派生クラスであるAdaptedInterruptibleCallableクラスが戻ります。

2種類のオーバーロードの違いは、引数の型が違うのでjoinした時の戻り値に違いがでるということです。Runnableインタフェースではタスクの戻り値がないので、adaptInterruptibleメソッドの第2引数のresultが返ります。

 

java.base/java.util.randomパッケージ

乱数値のストリームを生成するメソッドが追加されています。

 

RandomGeneratorクラス

RandomGeneratorクラスではdoubleの乱数値のストリームを生成するdoublesメソッドがあります。これの派生メソッドが追加されました。

  • default DoubleStream equiDoubles(double left, double right, boolean isLeftIncluded, boolean isRightIncluded)

doublesメソッドでは要素数を指定しますが、equiDoublesメソッドは無限ストリームになります。

引数は境界値で、その境界値を含むかどうかを第3, 4引数で指定します。

 

 

Java 22のAPI変更について紹介しましたが、やはり少ないですね。

また、これは便利だとか、使えそうというAPIの追加もないようです。

とはいうものの、FFMは外部ライブラリを使いたい人には有用ですし、Class-File APIもASMを使っていた人にはうれしいはず。といっても、これらを使う開発者はごくごくわずかだとは思います。

普通の開発者であれば、ストリームのGathererは便利に使えるはずです。GathererがStandard JEPになるまで、待ちましょう!

 

さて、次のJava 23では、長らくIncubatorだったVector APIが入るかどうかです。最近はVector APIのAPI変更もないようなのですが、一波乱あるのかどうか。ぜひ入ってほしいなぁ。

2024/03/17

Jfokus 2024 その2 セッション編

このエントリーをはてなブックマークに追加

前回に引き続き、Jfokusの参加記です。

www.javainthebox.com

 前回はJfokusに参加するまでの話ですが、本エントリーではさくらばがJfokusに参加して興味深かったセッションを紹介します。


Jfokusは3日間の会期中、1日目がチュートリアルとハンズオンが行われます。2, 3日目が通常のセッションです。


Java 21 Deep Dive - Better Language, Better Scalability, Better APIs, Better Tools

チュートリアルで聴講したのがこれ。おなじみのOracleのアドボケイトのNicolai ParlogとAna-Maria Mihalceanuのセッションです。

資料はこちら。

slides.nipafx.dev


Pattern Matching、Virtual Threads、String Templatesが前半で、後半はSequenced Collectinosなど細かな機能、最後にツール系を紹介していました。

まぁ、さくらばには、ほとんどが知っていることだったので、機能の再確認をしたという感じです。

適度にまとまっているので、機能のチェックをしたいのであれば、ちょうどいいと思います。


Java in 2024

Jfokusのキーノートセッションで、こちらもおなじみ、OracleのGeorges Saabです。


当初は、JavaのチーフアーキテクトのMark Reinholdが話す予定でした。しかし、Markの来訪がキャンセルになってしまって、急遽Georgesになりました。

さくらばはMarkのセッションを楽しみにしていたので、キャンセルと聞いてモチベーションダダ下がり。さらにVM Tech Summitもなくて、さらにモチベーションが下がる。

そのモチベーションの低さが会場の写真などをほとんど撮らなかったことにつながるわけです。

Georgesの話はいつも通りな感じですね。


Java Language Update

Devoxx BEでBrian Goetzが話したセッションのアップデート版。JfokusではOracleのViktor Klangが担当。


最近、ViktorさんとParさんが話すことが多いようなんですけど、そういう役割なんですかね。

Java 21だけでなく、Java 17ぐらいからのJava言語仕様の変化についてまとめたセッションです。


Enter the Parallel Universe of the Vector API

AzuleのSimon RitterのVector APIに関するセッション。


資料はこちら。

Enter The Parallel Universe of the Vector API


Vector APIについての分かりやすい解説。これを見ておけば、だいたい理解できるんじゃないかなぁ。資料だけだとちょっとつらいかもしれないですが。

Java 22でFFMが正式に導入されるので、Vector APIももうすぐですね。


Modern Java in Action

こちらもNicolai Parlogのセッション。


資料はこちら。

slides.nipafx.dev


GitHubをクロールしてするサンプルアプリケーションを古いスタイルから新しいスタイルに書き換えていくというライブコーディングのセッション。

なかなかおもしろいけど、早すぎて途中からついていけないのが...


これ以外にも、Ubertoの関数型のセッションや、ParさんのLeydenのセッション、Datadogのプロファイラー、AlinaとShaunのGraalVMなどを聴講しました。

それにしても、VM Tech Summitがなかったのがイタイ。

来年は、VM Tech Summitがあれば参加するつもりですが、ないのならばやめようかなぁと思うさくらばなのでした。

2024/03/16

Jfokus 2024 その1 準備編

このエントリーをはてなブックマークに追加

2月5から6日にかけて、スウェーデンのストックホルムでJavaのカンファレンスのJfokus 204が開催されました。今回、日本人の参加者は私を含めて3人しかいませんでした。

ぜひ、来年は日本からの参加者が増えるといいなぁということで、Jfokusの備忘録です。

なお、2月3, 4日にはベルギーのブリュッセルでFOSDEMというカンファレンスも開催されています。FOSDEMとJfokusの両方とも参加される方も多いのですが、さくらばはJfokusだけ参加しました。

今回はあまり写真を撮っていないので、会場などの写真はほぼないです。すみません。


Jfokus

Jfokusはスウェーデンのストックホルムで2007年から開催されているJavaのカンファレンスです。VM Tech SummitというVMに特化したイベントも一緒に行っているなど、ちょっとデープなカンファレンスになっています。

しかし、コロナ後はVM Tech Summitが開催されておらず、普通の大規模なJavaカンファレンスになっていました。今年は、事前にはVM Tech Summitもあると言われていたのですが、結局なかったらしいです。かなり残念。

www.jfokus.se

参加者は1,000人ぐらい?上述したようにFOSDEMから参加している方も多いようです。

スウェーデンでの開催ですが、セッションは英語です。スウェーデンはTOEICの国別ランキングで常に上位にいる国なので、どこでも英語でOKのようです。


チケット

JfokusはDevoxxのようにチケットの争奪戦になることはないですが、売り切れることもあるので早めに取得するのがいいと思います。また、Devoxxとは異なり、クレジットカードに対応しています。


ストックホルムへ

Jfokusに参加することが決まったら、まずやることは交通手段と宿泊の確保です。


空路

ストックホルムはアーランダ空港とスカブスタ空港がありますが、ほとんどがアーランダ空港になると思います。ただし、ライアンエアーなどの航空会社はスカブスタ空港をしていますが、まぁ使うことはないと思います。

現状、アーランダ空港への直行便はないので、どこかしらで乗り継ぎを行う必要があります。

スウェーデンはシェンゲン協定加盟国なので、フランクフルトやパリから乗り継ぐ場合は乗り継ぐ場所で入国審査を受ける必要があります。このため、乗り継ぎには余裕をみてスケジュールしたほうがいいです。

イギリスなどシェンゲン協定に加盟していない国はスウェーデンで入国審査を受けます。

今回、さくらばはロンドン ヒースロー空港で乗り継ぎで、アーランダ空港着でした。


ストックホルム市内へ

アーランダ空港からストックホルム市内へは、鉄道のアーランダエクスプレスを使うのが便利です。

ターミナル5に直結したアーランダ北駅と、ターミナル2, 3, 4から利用できるアーランダ南駅とストックホルム中央駅を結ぶ鉄道で、だいたい40分ぐらいでストックホルム中央駅に着きます。

チケットは空港でも購入できますが、事前にオンラインで購入する方がいいと思います。オンラインで購入すると、QRコードが発行されるので、それを駅でスキャンすればOKです。

列車内で検察にくることもあります。

www.arlandaexpress.com


宿泊

Jfokusの会場はストックホルム中央駅に直結したStockholm Waterfront Congress Centreという場所で開催されます。

なので、中央駅近辺でホテルを探すのがいいと思います。

一番いいのは会場と同じ建物にあるRadisson Blu Waterfront Hotelですが、周りのホテルに比べると宿泊費は高めです。なお、すぐそばに同じ系列のRadisson Blue Royal Vikingというホテルもあるので、お間違えなく。

Radisson Blue Waterfront Hotelは新しい建物なのですが、中央駅の近辺は古い建物が多いのでホテルも古いところが多い感じです。ちょっと離れると新しいところもあるので、会場まで近いという利便性をとるか、施設のよさをとるかのどちらかですね。

今回、さくらばはFreys Hotelに宿泊しましたが、ここもかなり古い建物でした。


気候

北欧と聞いたらやっぱり寒いと思いますよね。

今年のJfokus会期中は一番寒い日で最低気温-10度、最高気温-3度ぐらいでした。

Jfokusの前の週がかなり暖かくて、前々週は最低気温が-20度になるぐらいの寒さだったようです。

したがって、行くとしたら、最低気温が-20度になっても耐えられるぐらいの服装で!今年もそこまで寒いというわけではなかったですが、日本からの参加者の1人が寒さで風邪をひいてしまっていました。せっかくカンファレンスに参加するのですから、体調を崩さないように、万全の体制で挑むようにしましょう。

前週が暖かったということから、今年は街中はほとんど雪は残ってませんでした。昨年は雪も残っていて、道がアイスバーン化しているところもあったらしいので、靴もそれ用に用意したほうがいいと思います。

ストックホルムは湖に面した街ですが、最低気温が-10度にもなると湖も運河もカチカチに凍りますね。


Jfokus会場

Jfokusの会場は前述したようにWaterfront Congress Centreです。ストックホルム中央駅からほぼ直結してます。

ただ駅の裏側なので、ちょっと分かりにくいかもしれません。

古い建物が並ぶストックホルムの中心街の中で、ここだけは妙にモダンな建物になっています。

運河に面している建物ですが、運河の向かい側は市庁舎です。私は知らなかったのですが、魔女の宅急便に出てくる時計台の尖塔は、この市庁舎がモデルらしいです。

入り口にはクロークがあるので、上着はここで預けられます。会場は暖かく、上着は邪魔になるだけなので、預けた方がいいと思います。


食事

Jfokusは、朝食とランチが提供されます。

朝食にはスウェーデン名物でもあるカネルブッレ(シナモンロール)やカルダモンマブッレ(カルダモンロール)、オープンサンドのスモーブローなどが出されます。

昼は日によって異なりますが、スウェーデン料理のプレート。けっこうおいしいです。

また、会期2日目の夜は展示会場でレセプションがあり、軽食がでます。ただ、おなかがいっぱいになるほどではないですね。

気をつけなくてはいけないのが、レストランが意外と早い時間にしまってしまうこと。行くところが決まっているのであれば、予約してからいくのがいいようです。

逆に早朝からやっているカフェやイートイン併設のパン屋さんは多いんですけどね。


後編では、さくらばがJfokusで聴講したセッションの中から面白かったものについて紹介します。

2024/02/02

なぜあなたはラムダ式が苦手と感じるのか

このエントリーをはてなブックマークに追加

今年もブリ会議で講演してきました。例年、Javaの新しい機能などについて話すことが多かったのですが、今年はProject Lambdaから10年ということもあり、あらためてラムダ式についての話です。

2部構成で前半が初心者向けの「なぜあなたはラムダ式を苦手と感じるのか」というラムダ式を使う立場での話。後半はぐっと難易度があがって、「ラムダ式はどうやって動くのか」という内容です。

このエントリーはブリ会議で話した内容の前半部分の解説です。資料はこちら。


ラムダ式が導入されたのは2014年、Java 8の時です。今年でちょうど10年ですね。

10年もたったのですから、ラムダ式を当たり前のように使っている開発者も多いとは思いますが、いまだに苦手と感じている方がいらっしゃるのも事実だと思います。

ラムダ式が導入後にJavaを使い始めた方でも苦手という方がいるんですよね。

ラムダ式は、Javaで関数型プログラミングの考え方が導入された端緒です。ラムダ式と一緒に導入されたStream APIをはじめ、今では関数型プログラミングに関する機能がJavaではどんどん増えています。

たとえば、言語仕様では

  • switch式
  • Record
  • Sealed Class
  • パターンマッチング

など。RecordとSealed Classは、両方を組み合わせて代数的データ型 (Algebraic Data Type, ADT) を構成するのに使われます。

標準のAPIだと、次のようなAPIがあります。

  • Stream API
  • Flow (Reactive Stream)
  • HTTP Client

一番使われているのはもちろんStream APIですね。HTTP Clientは、Java 11で導入されましたが、Flowを使用して宣言的にHTTPのクライアントを記述します。

標準API以外でも、Spring WebFluxや、Oracle Helidon、Red HataのRed Hat Quarkusなど関数型プログラミングの考えを使うライブラリやフレームワークも増えています。

つまり、これからはJavaを使っていても関数型プログラミングからは逃れられなくなっているのです。

関数型プログラミングの考えを取り入れたプログラミングスタイル、つまり今までの手続き的な記述から宣言的な記述に変えていかなくてはなりません。

さらにいうと、今までのJavaはどうしても文で考えがちなのですが、そうではなく式を使って記述することを意識していきたいのです。


ラムダ式とは

さて、そのラムダ式ですが、端的にいえば関数です。名前はないので、無名関数ということができます。

無名関数といっても、メソッドと同じようなものです。Java Language Specificationのラムダ式の説明の一番はじめには、次のような記述があります。

A lambda expression is like a method: it provides a list of formal parameters and a body - an expression or block - expressed in terms of those parameters. (JLS 15.27)

つまり、メソッドが書ければラムダ式は書けるはずなのです。

もちろん、ラムダ式は通常のメソッドとは異なり、クラスに属していなかったり、ラムダ式の外側のクラスの状態にアクセスすることも制限されていたりするので、メソッドと同じというわけではありません。

しかし、それは今までの手続き的な記述にとらわれてしまっているからではないでしょうか。


手続き的な記述の欠点

手続き的な記述には読みにくいポイントや、バグを発生しやすくしてしまうポイントがあります。

代表的なポイントを以下に示しました。


1点目の複数の処理を一緒に書けてしまうというのは、処理を十分に分解せずにまとめて書きがちだということです。

残りの2点は変数に関することです。手続き的な記述だと、どうしてもy処理の途中経過を保持するなどの変数が使われます。途中経過を保持させるので、ミュータブルになってしまうわけです。

また、途中経過を保持させて後に、最終的な結果を得るためには別のスコープに入ることがあり、スコープが広くなってしまいがちです。

言葉で書いても分かりにくいと思うので、具体的なコードで見てみましょう。

たとえば、A組の生徒の平均値を算出することを考えてみます。

生徒の成績は次のRecordで保持しているとします(通常のクラスでもいいのですが、こういうデータを保持させるにはイミュータブルなRecordの方が適しています)。

    record StudentScore(
            String name,
            String className,
            int score) {}

StudentScoreでは生徒の名前と、クラス、成績を保持させています。

平均値を求めるメソッドを次に示します。

    double calcAverage(List<StudentScore> scores) {
        double sum = 0.0;
        int count = 0;
 
        for (int i = 0; i < scores.size(); i++) {
            var ss = scores.get(i);
            if (ss.className().equals("A")) {
                sum += ss.score();
                count++;
            }
        }

        sum /= count;

        return sum;
    }

一見、よさげに見えますが、何が問題なのでしょう。

まず、ローカル変数のsumとcountです。この2つの変数はいずれもループでの中間値を保持させるためにミュータブルになっています。

また、ループが終わった後の最終的な結果を求めるために、ループの外側でも変数を使用します。このため、変数のスコープがメソッド全体になってしまっています。

ミュータブルだと意図しない値の変更がされる可能性があります。このメソッドは行数が少ないからよいですが、行数が多いメソッドを複数人で編集していたりすると意図しない変更が起こりがちです。

スコープが広い変数だとなおさらです。

たとえば、このメソッドでは最後にsumをcountで割って平均値を求めていますが、変数sumはループの中では合計値を保持させています。合計値を保持させる変数であるのに、最後に合計値ではない値を代入しているわけです。

平均値を代入した後に、他の人が合計値だと思って変数sumを使ってしまうとバグが発生してしまいます。


そして、ループです。

ループというか、繰り返し処理ってやっぱり分かりにくいと思うんですよ。

慣れてしまえばパターンとして覚えてしまうので、パッと書けるとは思いますが、初心者には難しい。

ここでのたった7行のループで、ループの制御、値の取り出し、比較、合計処理、カウンターのインクリメントまでやっています。特にループカウンターを使ったループの制御は本来の平均を求める処理とは別個のものなので、一緒にしてしまうと分かりにくくなってしまいます。

ここでは普通のfor文で書きましたが、for-each (拡張for文)で書いたとしても本質的な難しさは変わりません。

ループの制御はコンテナ側に任せ、ループで扱うデータに対し何を行っていくのかを分解して考えることで分かりやすさ、読みやすさは格段に向上します。

これが宣言的に記述するということにつながります。

for文という文ではなく、式で処理を連ねていくわけです。


宣言的な記述

先ほどの平均値を求めるメソッドを宣言的に記述したのが以下のコードです。

    double calcAverage(List<StudentScore> scores) {
        final var ave = scores.stream()
                              .filter(ss -> ss.className().equals("A"))
                              .collect(Collectors.averagingDouble(ss -> ss.score()));
        
        return ave;
    }

この記述には、return以外は文が使われておらず、式で処理を記述しています。

そして、ローカル変数のaveはイミュータブルな変数になります。

Stream APIを使ったループはいわゆる内部イテレータになり、ループの制御はStream APIが行います。Stream APIを使う側は、データをどのように処理するかだけに集中し、それをラムダ式で記述します。

行っている処理自体は手続き的に記述しても、宣言的に記述しても同じです。

しかし、Stream APIを使うことで、条件、値の取り出しなどの処理をそれぞれ1つのラムダ式で記述することで、処理の流れが分かりやすくなります。

とはいっても、今まで手続き的な記述しかしてこなかった方には、宣言的な記述はとっつきにくい感じを受けてしまうのはしかたないと思います。

だからといって、今までの手続き的な記述に固執していたら、どんどん増えている宣言的スタイルのライブラリやフレームワークを使えなくなってしまいます。

いつかは宣言的な考え方をしなくてはいけないのであれば、今がそのチャンスです。


重要なのはシグネチャー

前述したように、ラムダ式は関数として扱うのが自然です。

しかし、関数型インタフェースがとかを考え始めてしまうと、なかなかとっつきにくくなります。

Javaにも関数型があればこんなことに悩む必要はないのですが、残念ながらJavaでは関数型はありません。しかたないので、関数型インタフェースなんてものを持ち出したわけです。

しかし、ラムダ式を関数と考えるのであれば、型はそれほど重要ではありません。

重要なのはシグネチャーです。つまり

  • 引数の個数と、その型
  • 戻り値の有無と、その型

が重要になります。引数や戻り値の型はジェネリクスの型パラメータで定義されるので、引数の個数や戻り値の有無から考えましょうということです。

たとえば、数値を保持しているリストをソートするには以下のように記述します(もっと簡単に書けますけど、説明のためこう書いてます)。

    List<Integer> nums = ...;

    var sortedNums = nums.stream()
                         .sorted((x1, x2) -> x1 - x2)
                         .toList();

sortedメソッドの引数のラムダ式で要素同士を比較して、ソートの並び順を決めています。

このラムダ式はComparator<S>インタフェースなのですが、実際にはBiFunction<S, S,  Integer>インタフェースだとしても、処理はまったく同じです。

つまり、sortedメソッドの引数にするラムダ式では2つの引数が渡されて、戻り値としてintで戻すということが重要になるわけです。


java.util.functionパッケージのインタフェース

ラムダ式は関数で、重要なのはシグネチャということですが、ではFunctionインタフェースなどのjava.util.functionパッケージで提供されている関数型インタフェースはどのように考えればよいのでしょう。

インタフェース自体の用途などは気にせずに、シグネチャーを区別するためのものと割り切ると理解しやすいです。

つまり、シグネチャーが

  • 引数なし、戻り値ありならば Supplier<T>
  • 引数が1つ、戻り値ありならば Function<T, R>
  • 引数が1つ、戻り値がbooleanならば Predicate<T>
  • 引数が1つ、戻り値はなしならば Consumer<T>

のように考えるわけです。

たとえば、Streamインタフェースのmapメソッドの引数はFunctionインタフェースですが、その場合は引数が1つで、戻り値ありのラムダ式を書けばいいのだなと分かるわけです。


ちなみに、ラムダ式を関数型インタフェースの匿名クラスの延長として考えてしまうのは危険です。それだと、いつまでたっても手続き的な考え方に執着してしまいます(実際に動作としても匿名クラスとラムダ式はまったく異なるのですが、それはブリ会議の後半のエントリーで説明します)。

そんな変なことを考えずに、ラムダ式は関数として考えましょう。そして、ラムダ式を書く時にはシグネチャーから処理を記述していきましょう。


ラムダ式を書く時のTips

ここまでラムダ式は関数として扱いましょうということを説明してきたわけですが、では実際にラムダ式を書く時にどうすればよいでしょう。

ラムダ式は単独で使うということはほぼなく、ほとんどがライブラリやフレームワークのメソッドの引数として使います。

ということはラムダ式を単体で考えるのではなく、ライブラリやフレームワークと合わせて一緒に考えればよいということです。

ちなみに、メソッドの引数や戻り値に関数を使用することを高階関数と呼びます。名前はどうでもいいのですが、メソッドの引数にラムダ式というのがラムダ式の主流の使い方ということです。

Project LambdaのスペックリードのBrian GoetzはJava Magazineのインタビューで次のように語っています。

Project Lambdaは単なる言語機能ではなく、ライブラリも対象としています。言語機能とライブラリが一体となって、Javaプログラミング・モデルを大幅にアップグレードします。 (Project Lambdaの展望, Java Magazine Oct. 2012)

ラムダ式を策定したProject Lambdaではラムダ式とStream APIを合わせて策定しています。

このようにラムダ式単独ではなく、ライブラリやフレームワークと一緒に使うことを前提にラムダ式を考えていきましょう。


次の処理は1つだけというのは、ラムダ式はなるべくシンプルにしましょうということです。ラムダ式のボディにいろいろ処理を書いてしまうというのは、処理を正しく分解できていないということにつながり、どうしてもラムダ式のボディが手続き的になってしまいます。

処理を分解して、1つのラムダ式には単純な処理を記述し、それを連ねていくというスタイルに変えていきましょう。


3つめはラムダ式で扱うデータをなるべく引数だけにするということです。

ラムダ式は、ラムダ式が定義されている外側のクラスのフィールドなどにもアクセスできますが、それは可読性の低下につながります。もし、外部のデータにアクセスするのであれば、定数などに限定しましょう。


4つめの処理結果を戻り値以外で戻さないというのは、3つめの外部のデータにアクセスしないにも通じます。関数なのですから、結果は戻り値だけです。


最後はラムダ式だけでなく、宣言的な記述全般にいえることですが、変数はイミュータブルにしましょう。


まとめ

さて、前半のまとめです。

なんども書いていますが、ラムダ式は関数としてあつかい、宣言的な記述を行うために役立てるのがお勧めです。

匿名クラスの延長として考えてしまうと、今までの手続き的な考えの枠内にとどまってしまい、宣言的な記述を書くことの妨げになってしまいます。

Javaは今までの手続き的な記述から、宣言的な記述にプログラミングスタイルが変わってきています。今後もこの流れは変わりません。

ですから、手続き的な考えでラムダ式を理解しようとすることはやめましょう。

宣言的な記述、文から式への移行を役立てるためにラムダ式が存在するのです。


最後に参考文献にあげた「なっとく! 関数型プログラミング」を紹介しておきます。

この本はJavaからScalaへの移行を解説した書籍です。

しかし、手続き的なJavaから、宣言的なJavaへ移行するにも役立つはずです。

トピックはだいたい1ページで収まっており、読みやすいのもいいです。

ただ、Kindleだと画像の解像度が低くて、ちょっと読みにくいのが欠点です。翔泳社さん、どうにかしてくれないですかねぇ。

www.amazon.co.jp


さて、ブリ会議で後半に解説したラムダ式がどのように動作するのかについては、次エントリーで紹介する予定です。