Featured image of post Java 17は何が変わったか

Java 17は何が変わったか

今月は新しいLTSバージョンであるJava 17のリリースがありました。まだJava 1.8を使っている案件も多いかなと思いますが、Java 1.8は2022年まで、Java 11は2023年までのサポートとなるので、いずれにせよJava 17に移行する必要はあるかなと思います。特にJava 9からモジュールが導入されたため、8からの移行はかなり大変だったらしいですが、11から移行する場合はそれほどでもないと言われているので、今からでも17では何が変わっているか、目を通しておくのもそう悪くはないでしょう。

現時点ではEclipse Temurin(旧AdoptOpenJDK)、Zuluなどの有名JDKはほとんどが17のリリースを完了しているか、対応の最中にありますね。また、Oracle JDK 17は無料になったので、こちらを選ぶもの悪くない選択肢の一つかもしれません。

また、こういう無料化やJDKの多様化のみでなく、GoogleとOracleの訴訟の件もGoogleの勝利で終わったので、AndroidでもJava 17を使える可能性ができた以上、これからJava 17を使える場面は増えてくるかもしれません。実際、まだ遠い話ではあります、Springを使う場合、2022年のSpring 6はJava 17がベースラインとなるらしいですね。なので、Java 11は採択されてなかった現場でも、サポート期間などを考慮して17に転換する可能性はあると思います。

というわけで、今回はそんなJava 17では何が変わったかを述べていきますが、大きく分けて新しい予約語の追加、新しい書き方など言語スペックとして変わったものと、新しく追加されたAPIという二つの観点でその変化を辿っていきたいと思います。案件によってはJava 1.8から17に移行するケースもあるかと思いますが、9〜11までの間にあった変更事項や新しいAPIなどはこのブログでも扱っていて、他でも参考にできるサイトが多いと思いますので、今回は8~11までの変化については割愛し、11〜17の間の変化だけを扱うことにさせてください。

言語スペック

New macOS Rendering Pipeline (17)

macOSでは長い間、SwingなどJavaの2DレンダリングにOpenGLを使っていましたが、新しいMetal frameworkを導入しながら、10.14からOpenGLはdeprecatedとなりました。

従ってJava側でも、Metalを利用する新しいグラフィック・レンダリング・パイプラインを実装するというProject Lanaiが進められていましたが、17からNew macOS Rendering Pipelineという名で導入されました。JavaであまりGUIを使うことないのでは?と思いがちかと思いますが、intellijのようなJavaベースのIDEでも画面描画で性能向上があるという噂です。ただ、intellijでは基本的にJetbrains Runtimeを使っていて、現時点ではそれがJava 17に対応していないので少し待つ必要はあります。

macOS/AArch64 Port (17)

17からはM1など、Apple Siliconを搭載した新しいMacに対応しました。Zuluなどの他のJDKでは独自に対応してるケースもありましたが、OpenJDK(OracleJDK)で対応したことで、これをベースとするEclipse TemurinMicrosoft Build of OpenJDKのような他のJDKでも自然にARMベースMacでネイティブとして使えるということになると思います。

Record (17)

14からPreviewとして導入されたRecordが、17ではstableになり正式に導入されました。指定したフィールドをprivate finalにして、コンストラクタ、gettertoStringhashcodeequalsなどを自動生成してくれるものです。最初はLombok@Dataのようなものかと思いきや、実際は@Valueに近いものになっていますね。値はコンストラクタでしか渡せなくて、後から変更はできなくなります。こういうところは、フィールドをvalとして指定したKotlinのdata classに近い感覚でもあります。なので、実際の使用例を見ると、以下のようになります。

// Recordの定義
record MyRecord(String name, int number) {}

// インスタンスの作成
MyRecord myRecord = new MyRecord("my record", 1);

// フィールドの取得
String myRecordsName = myRecord.name();
int myRecordsNumber = myRecord.number();

KotlinではNamed Argumentsに対応しているのですが、Javaではまだそのような機能がないので、Recordだとフィールドが多くなるとどれがどれだかわからなくなりそうな気はします。これに対してKotlin側でRecordを使う場合、何らかのラッパークラスを作って対応するなどの方法は考えられますね。もしくは普通にsetterをもつDTOを定義するか、builderパターンを利用する方が良いでしょう。

