2025/02/12

バイトコード入門 その2 スタックマシン

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

前回からはじめたバイトコード入門。なかなかバイトコードに入れなくても申し訳ないのですが、今回はスタックマシンについて紹介します。

  1. 準備編
  2. スタックマシン (今回)
  3. バイトコード処理の構成
  4. バイトコード処理の基礎
  5. オブジェクト生成、メソッドコール
  6. 制御構造

 

スタックマシン

スタックマシンというのは計算モデルの1つです。

スタックマシンと、よく引き合いに出される計算モデルにレジスタマシンがあります。

この2つの計算モデルはメモリーの使い方にあります。

  • スタックマシン: メモリーをスタックとする計算モデル
  • レジスタマシン: メモリーをレジスタとする計算モデル

純粋なスタックマシンはスタックだけで構成しますが、通常はランダムアクセスができるメモリーと組み合わせで使われることが多いです。これはレジスタマシンでも同様で、通常はレジスタ以外にメモリーを使用します。

スタックマシンにはJVMの他にも.NET Frameworkでも使用されています。

一方、既存のほとんどのCPUがレジスターマシンになります。

たとえば、レジスターマシンで加算を行う場合、aレジスタの値とbレジスタの値を加算して結果をaレジスタに格納するというような命令になります(add a, bのような感じです)。

命令の対象が明確に記述されているので、分かりやすいはずです(とはいうものの、機械語でプログラムを書けと言われてもイヤですけど😰)

では、スタックマシンはどのように動作するのでしょう?

ここでは、単純な例としてHPの電卓でスタックマシンの動作を説明していきます。

 

HPの電卓

HPというと、紆余曲折あってPCやプリンターのHP Inc.とサーバーのHPEになっていますが、かつては計測機器を扱う会社でした。そんなHPが1970年代から2000年代にかけて電卓を作っていたのでした。

当時は、関数電卓やプログラム電卓といったらHPという感じで、そこそこ使われていました。プログラム電卓というのは、その名の通りプログラムが組める電卓です。

このHPの電卓は、なんといっても入力方式が独特でした。

たとえば、1+2を計算する場合、通常の電卓であれば 1 + 2 = と入力しますね。これに対し、HPの電卓は 1 [Enter] 2 [Enter] + と入力しました([Enter]というボタンがあったのです)。

[Enter]を省略して記述すると 1 + 2 は 1 2 + になるということです。この数式の書き方は逆ポーランド記法と呼ばれています。

 

逆ポーランド記法

ちょっと脱線気味ですが、逆ポーランド記法についても説明しておきましょう。

「逆」とついていることから分かるかもしれませんが、「逆」ではないポーランド記法もあります。というか、こちらが先ですね。

ポーランド記法はJan Łukasiewicz (ヤン・ウカシェヴィチ)が発案した数式の記法です。

演算子を先に記述することから前置記法とも呼ばれます。

私たちが通常使用している 1 + 1 のような記法は演算子が数値の間に記述されるので中置記法と呼びます。逆ポーランド記法は演算子が最後なので、後置記法になります。

 

逆ポーランド記法の利点はカッコを使用せずに数式を記述できるところです。

たとえば、以下の数式はどうでしょう。

(2 + 3) × 5 + (4 - 2) ÷ 2

これを逆ポーランド記法で記述すると以下のようになります。

2 3 + 5 × 4 2 - 2 ÷ +

カッコがないというのは、電卓でメモリ機能(M+やM-、MRCなど)を使わなくても計算ができるということで、入力が簡単になります。まぁ、逆ポーランド記法で考えなければいけないというハードルはありますけど。

そして、もう1つの利点が、逆ポーランド記法はスタックを使えば簡単に実装できるということです。

 

スタックを使用した逆ポーランド記法の計算

では、逆ポーランド記法の数式をスタックを使用して計算してみましょう。

ルールは簡単です。

  1. 数値であれば(HPの電卓では、[Enter]が入力されたら)その値をスタックに積む
  2. 演算子であれば、演算に必要な個数のデータをスタックから取り出し、計算結果を再びスタックに積む

では、1 + 2をやってみましょう。

 

電卓であれば、スタックの先頭を表示していれば計算結果が表示されます。

複雑な数式でも計算の途中結果がスタックに保持されているので、スタックとは別のメモリーを使用しなくても計算が実行できます。

 

電卓では計算だけですが、一般のスタックマシンでも同様に必要なデータをスタックに置き、処理の結果を再びスタックに置くという過程で処理が進みます。

これはJVMでも同様です。

では、次回は実際にJVMでどのようにスタックマシンが構成されているのか紹介する予定です。

2025/02/10

バイトコード入門 その1 準備編

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

先月のJJUGナイトセミナーで、Class-File APIとその前提知識となるバイトコードについてプレゼンしてきました。資料はこちら。

 

おかげさまで、コロナ後のオフラインに戻してから初のキャンセル待ちとなるぐらい盛況でした。もっともバイトコードの話よりも、増田さんのアーキテクチャーの話の方が期待されていたとは思いますけど。

 

まぁ、それはそれとして、バイトコードがどのように実行されるかというところから、Class-File APIまでを50分で説明するのは分量的になかなか難しく、特にClass-File APIの方はかなりはしょった説明になってしまいました。

そういえば、今までJava in the Boxでもバイトコードに関して触れたことがなかったので、いい機会ですし、バイトコードって何というところから説明していきたいと思います。

  1. 準備編 (今回)
  2. スタックマシン
  3. バイトコード処理の構成
  4. バイトコード処理の基礎
  5. オブジェクト生成、メソッドコール
  6. 制御構造

 

バイトコードって何?

Javaでシステムを作っているだけであれば、バイトコードに触れる機会はまずないはずです。

バイトコードというのはJava特有の言葉ということではなく、他にも使われる言葉です。

一般的には仮想マシンなどの実行環境が解釈するための中間表現です。人間が読むことを想定しておらず、バイナリーで記述されるため、バイトコードと呼ばれるようです。

Javaの場合、バイトコードは当然ながらJVMが解釈するために使用されます。

バイトコードは約200種類のJVMに対する命令(オペコードもしくはインストラクションと呼ばれます)からなっています。もちろん、バイナリーなので16進数表記で表されますが、さすがにそれでは読みにくいのでloadやstoreなど命令に1対1に対応づいた表記で表されることが多いです。

機械語に対しうるアセンブラのようなものですね。

具体的な命令などは次回以降に説明します。

 

バイトコードはどこに記述される?

Javaのソースコードをjavacでコンパイルすると、クラスファイルが生成されます。このクラスファイルにバイトコードが含まれています。

なお、クラスファイルにはバイトコード以外にも実行に必要な情報が含まれています。

バイトコードの定義や、クラスファイルのフォーマット定義などはJava Virtual Machine Specification (JVS)に記載されています。

Java言語の仕様であるJava Language Specification (JLS)は、Javaのバージョンごとに改定されていますが、JVMSも同じくバージョンごとに改定されます。

最新版のJava 23でのJVMSは以下のURLで参照できます。

クラスファイルのフォーマットは4章、バイトコードは6章に記載されています。

クラスファイルには大まかにいうと、以下の4つの情報が記録されています。

  1. クラスファイルの情報
  2. クラスの情報
  3. コンスタントプール
  4. アトリビュート

クラスファイルの情報にはクラスファイルのバージョンなどが含まれます。Java言語にもバージョンがありますが、クラスファイルにもバージョンがあるのです。

たとえば、Java 23のjavacでコンパイルされたクラスファイルのバージョンは67になります。

クラスの情報は、クラス名やスーパークラス、実装しているインタフェースなどです。コンスタントプールはクラスで使用される様々な定数を定義していあります。

最後のアトリビュートは様々な情報を記載することができ、バイトコードもそのうちの1つになります。

アトリビュートにはバイトコード以外にフィールド定義やメソッド定義などが記載されています。

 

javapコマンド

前述したようにバイトコードはバイナリで表されます。同様にクラスファイルも人が読むことを想定していないため、バイナリファイルです。

とはいうものの、クラスファイルに何が記述されているのか確認したいこともありますよね。

こういう時に使用するのが、JDKに含まれているjavapコマンドです。

javapコマンドはいろいろな情報が出せるので、さっそく試してみましょう。

サンプルに使うのはおなじみのHello, World!です。

public class Hello {
    static final String HELLO = "Hello, World!";

    private void sayHello() {
        System.out.println(HELLO);
    }
    
    public static void main(String... args) {
        new Hello().sayHello();
    }
}

 