また、Recordではgetter名もフィールド名そのままになるという特徴もありますが、自動生成されるコンストラクタをカスタマイズするときも少し書き方が違うという特徴があります。

record MyRecord(String name, int number) {
    // コンストラクタにバリデーションをつける例
    public MyRecord {
        if (name.isBlank()) {
            throw new IllegalArgumentException();
        }
    }
}

他に、Recordとして定義しても実際はClassが作られることになるので、以下のようなこともできます。

  • コンストラクタを追加する
  • getterをオーバライドする
  • インナークラスとしてRecordを定義する
  • インターフェイスを実装する

また、ReflectionでもクラスがRecordであるかどうかを判定するisRecordも追加されています。

Text Blocks (15)

Javaでは長い間、HTMLやJSON、SQLなどをリテラルとして使うためにはエスケープや文字列の結合などを使う必要がありました。これはあまり可読性という面でよくなく、コードの修正も難しくなる問題がありましたね。例えば、HTMLを表現するとしたら以下のようなことをしていたかと思います。

String html = "<html>\n" +
              "    <body>\n" +
              "        <h1>This is Java's new Text block!</h1>\n" +
              "    </body>\n" +
              "</html>\n";

String query = "SELECT \"EMP_ID\", \"LAST_NAME\" FROM \"EMPLOYEE_TB\"\n" +
               "WHERE \"CITY\" = 'INDIANAPOLIS'\n" +
               "ORDER BY \"EMP_ID\", \"LAST_NAME\";\n";

幸い、15からText Blocksが導入され、他の言語のように簡単かつ可読性の高い文字列を定義することができるようになりました。これを使うとエスケープを意識しなくて良いので、複数行でなくても色々な分野で有効活用できそうですね。Text Blocksを使って上記のコードを変えると、以下のようになります。

String html = """
              <html>
                  <body>
                      <h1>This is Java's new Text block!</h1>
                  </body>
              </html>
              """;

String query = """
               SELECT "EMP_ID", "LAST_NAME" FROM "EMPLOYEE_TB"
               WHERE "CITY" = 'INDIANAPOLIS'
               ORDER BY "EMP_ID", "LAST_NAME";
               """;

Kotlinでは全く同じ書き方で同じことができるので、ここでは割愛します。

Sealed Class (17)

JDK 15からPreviewで導入されたsealed classesが、Stableとなりました。classinterfacesealedにすれば、それを拡張・実装できるクラスやインターフェイスを限定できるようになります。こうすることで、ライブラリなどで勝手に拡張して欲しくないクラスやインターフェイスを守ることができますね。また、将来的にはsealedとして定義されてあるクラスの子クラスをswitchcaseに指定するときは全部のケースが指定されているかどうかをコンパイラがチェックするようにするとかの話もあるようです。以下は、sealedクラスがpermitsキーワードを使って継承できるクラスを指定する例です。

public abstract sealed class Shape permits Circle, Rectangle, Square, WeirdShape { }

KotlinでもSealed Classesは存在していますが、interfacesealedにするためには1.5以降を使う必要があって、拡張・実装できるクラスやインターフェイスを指定するわけではなく、コンパイルされたモジュール以外でsealedとして定義されているクラスやインターフェイスを拡張・実装できない仕様となっています。なので書き方的には、以下のようになります。より簡単ですね。

sealed interface Error

sealed class IOError(): Error

class FileReadError(val f: File): IOError()
class DatabaseError(val source: DataSource): IOError()

object RuntimeError : Error

また、Javaの場合はRecordと同じく、このクラスがsealedであるかどうかを判定するisSealedが追加されています。

Switch Expressions (14)

Java 12からPreviewでSwitch Expressionsが導入され、14からはStableになっています。従来のswitchを改善したもので、以下のようなことができるようになりました。

  • caseをまとめて指定できる
  • caseの処理をラムダのような書き方で記述できる
  • caseの処理を戻り値にして、switchを式として使える

例えば、dayというenumの値を見て、int値を返すメソッドを実装するとしましょう。従来の方法では以下のようになるはずです。

int numLetters;
switch (day) {
    case MONDAY:
    case FRIDAY:
    case SUNDAY:
        numLetters = 6;
        break;
    case TUESDAY:
        numLetters = 7;
        break;
    case THURSDAY:
    case SATURDAY:
        numLetters = 8;
        break;
    case WEDNESDAY:
        numLetters = 9;
        break;
    default:
        throw new IllegalStateException("Wat: " + day);
}