まず、javacでコンパイルしてから、javapを実行します。javapの引数はクラス名もしくはクラスファイル名です。

> javac Hello.java

> javap Hello
Compiled from "Hello.java"
public class Hello {
  static final java.lang.String HELLO;
  public Hello();
  public static void main(java.lang.String...);
}

>

 

何もオプションを指定せずにjavapを実行すると、クラスの情報と宣言されているフィールド、メソッドの情報が出力されます。

しかし、何か抜けているような気がしませんか。

そう、プライベートメソッドのsayHelloメソッドが抜けているのです。

javapはデフォルトでは、パッケージプライベートで宣言されたフィールド、メソッドしか出力しません。プライベートで宣言されたフィールド、メソッドを出力するには、オプションの-privateもしくは-pを指定します。

> javap -p Hello
Compiled from "Hello.java"
public class Hello {
  static final java.lang.String HELLO;
  public Hello();
  private void sayHello();    
  public static void main(java.lang.String...);
}

>

 

この出力結果を見ると、Helloクラスはパブリッククラスで、フィールドはパッケージプライベートでstatic finalのHELLO、メソッドはデフォルトコンストラクター、sayHelloメソッド、そしてstaticメソッドのmainが宣言されていることが分かります。

元のHello.javaにはデフォルトコンストラクターは記述されていませんが、コンパイル時に自動生成されます。

 

バイトコードの解析: -cオプション

javapコマンドでバイトコードを出力するには、-cオプションを使用します。

> javap -p -c Hello
Compiled from "Hello.java"
public class Hello {
  static final java.lang.String HELLO;

  public Hello();
    Code:
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return

  private void sayHello();
    Code:
         0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #15                 // String Hello, World!
         5: invokevirtual #17                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return

  public static void main(java.lang.String...);
    Code:
         0: new           #13                 // class Hello
         3: dup
         4: invokespecial #23                 // Method "<init>":()V
         7: invokevirtual #24                 // Method sayHello:()V
        10: return
}

>

 

この場合でも、-pオプションを使用しないとプライベートフィールド、メソッドは出力されないので、忘れないようにしましょう。

バイトコードはメソッド定義の後のCode:の次の行から始まります。aload_0やinvokespecialなどがバイトコードの命令です。

invokespecial #1のように、#と数字で表示されているのはコンスタントプールを指しています。#1はコンスタントプールのインデックス1の定数です。具体的にはコメントで示されているように、Objectクラスのデフォルトコンストラクタへのメソッド参照です。

なお、このコメントはjavapが解析結果を付記したもので、クラスファイルに記載されているわけではありません。

このコメントがあると、バイトコードやコンスタントプールを読む手間がかなり省けるはずです。

 

全部出力: -verbose/-vオプション

バイトコードだけでなく、コンスタントプールの値や、その他のアトリビュート、フラグなどの情報を出力するには-verboseもしくは-vオプションを使用します。

ちょっと長いですが、Helloクラスを-vでの出力を以下に示します。

 

> javap -p -v Hello
Classfile /temp/Hello.class
  Last modified 2025/02/09; size 557 bytes
  SHA-256 checksum 5b4893687f4d64e1e6a659d495f8febf0680a2a3d8a036c2e40c5057d2d0c5d1
  Compiled from "Hello.java"