上記の処理は新しいswitchでは以下のように書くことができます。

int numLetters = switch (day) {
    case MONDAY, FRIDAY, SUNDAY -> 6;
    case TUESDAY                -> 7;
    case THURSDAY, SATURDAY     -> 8;
    case WEDNESDAY              -> 9;
};

Kotlinだと以下のようになるはずですね。

val numLetters = when (day) {
    Day.MONDAY, Day.FRIDAY, Day.SUNDAY -> 6
    Day.TUESDAY -> 7
    Day.THURSDAY, Day.SATURDAY -> 8
    Day.WEDNESDAY -> 9
}

また、whenだとargumentなしでも使えて分岐を条件文によるものにすることもできるなどの特徴もあるので、使い勝手はJavaのswitchよりいいかなと思います。ただ、Javaでもバージョンアップと共に後述する機能も追加されてあるので、今後Kotlinのように色々と改良が行われる可能性はあるかと思いますね。

Pattern Matching for instanceof (16) / switch (17)

Java 14からは、Pattern Matching for instanceofが導入され、16ではStableになりましt。今まではinstanceofを使ってオブジェクトのインスタンスの種類を判定した後、そのインスタンスの種類にあった処理を行うには以下のようにキャストが必要でしたね。

static String formatter(Object o) {
    String formatted = "unknown";
    if (o instanceof Integer) {
        formatted = String.format("int %d", (Integer) i);
    }
    // ...
}

一度どれのインスタンスかわかった上でさらにキャストをする必要はあるのはだるいし、ミスをしたら例外の原因にもなり得る問題がありますね。なので、Pattern Matchingを利用して、キャストをなくすことができるようになりました。instanceofを使った条件文の中に、キャストする変数名を指定しておくと、if分の中でそのまま自動にキャストされた変数を使えるようになります。なので、以下のようなことができるようになります。

static String formatter(Object o) {
    String formatted = "unknown";
    if (o instanceof Integer i) {
        formatted = String.format("int %d", i);
    } else if (o instanceof Long l) {
        formatted = String.format("long %d", l);
    } else if (o instanceof Double d) {
        formatted = String.format("double %f", d);
    } else if (o instanceof String s) {
        formatted = String.format("String %s", s);
    }
    return formatted;
}

さらに、17からはPreviewとしてPattern Matching for switchが導入されています。これを使うと、instanceofなしで、switch文を使ったよりシンプルな処理を書けるようになります。これを先に紹介したSwitch Expressionsと組み合わせることで、上記の処理は以下に変えることが可能になります。かなりシンプルになったのがわかりますね。

static String formatterPatternSwitch(Object o) {
    return switch (o) {
        case Integer i -> String.format("int %d", i);
        case Long l    -> String.format("long %d", l);
        case Double d  -> String.format("double %f", d);
        case String s  -> String.format("String %s", s);
        default        -> o.toString();
    };
}

Packaging Tool (16)

実行できるバイナリを生成するPackaging Toolが導入されています。これを使うと、Java runtimeとライブラリ、それぞれのOSにあった実行ファイルが一つのパッケージになる機能です。Java runtimeが含まれるということはOSのJavaのバージョンに関係なく実行できるものになるという意味なので、Javaのバージョンを固定したり、複数のアプリでそれぞれ違うバージョンのJavaを使って起動したい場合は役立つ機能かもしれません。

API

Java 17からは、APIドキュメントから、新しく追加されたAPIの一覧だけを見られるタブができたということです。今回は11以降に追加されたもののみですが、今後新しいLTSバージョンがリリースすると、17以降のものをこちらから確認できそうですね。新しいAPIの一覧はこちらから確認できます。

ここで全てのAPIの詳細まで探るのは難しいと思いますので、個人的に興味深いと思ったのを一部紹介したいと思います。

@Serial (14)

java.ioパッケージに、Serialというアノテーションが追加されました。これはSerializableを実装したクラスで、そのシリアライズのメカニズムを@Overrideするような機能のようです。例えば以下のようなことができます。

class SerializableClass implements Serializable {

    @Serial
    private static final ObjectStreamField[] serialPersistentFields;

    @Serial
    private static final long serialVersionUID;

    @Serial
    private void writeObject(ObjectOutputStream stream) throws IOException {}

    @Serial
    private void readObject(ObjectInputStream stream) throws IOException, ClassNotFoundException {}

    @Serial
    private void readObjectNoData() throws ObjectStreamException {}
    
    @Serial
    Object writeReplace() throws ObjectStreamException {}

    @Serial
    Object readResolve() throws ObjectStreamException {}
}

このアノテーションをつけることで、コンパイルタイムでエラーをキャッチできるのも特徴的です。例えば、このアノテーションを以下のようなクラスのメンバに使う場合はコンパイルエラーとなります。

  • Serializableを実装してないクラス
  • Enumのように、Serializeの効果がないクラス
  • Externalizableを継承しているクラス

このようなアノテーションが追加されたことによって、JacksonやGsonなどのライブラリの実装にも何か影響があるかもしれません。

String

同じ文字列だとしても、Javaではjava.lang.String、Kotlinではkotlin.text.Stringを使うことになるので、Kotlinを使う場合はあまりJavaのAPIを使うことはないかと思います(また、JavaでのString関連のAPIは、Kotlinだとdeprecatedになるケースが多いです)。なので、ここでは新しいAPIと、Kotlinで同じような処理をするために使える方法を中心に紹介します。

formatted (15)

JavaではString.format()をで文字列をフォーマットとして使うことができました。多くの場合、文字列は+を使うよりフォーマットを使った方が性能が良いと言われていて、よく使っていたものです。

String name = "formatted string";

// 15以前
String formattedString = String.format("this is %s", name);

// 15以降
String newFormattedString = "this is %s".formatted(name);

KoltinだとString.formatString Templatesが使えます。

val name = "formatted string"

// Format
val formattedString = "this is %s".format(name)

// String Template
val templateString = "this is $name"

indent (12)

indentでは、対象の文字列に引数で指定した分のwhite spaceを入れます。引数がint型なので、負数を渡すことでwhite spaceを減らすこともできます。

String nonIndent = "A";
// インデントを追加
String indented10 = nonIndent.indent(10); // "          A"
// インデントを削除
String indented5 = indented10.indent(-5); // "     A"

Kotlinの場合は、インデントを追加するためのprependIndentや代替するするためのreplaceIndentなどがあり、渡すパラメータも文字列となるのでJavaのものとは少し使い方が違います。

val nonIndent = "A"
// インデントを追加
val prepended = nonIndent.prependIndent("     ") // "     A"
// インデントを代替(なかった場合は追加)
val replaced = prepended.replaceIndent("|||||") // "|||||A"

stripIndent (15)

Text Blockで複数行の文字列を扱う場合、ソースコード上の可読性の都合で任意のインデントを入れたら実際のデータとしては扱いづらい場合もあるはずです。ここでインデントを削除するためののものがstringIndentです。

KotlinではtrimIndentが同じ役割をしています。

transform (12)

文字列に対してFunctionを実行するという単純なAPIです。replaceでは不可能な、条件による処理などが必要なときに使えそうです。実装を見ると極めて単純です。

public <R> R transform(Function<? super String, ? extends R> f) {
    return f.apply(this);
}

Kotlinでは文字列でもmapfilterreduceのような高階関数が使えるのでこれらを使うこともできますね。もしくは以下のような拡張関数を定義することで同じことができるかと思います。

fun <R> String.transform(f: (String) -> R): R = f(this)

translateEscapes (15)

エスケープになっている一部の文字をリテラルに変えてくれる機能です。こちらはコードを見た方が理解が早いかなと思います。

String string = "this\\nis\\nmutli\\nline";
String escapeTranslated = string.translateEscapes() // "this\nis\nmutli\nline"

以前はMatcherと正規式を組み合わせるなど独自の処理を書くか、ライブラリに依存していたと思いますので、こういうのができると嬉しいですね。変換されるエスケープ文字は以下の通りです。

EscapeNameTranslation
\bbackspaceU+0008
\thorizontal tabU+0009
\nline feedU+000A
\fform feedU+000C
\rcarriage returnU+000D
\sspaceU+0020
\"double quoteU+0022
\'single quoteU+0027
\\backslashU+005C
\0 - \377octal escapecode point equivalents
\<line-terminator>continuationdiscard