public class Hello
  minor version: 0
  major version: 68
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #13                         // Hello
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 3, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // java/lang/System.out:Ljava/io/PrintStream;
   #8 = Class              #10            // java/lang/System
   #9 = NameAndType        #11:#12        // out:Ljava/io/PrintStream;
  #10 = Utf8               java/lang/System
  #11 = Utf8               out
  #12 = Utf8               Ljava/io/PrintStream;
  #13 = Class              #14            // Hello
  #14 = Utf8               Hello
  #15 = String             #16            // Hello, World!
  #16 = Utf8               Hello, World!
  #17 = Methodref          #18.#19        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #18 = Class              #20            // java/io/PrintStream
  #19 = NameAndType        #21:#22        // println:(Ljava/lang/String;)V
  #20 = Utf8               java/io/PrintStream
  #21 = Utf8               println
  #22 = Utf8               (Ljava/lang/String;)V
  #23 = Methodref          #13.#3         // Hello."<init>":()V
  #24 = Methodref          #13.#25        // Hello.sayHello:()V
  #25 = NameAndType        #26:#6         // sayHello:()V
  #26 = Utf8               sayHello
  #27 = Utf8               HELLO
  #28 = Utf8               Ljava/lang/String;
  #29 = Utf8               ConstantValue
  #30 = Utf8               Code
  #31 = Utf8               LineNumberTable
  #32 = Utf8               main
  #33 = Utf8               ([Ljava/lang/String;)V
  #34 = Utf8               SourceFile
  #35 = Utf8               Hello.java
{
  static final java.lang.String HELLO;
    descriptor: Ljava/lang/String;
    flags: (0x0018) ACC_STATIC, ACC_FINAL
    ConstantValue: String Hello, World!

  public Hello();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  private void sayHello();
    descriptor: ()V
    flags: (0x0002) ACC_PRIVATE
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #15                 // String Hello, World!
         5: invokevirtual #17                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8

  public static void main(java.lang.String...);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0089) ACC_PUBLIC, ACC_STATIC, ACC_VARARGS
    Code:
      stack=2, locals=1, args_size=1
         0: new           #13                 // class Hello
         3: dup
         4: invokespecial #23                 // Method "<init>":()V
         7: invokevirtual #24                 // Method sayHello:()V
        10: return
      LineNumberTable:
        line 9: 0
        line 10: 10
}
SourceFile: "Hello.java"

>

 

Constant pool:で始まる次の行からがコンスタントプールです。

先ほど-cオプションで出力したバイトコードで#1が参照されていましたが、このコンスタントプールの表で見てみましょう。

#1の行にはメソッドの参照を示すMethodrefに続いて#2.#3と記載されています。

#2のClassはクラスの参照を示しており、その名前は#4に記載されいます。

#4の後のUtf8は文字列を表しています。#4で定義されている文字列定数はjava/lang/Object、つまり#2で参照していたクラス名です。

では、#1で参照しているもう1つの#3を見てみましょう。

#3はNameAndType、つまりメソッド名と型(ここではクラス名ではなく、メソッドのシグネチャーです)を示しており、#5と#6を参照しています。

#5は文字列定数で<init>、これはコンストラクタを表しています。#6も文字列定数で()Vです。これはメソッドのシグネチャーが引数なし、戻り値がvoidであることを示しています。

このように、コンスタントプールは参照、参照となっていますが、各々の行にコメントが追記されているので、これを見れば参照を追わずとも分かるはずです。

 

フィールドやクラスも追加の情報が記載されていることが分かると思いますが、これらに関してはバイトコードの詳細説明の時に触れる予定です。

 

準備編まとめ

  • バイトコードはJVMが解釈するための中間表記
  • クラスファイルにはバイトコード以外に実行に必要な情報が記載される
  • クラスファイルの解析コマンド javap
  • javapでプライベートを含めて出力: -pオプション
  • javapでバイトコードを出力: -cオプション
  • javapで全部出力: -vオプション

 

次回は、バイトコードの詳説に移りたいところですが、その前の事前知識としてスタックマシンについて紹介する予定です。

2025/01/09

Stream Gatherer 動作編

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

年が変わってしまいましたが、前回の続き。前回はこちら

全開でGathererの使い方を一通り説明したので、今回はGathererがどのように動作しているのかを解説していきます。

 

Stream APIの動作

Gathererがどのように動作するのかを説明する前に、もともとのStream APIがどのように動作しているのか復習しておきましょう。

といっても、詳しく説明すると長くなってしまうので、簡単に。

ここではIntStreamインタフェースなどのプリミティブ系は省略、またシーケンシャルな動作に限定して説明します。

Streamの実行に重要なインタフェース

Stream APIの動作を説明する前に、Stream APIを使っているだけであれば出てこない、でも実装では重要なインタフェースを2つだけ先に紹介しておきます。

  • java.util.Spliterator
  • java.util.stream.Sink

Spliteratorインタフェースは公開インタフェースで、Sinkインタフェースはパッケージプライベートなインタフェースです。

1つ目のSpliteratorインタフェースはSplit + Iteratorのことで、簡単にいえばストリームのイテレーションを制御するインタフェースになります。

Splitがパラレルの場合で、分割統治法により個々の要素を処理します。Iteratorがシーケンシャルの場合で、こちらは普通のイテレーターですね。

Spliteratorオブジェクトはソースによって実装クラスが異なり、ソースからStreamオブジェクトを生成する時に一緒に作られます。

もう一方のSinkは、台所にあるシンクと同じ単語です。動詞だと「沈む」もしくは「沈める」という意味です。

何が沈んでいるかというと操作です。中間操作や終端操作で行われる操作がSinkに沈められています。

SinkインタフェースはConsumerインタフェースのサブインタフェースで、acceptメソッドがコールされると、その操作が実行されます。

 

Sinkオブジェクトで操作を実行する仕組み

では、コードを使って説明していきましょう。ここでは、次のコードで説明していきます。

    var stream = Stream.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
    var stream2 = stream.map(i -> Integer.toString(i));
    var result = stream2.reduce("", (prev, pres) -> prev + pres);

 

Integerのリストを文字列化して、文字列の連結を行うコードです。

さて、ここで変数stream2の型はどうなるでしょう?

もちろん、Streamインタフェースではありますが、Streamインタフェースを実装したコンクリートクラスの方です。

答えはStatelessOpクラスの匿名クラスです。

このStatelessOpクラスが、mapメソッドやfilterメソッドなど状態を持たない中間操作で使用されるクラスです。

StatelessOpクラスのスーパークラスがReferencePipelineクラスで、ReferencePipelineクラスがStreamインタフェースを実装しています。

Referenceなのはストリームを流れる要素が参照型だからで、intであればIntPipelineクラスになります。

それはそれとして、ここで使われているStatelessOpクラスの匿名クラスを生成している部分が次のコードになります。ここでは、mapメソッドの場合です。

    public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
        Objects.requireNonNull(mapper);
        return new StatelessOp<>(this, StreamShape.REFERENCE,
                StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
            @Override
            Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
                return new Sink.ChainedReference<>(sink) {
                    @Override
                    public void accept(P_OUT u) {
                        downstream.accept(mapper.apply(u));
                    }
                };
            }
        };
    }

 

StatelessOpクラスの匿名クラスを作る時に、opWrapSinkメソッドをオーバーライドしています。ここで、Sinkが出てきましたね。

opWrapSinkメソッドの中ではSink.ChainedReferenceクラスの匿名クラス生成しています。もちろん、Sink.ChainedReferenceクラスはSinkインタフェースの実装クラスです。

Sink.ChainedReferenceクラスはその名の通りSinkをチェーンでつなげていくインタフェースです。フィールドに下流のSinkオブジェクトであるdownstreamを保持しています。

Gathererでもdownstreamが出てきましたが、ここでは次段のSinkオブジェクトを表しています。

そして、赤字で示したacceptメソッドで、mapメソッドの引数で指定された関数(Functionインタフェースのラムダ式)を実行し、その結果を引数にして下流のSinkオブジェクトのacceptメソッドをコールしています。

これで、中間操作の操作を順々に実行する仕組みができました。

 

終端操作のSinkオブジェクトを生成する仕組み

実際に中間操作から終端操作まで操作をつなげていくのは、終端操作の時です。

まずは終端操作に対応するSinkオブジェクトを作るしくみです。これはreduceメソッドの内部でコールされるReduceOps.makeRefメソッドで行われます。

    public final <R> R reduce(R identity, BiFunction<R, ? super P_OUT, R> accumulator, BinaryOperator<R> combiner) {
        return evaluate(ReduceOps.makeRef(identity, accumulator, combiner));
    }

 

ReduceOpsクラスは、ReduceOpクラスのユーティリティクラスですね。中間操作で使用したStatelessOpの終端操作版がReduceOpクラスになります。

おもしろいことに、ReduceOpクラスはReduceOpsクラスのインナークラスになっています。

さて、そのmakeRefメソッドは以下のようになっています。

    public static <T, U> TerminalOp<T, U>
    makeRef(U seed, BiFunction<U, ? super T, U> reducer, BinaryOperator<U> combiner) {
        Objects.requireNonNull(reducer);
        Objects.requireNonNull(combiner);
    
        class ReducingSink extends Box<U> implements AccumulatingSink<T, U, ReducingSink> {
            @Override
            public void begin(long size) {
                state = seed;
            }

            @Override
            public void accept(T t) {
                state = reducer.apply(state, t);
            }

            @Override
            public void combine(ReducingSink other) {
                state = combiner.apply(state, other.state);
            }
        }
    
        return new ReduceOp<T, U, ReducingSink>(StreamShape.REFERENCE) {
            @Override
            public ReducingSink makeSink() {
                return new ReducingSink();
            }
        };
    }

 