Kotlinでは似たようなAPIがないので、必要なら独自の処理を書いた方が良さそうです。(ライブラリは知らず…)

Map.Entry.copyOf (17)

Map.Entryのコピーを作成します。コピーしたエントリは元のMapとは何の関係もないデータとなります。以下のようなサンプルコードを公式ドキュメントから提示していますね。

var entries = map.entrySet().stream().map(Map.Entry::copyOf).toList();

ちなみにMapそのもののコピーは、10から追加されたcopyOfでできます。

var copiedMap = Map.copyOf(map);

Kotlinだと、Entryのコピーは以下のようにできます。型はList<MutableMap.MutableEntry<K, V>>となります。

// Map.Entryを使う場合
val entriesJava = map.entries.map { Map.Entry.copyOf(it) }

// KotlinのMap.Entryを使う場合
val entriesKotlin = map.entries.toSet()

また、KotlinでのMapのコピー方法は以下のようにできます。

val copiedMap = map.toMap()

Stream

mapMulti (16)

16からStreamにmapMultiというメソッドが追加されました。基本的には「Streamの要素に1:Nの変換を適用して結果をStreamを返す」という処理なので、flatMapに似ていますが、以下のケースではflatMapを使う場合より良いと言われています。

  • 要素を減らす場合
  • 要素をStreamに変換するのが難しい場合

まずはオブジェクトがネストされているCollectionに対してflatMapを使う場合を考えてみましょう。要素を減らすケースでは、flatMapでまず全ての要素を展開し、filterを使って条件に合う要素だけを取る必要があります。ここで要素を展開するには、全ての要素をStreamに変換しなければならないので、全ての要素のグループに対してStreamのインスタンスを作ることになります。また、オブジェクトがネストしている場合は、その個別の要素に対してどうやってStreamに変換するか、処理の中で定義する必要があります。

問題はStreamのインスタンスを毎回作るためオーバヘッドが発生することにもなるし、要素がさまざまな型のオブジェクトである場合はStreamに変換する処理を書くのも大変ということです。例えば以下のようなListがあるとしましょう。

List<Object> numbers = List.of(List.of(1, 2L), 3, List.of(4, 5L, 6), List.of(7L), 8L);

このListから、Integerのみを抽出して別のListにしたい場合はどうしたら良いでしょうか。まずflatMapを使うとしたら、以下のような処理を書くことになるかと思います。

List<Integer> integers = list.stream()
        .flatMap( // 要素をStreamに変換する
                it -> {
                    if (it instanceof Iterable<?> l) {
                        return StreamSupport.stream(l.spliterator(), false);
                    } else {
                        return Stream.of(it);
                    }
                })
        .filter(it -> it instanceof Integer) // Integerのみを取る
        .map(it -> (Integer) it) // ObjectからIntegerへキャスト
        .toList();

これをmapMultiを使って処理する場合は以下のようになります。よりシンプルになりましたね。

class MultiMapper {
    static void expandIterable(Object e, Consumer<Integer> c) {
        if (e instanceof Iterable<?> i) {
            i.forEach(ie -> expandIterable(ie, c));
        } else if (e instanceof Integer i) {
            c.accept(i);
        }
    }
}

List<Integer> integers = list.stream().mapMulti(MultiMapper::expandIterable).toList();

他にもmapMultiToIntmapMultiToLongmapMultiToDoubleなどのメソッドも追加されていますので、数字を扱う場合はこちらを使った方が便利でしょう。例えば、上記のmapMultimapMultiToIntで書く場合は以下のようになります。

class MultiMapper {
    static void expandIterable(Object e, IntConsumer c) {
        if (e instanceof Iterable<?> i) {
            i.forEach(ie -> expandIterable(ie, c));
        } else if (e instanceof Integer i) {
            c.accept(i);
        }
    }
}

List<Integer> integers = list.stream().mapMultiToInt(MultiMapper::expandIterable).boxed().toList();

mapMultiToIntの戻り値はIntStreamなので、Stream<Integer>に変換するためにboxedを呼び出し、ConsumerIntConsumerに変わり、mapMultiの型指定が変わるなど少しの違いがあります。

KotlinではそもそもflatMapStreamとして扱わないので、そもそもの処理を違う観点から考える必要があります。幸い、KotlinのCollectionには色々なAPIがあるので、そこまで難しくはないです。例えば、オブジェクトのインスタンスを基準に要素を集約したい場合は以下のようなコードを書くことができます。

val list = listOf(listOf("A", 'B'), "C", setOf("D", 'E', "F"), listOf('G'), 'H')

val result: List<String> = list.flatMap {
    if (it is Iterable<*>) {
        it.filterIsInstance<String>()
    } else {
        listOf(it).filterIsInstance<String>()
    }
} // [A, C, D, F]

ただ、JavaではList.of(1, 2L)でListを作成した場合、1はint、2LはLongとして扱われますが、KotlinではlistOf(1, 2L)List<Long>となってしまうので、そもそもの型に注意する必要があります。

val list = listOf(listOf(1, 2L), 3, setOf(4, 5L, 6), listOf(7L), 8L)

val result = list.flatMap {
    if (it is Iterable<*>) {
        it.filterIsInstance<Int>()
    } else {
        listOf(it).filterIsInstance<Int>()
    }
} // [3]

toList(16)

Streamの終端処理として使用頻度の高い「Listに集計する」をシンタックス・シュガーとして作ったような感覚のメソッドです。ここはKotlinの機能をJavaが受け入れたような気もしますね。処理の結果として生成されるListはUnmodifiableです。

List<String> list = List.of("a", "B", "c", "D");

// 旧
List<String> upper = list.stream().map(String::toUpperCase).collect(Collectors.toUnmodifiableList());

// 新
List<String> lower = list.stream().map(String::toLowerCase).toList();

Kotlinでは基本的にCollectionで高階関数を呼び出した結果がUnmodifiableなListになるのですが、streamに変換して使うこともできるので、場合によっては便利なのかもしれませんね。

Collectors.teeing (12)

Collectorsに、二つのCollectorを結合するteeingというメソッドが追加されました。ちなみにTeeは二つの水道管を接続して一つにしてくれる「T字継手」の意味を持つらしいです。引数に二つのCollectorと、それを結合する処理のBiFunctionを指定する形となっています。

例えば以下のようなStreamがあるとしましょう。

record Member(String name, boolean enabled) {}

/**
* [
*    Member[name=Member1, enabled=false],
*    Member[name=Member2, enabled=true],
*    Member[name=Member3, enabled=false],
*    Member[name=Member4, enabled=true],
* ]
*/
Stream<Member> members = IntStream.rangeClosed(1, 4).mapToObj(it -> new Member("Member" + it, it % 2 == 0));

これをteeingを使って、Memberenabledを基準に二つのListに分けるとしたら以下のようになります。

/**
* [
*    [
*       Member[name=Member2, enabled=true],
*       Member[name=Member4, enabled=true]
*    ],
*
*    [
*       Member[name=Member1, enabled=false],
*       Member[name=Member3, enabled=false]
*    ]
* ]
*/
List<List<Member>> result = members.collect(
        Collectors.teeing(
                Collectors.filtering(
                        Member::enabled,
                        Collectors.toList()
                ),
                Collectors.filtering(
                        Predicate.not(Member::enabled),
                        Collectors.toList()
                ),
                (list1, list2) -> List.of(list1, list2)
        )
);

Kotlinではそもそもcollectする必要がないので、Collectionの高階関数を使った処理をした方が良いでしょう。(Javaでもそうした方がわかりやすいような…)

最後に

いかがだったでしょうか。さすがに全ての変更事項を整理するのは難しかったので、目立っている変化だけをいくつか取り上げてみましたが、それでもかなりの量ですね。ただ確かなのは、Java 17が11よりもさらにモダンな言語になったバージョンであるので、Javaを使っている案件なら十分導入する価値がありそうです。また、Java 15からは11に比べてG1GCの改良による性能向上もあったようですので、性能という面でも良いですね。

Kotlinを使っている場合でも、APIだけを見るとあまりメリットはないかもしれませんが、JVMを使っている限り性能向上などの恩恵を受けることはできると思われるので、導入を考慮しても良いかなと思います。また次のLTSでは色々と面白いAPIが続々と登場するかもしれませんしね。

では、また!

Built with Hugo
Theme Stack designed by Jimmy