makeRefメソッドの内部で、青字で示したReducingSinkクラスを定義しています。ここでSinkが出てきました。

このReducingSinkクラスが終端操作に対応するSinkです。

ただし、ReducingSinkクラスは中間操作で使用したSinkとはちょっと異なります。

それはReducingSinkクラスがBoxクラスのサブクラスだということです。Boxクラスは値を1つだけ保持するコンテナクラスです。

Gathererと同じで、終端操作は状態を持ち、最終的に処理の結果を返します。その状態を保持するためにBoxクラスを使用しています。

そして、makeRefメソッドの戻り値としてReduceOpクラスの匿名クラスを生成し、その内部でmakeSinkメソッドをオーバーライドし、ここで定義したReducingSinkオブジェクトを返すようにしています。

makeSinkメソッドは定義しましたが、ここでコールされるわけではありません。したがって、まだSinkオブジェクトは生成されていません。

 

中間操作から終端操作までのSinkオブジェクトを連ねる

ここまでで、中間操作と終端操作のSinkオブジェクトを生成する仕組みを見てきました。後は、中間操作から終端操作にいたるSinkオブジェクトを生成し、一連の処理をつなげる必要があります。

これは、reduceメソッドの内部でコールされているevaluateメソッドで行われます。

evaluateメソッド内では処理がシーケンシャルかパラレルかによって処理が分かれますが、ここではシーケンシャルに処理するevaluateSequentialメソッドを見ていきます。

次のコードはReduceOpクラスのevaluateSequentialメソッドです。

    public <P_IN> R evaluateSequential(PipelineHelper<T> helper,
                                       Spliterator<P_IN> spliterator) {
        return helper.wrapAndCopyInto(makeSink(), spliterator).get();
    }

 

ここで、赤字で示したmakeSinkメソッドが出てきました。

makeRefメソッドの中で、ReduceOpクラスの匿名クラスを定義し、makeSinkをオーバーライドしていましたが、そのmakeSinkメソッドをコールするのがevaluateSequentialメソッドの中でした。

これで、終端操作に対応するSinkオブジェクトが生成できました。

一方のSpliteratorオブジェクトも出てきました(変数spliterator)。Spliteretorオブジェクトの生成は、ソースからStreamオブジェクトを作成する時に作られます。ここでは、省略しますが、もし興味があれば、ソースからStreamオブジェクトを生成する部分を見てみるのもおもしろいと思います。

さて、evaluateSequentialメソッドの第1引数のhelperは、実はReduceOpオブジェクト自身です。

継承関係をさかのぼっていくと、ReferencePipelineクラスのスーパークラスがAbstractPipelineクラスで、さらにそのスーパークラスがPipelineHelperクラスになります。

evaluateSequentialメソッドの内部でコールされているwrapAndCopyIntoメソッドがPipelineHelperクラスで定義されているためこうなっているとは思いますが、ちょっと分かりにくいですね。

PipelineHelperクラスのwrapAndCopyIntoメソッドはabstractとして定義されており、AbstractPipelineクラスでオーバーライドされています。

    final <P_IN, S extends Sink<E_OUT>> S wrapAndCopyInto(S sink, Spliterator<P_IN> spliterator) {
        copyInto(wrapSink(Objects.requireNonNull(sink)), spliterator);
        return sink;
    }

 

赤字で示したwrapSinkメソッドがSinkオブジェクトを連ねる処理を行いそうなことが分かります。

中間操作のmapメソッドの中でStatelessOpeクラスの匿名クラスがopWrapSinkメソッドをオーバーライドしていたのを思い出してください。

では、そのwrapSinkメソッドです。

    final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
        Objects.requireNonNull(sink);

        for ( @SuppressWarnings("rawtypes") AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
            sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
        }
        return (Sink<P_IN>) sink;
    }

 

for文で自分自身(終端操作のReduceOpオブジェクト)からパイプラインをさかのぼって、opWrapSinkメソッドをコールしています。

opWrapSinkメソッドの内部ではSinkオブジェクトを生成しているので、これで終端操作から中間操作の先頭までのSinkオブジェクトを生成して、チェーンでつなげていくことができました。

残るは、ここで生成したSinkオブジェクトに対して、登録されている処理を行う部分です。

 

Spliteratorを使用したイテレーション

やっと最後のイテレーションの部分にまで到達しました。

先ほどのwrapAndCopyIntoメソッドでwrapSinkメソッドの戻り値(Sinkオブジェクト)とSpliteratorオブジェクトを引数にしてコールされるのが、copyIntoメソッドです。

copyIntoメソッドもAbstractPipelineクラスでオーバーライドされています。

    final <P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator) {
        Objects.requireNonNull(wrappedSink);

        if (!StreamOpFlag.SHORT_CIRCUIT.isKnown(getStreamAndOpFlags())) {
            wrappedSink.begin(spliterator.getExactSizeIfKnown());
            spliterator.forEachRemaining(wrappedSink);
            wrappedSink.end();
        }
        else {
            copyIntoWithCancel(wrappedSink, spliterator);
        }
    }

 

if文でShort Circuitの有無で処理を変えています。

前回、Short Circuitが出てきましたが、イテレーションの途中で停止させるのがShort Circuitです。

Short Circuitの可能性がある場合、イテレーションを継続するか停止するかチェックする必要があるため別メソッド(copyIntoWithCancelメソッド)になっています。

イテレーションの本体は赤字で示したforEachRemainingメソッドです。

Gatherer.IntegratorインタフェースのファクトリーメソッドであるofGreedyメソッドを使った場合も、Short CircuitされることはないのでforEachRemainingメソッドが使用されます。

 

forEachRemainingメソッドを定義しているSpliteratorインタフェースの実装クラスはソースによって異なります。ArrayListクラスや配列の場合、ArraySpliteratorクラスが使われます。

サンプルのコードはStream.ofメソッドでStreamオブジェクトを生成していますが、この場合もArraySpliteratorクラスが使われます。

ArraySpliteratorクラスのforEachRemainingメソッドを次に示します。

    public void forEachRemaining(Consumer<? super T> action) {
        Object[] a; int i, hi; // hoist accesses and checks from loop
        if (action == null)
            throw new NullPointerException();

        if ((a = array).length >= (hi = fence) &&
            (i = index) >= 0 && i > (index = hi)) {
            do { action.accept((T)a[i]); } while (++i < hi);
        }
    }

 

forEachRemainingメソッドの引数の型がConsumerインタフェースになっていますが、SinkインタフェースはConsumerインタフェースのサブインタフェースなので、実態はSinkインタフェースです。

メソッド内では、配列の範囲チェックの後に、do-while文でループします。

このループで、配列の要素を引数にして変数actionのacceptメソッドをコールしています。

この変数actionは最初の中間操作を保持しているSinkオブジェクトなので、中間操作から終端操作まで順々に実行されます。

 

簡単に紹介するつもりでしたが、かなり長くなってしまいました。

 

Stream Gathererの動作

やっとGathererです。

ここまでのStream APIの動作が理解できていれば、Gathererを理解するのも簡単です。

GathererのPipelineとSink

mapやfilterなど状態を持たない中間操作のパイプラインにはStatelessOpクラスが使われてきました。また、SinkにはSink.ChainedReferenceクラスが使われています。

これに対し、Gathererでは専用のパイプラインクラスであるGathererOpクラスが使用されます。また、SinkもGatherSinkクラスが使われます。

これをコードで確かめてみましょう。以下のコードはReferencePipelineクラスのgatherメソッドです。

    public final <R> Stream<R> gather(Gatherer<? super P_OUT, ?, R> gatherer) {
        return GathererOp.of(this, gatherer);
    }

 

ofメソッドがファクトリーメソッドになっており、GathererOpオブジェクトを返しています。

GathererOpクラスのopメソッドはパイプラインの前段がGathererOpクラスであれば、合成する処理が含まれていますが、基本的にはGathererOpクラスのオブジェクトを生成しているだけです。

もう一方のSinkの方ですが、mapメソッドなどではStatelessOpクラスのopWrapSinkメソッドをオーバーライドしていたのを思い出してください。

GathererOpクラスはGatherer専用のクラスなので、opWrapSinkメソッドもオーバーライドされずに、そのまま使われます。

以下にGathererOpクラスのopWrapSinkメソッドを示します。

    Sink<T> opWrapSink(int flags, Sink<R> downstream) {
        return new GatherSink<>(gatherer, downstream);
    }

 

ここでGatherSinkクラスが出てきました。

GatherSinkクラスもGatherer専用なので、匿名クラスなどを使わずに、そのまま使われます。

では、GatherSinkクラスの定義とコンストラクターを見ておきましょう。

    static final class GatherSink<T, A, R> implements Sink<T>, Gatherer.Downstream<R> {
        private final Sink<R> sink;
        private final Gatherer<T, A, R> gatherer;
        private final Integrator<A, T, R> integrator; // Optimization: reuse
        private A state;
        private boolean proceed = true;
        private boolean downstreamProceed = true;

        GatherSink(Gatherer<T, A, R> gatherer, Sink<R> sink) {
            this.gatherer = gatherer;
            this.sink = sink;
            this.integrator = gatherer.integrator();
        }

 

GatherSinkクラスはSinkインタフェースを実装しているのは当然ですが、Gatherer.Downstreamインタフェースも実装しています。

Gatherer.Downstreamインタフェースは、Gatherer.Integratorインタフェースのintegratメソッドで使用するpushメソッドを定義しています。

前回は下流に対してデータを流すという説明をしていましたが、実際はGatherSinkクラスのpushメソッドがコールされるわけです。

また、Gatherer.Downstreamインタフェースを実装しているため、ごっちゃにならないように下流のSinkを表すフィールドはdownstreamではなくsinkという名前になっています。

 

GatherSinkクラスの動作

前述したように、終端操作のパイプラインにおいて、forEachRemainingメソッドでイテレーションが実行されます。その時に、Sinkオブジェクトのacceptメソッドがコールされます。

これはGathererを使った場合も同じです。GatherSinkオブジェクトのacceptメソッドがコールされます。

では、GatherSinkクラスのacceptメソッドを見てみましょう。

        public void accept(T t) {
            /* Benchmarks have indicated that doing an unconditional write to
             * `proceed` is more efficient than branching.
             * We use `&=` here to prevent flips from `false` -> `true`.
             *
             * As of writing this, taking `greedy` or `stateless` into
             * consideration at this point doesn't yield any performance gains.
             */
            proceed &= integrator.integrate(state, t, this);
        }

 

コメントがおもしろいですけど、主題とは関係ないので...

acceptメソッドの内部では、Gatherer.Integratorインタフェースのintegrateメソッドがコールされています。

通常の中間操作であれば、下流のSinkオブジェクトのacceptメソッドをコールするのですが、ここではそれがありません。

Gathererを使う時は、下流にデータを流すかどうかはGathererによるためです。

とはいえ、下流にデータを流す(つまり、下流のSinkオブジェクトのacceptメソッドをコールすることに相当します)場合もあります。これはどこで行っているのでしょうか。

ヒントはintegrateメソッドの第3引数です。

前回、integratorメソッドの説明で、下流にデータを流す時はGatherer.Downstreamインタフェースのpushメソッドをコールしますと説明しました。

そのpushメソッドはintegrateメソッドの第3引数のdownstream変数に対して行っていたことを覚えていますでしょうか。

では、第3引数が何かというと、上のコードではthisを渡しています。

GatherSinkクラスはGatherer.Downstreamインタフェースを実装していますと前述しました。ということは、結局、自分自身のpushメソッドをコールしているということになります。

では、そのpushメソッドを見てみましょう。

    public boolean push(R r) {
        var p = downstreamProceed;
        if (p)
            sink.accept(r);
        return !cancellationRequested(p);
    }

 

ここで、下流のSinkオブジェクトに対してacceptメソッドをコールしていました。

つまり、Gathererで下流にデータをpushした時に、次段から処理が行われるということです。

このようにして、二重ループではなく、効率的に状態を保持した中間操作を行うことができるのです。

 

まとめ

いちおうまとめておきましょう。

Stream APIの動作は要約すると次のようになります。

  1. Spliteratorがイテレーションを行う
  2. Sinkが処理をまとめて、一括してデータを処理する

これに対し、GathererではSinkを工夫することで、下流にデータを流した時にだけ次段から連なる処理を行うようにします。

 

今回はストリームの開始時と終了時の処理や、パラレル処理の場合を省略しましたが、もし興味があるのであればソースを見てみるとおもしろいですよ。

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になるのは既定路線でしょう。