見出し画像

今日から使えるJava 9以降の新機能

Java 8は2014年3月にリリースされました。
ラムダ式やStreamなど、それまでのプログラミングスタイルが一変するような数々の機能追加が行われた刺激的なバージョンでした。

時は流れて、2024年。
その間、リリースモデルも定期リリースサイクルに大きく変わり、3月末にはJava 22がリリースされました。

Java 9以降の変更はあまり追っておらず、IDEがサジェストしてきたコード見て「こんな書き方ができるようになったのか!」と驚いたという方もいらっしゃるかもしれません。
本稿では主にJava言語仕様関連の新機能に注目して、すぐにでも使っていきたい機能をまとめていきます。 

Javaの「未確定の新機能」

本編に入る前に、Javaの「未確定の新機能」に関して触れておきます。

Javaのほぼすべての新機能は、JDK拡張提案(JEP)として始まります。
重要な機能や大型な機能の場合、それを「未確定の新機能」として事前公開して開発者に前もって新機能を使ってもらい、フィードバックを求めるプロセスがあります。

https://blogs.oracle.com/otnjp/post/the-role-of-previews-in-java-14-java-15-java-16-and-beyond-ja

Javaの機能といっても範囲は多岐にわたります。
Java言語仕様、Java SE API機能、JDKツール、Hotspot JVMなどです。
その範囲ごとに「未確定の新機能」は、プレビュー版、試験運用版、インキュベーター版の3つに分類されます。

「未確定の新機能」はデフォルトで無効になっています。
そのため、例えばプレビュー版の機能を使うためには、コンパイル時と実行時に明示的にフラグを追加する必要があります。

JDKのリリースノートでは、ある新機能がプレビュー版/試験運用版/インキュベーター版なのか否かがわかるように書かれています。
その機能が正式採用されたものなのか否かにも気をつけながら確認することをお勧めします。

本稿では正式採用された(=仕様としてFIXされた)機能に絞って紹介していきます。

テキストブロック

はじめに紹介するのは、テキストブロックです。
複数行にまたがる文字列リテラルをエスケープ文字を使わずに記載できます。

JSON文字列をJavaの文字列リテラルで書く場合、ソースコードに以下のように書いていたと思います。

String json = "{\n" +
              "  \"name\": \"Taro\",\n" +
              "  \"age\": 40\n" + 
              "}\n";

テキストブロックを用いると、以下のように書けます。

String json = """
       {
         "name": "Taro",
         "age": 40
       }
       """;

\nや\"のようなエスケープ文字が出現しませんし、文字列のインデントも直感的に表現できるため、ソースコードがとても読みやすくなりますね!

改行コードはLFに正規化される

プラットフォーム(Windows、Mac、Linux)毎にソースファイル上のデフォルト改行コードは異なりますので、テキストブロック中に異なる改行コードが混在することがあります。
混乱を避けるために、コンパイル時にテキストブロック中の改行コードはすべてLFに正規化されます。
例えばプラットフォームの改行コードを含むテキストブロックが必要な場合、以下のようにします。

String str = """
   red
   blue
   green
   """;
String converted = str.replaceAll("\n", System.lineSeparator());

参考

詳しい仕様やスタイルガイド(どのような場合にどのようにテキストブロックを使うとよいか)が説明されています

レコードクラス

次に紹介するのは、レコードクラスです。
いわゆるイミュータブルな値クラスをボイラープレートコード不要で定義できます。

xy平面上の座標を表すイミュータブルな値クラスPointを考えてみましょう。
以下のように、コンストラクターはもとより、getter、equals、hashCode、toStringといったボイラープレートなメソッドを一通り実装することになります。
これらのコードはIDEで自動生成できますが、冗長なコードの割合が大きくなってしまうのはやむを得ません。

public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Point)) {
            return false;
        }
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }

    @Override
    public String toString() {
        return String.format("Point[x=%d, y=%d]", x, y);
    }
}


これを回避するため、Lombokを使っている方も多くいらっしゃると思います。
Lombokを使うと、以下のように書けます。これだけでも大幅にスッキリしました。

@Value
public class Point {
    int x;
    int y;
}


レコードクラスを用いると、Lombokを使わなくても以下のように書けます。
ついにワンライナーです。

public record Point(int x, int y) {}

コンストラクターや新たなメソッドを定義できる

コンストラクターで値域をチェックしたい場合、以下のように特殊なコンストラクターで書けます。
メンバ変数への代入は自動で行われるので、明示的に書く必要がありません。

public record Point(int x, int y) {
    public Point {
        if (x < 0 || y < 0) {
            throw new IllegalArgumentException(String.format("Invalid Point[x=%d, y=%d]", x, y));
        }
    }
}

また必要に応じて、メソッドを追加することもできます。
例えば、原点に対して点対称の座標を新たに返すメソッドtoSymmetricは以下のように追加できます。

public record Point(int x, int y) {
    public Point toSymmetric() {
        return new Point(-x, -y);
    }
}

参考

switch式

次に紹介するのは、switch式です。
それまでのswitch「文」が拡張されて、switch「式」としても使えるようになりました。
ここで、プログラミング言語における、文と式の違いを簡単に確認しておきましょう。

  • 文(Statement): プログラムの操作やアクションを定義する実行単位。制御文や代入文などが含まれる。

  • 式(Expression): 値を生成するコードの断片。演算やメソッド呼び出しなどが含まれる。

switch「式」に拡張されることで、その結果を変数に代入したり、とある関数の引数部に直接書いたりすることができるようになります。

各月の最大日(ただし、閏年は考慮しない)を求める処理を考えてみます。
従来のswitch文を使うと以下のようになります。
switch文ですから、フォールスルーを防ぐbreak文や未知の値に対応するためのdefaultラベルを忘れずに書く必要があります。

    public enum Month {
        JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC;
    }

    public static int maxDay(Month month) {
        int maxDay = 0;
        switch(month) {
            case JAN:
            case MAR:
            case MAY:
            case JUL:
            case AUG:
            case OCT:
            case DEC:
                maxDay = 31;
                break;
            case APR:
            case JUN:
            case SEP:
            case NOV:
                maxDay = 30;
                break;
            case FEB:
                maxDay = 28;
                break;
            default:
                throw new IllegalArgumentException("Invalid month:" + month);
        }
        return maxDay;
    }


switch式で書き直すと以下のようになります。
case L ->ラベルという新しい記法によって、break文を書く必要はありません。
また、switch式のcaseは網羅的であることが求められるので、考えられるすべての値にswitchラベルが一致する必要があります。
enumのswitchの場合は、すべての列挙子が網羅されていないとコンパイルエラーになります。
※下の例では、例えばcase FEB -> 28;をコメントアウトするとコンパイルエラーになります。
新たに列挙子が追加された場合、該当のswitch式がコンパイルエラーになるので、その点でも使いやすいですね。

    public enum Month {
        JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC;
    }

    public static int maxDay(Month month) {
        int maxDay = switch (month) {
            case JAN, MAR, MAY, JUL, AUG, OCT, DEC -> 31;
            case APR, JUN, SEP, NOV -> 30;
            case FEB -> 28;
        };
        return maxDay;
    }

他にも、以下のようなことができるようになっていますが、本稿では詳細は省略します。

  • switch式の中で従来のcase L:ラベルが使える

  • switch式の中でyield文で値を返せる

  • switch文でもcase L ->ラベルが使える

参考

シールクラス

次に紹介するのは、シールクラスです。
シールクラスおよびインターフェースは、それらを継承・実装する他のクラスやインターフェースを制限できます。
これによって得られる主な利点は以下の2つです。

  • スーパークラスやインターフェースの作者が、それらの継承・実装を制御できる

  • コンパイラが型の網羅性について推論できるので、後述するswitchのパターンマッチングでコンパイラによる網羅性チェックが行わる(defaultラベルも不要)

多くのJava開発者にとっては、後者のパターンマッチングとの合せ技で使うことが多そうです。

数式のモデリングを考えてみます。
式を表すインターフェースとしてExprを用意して、それを実装する整数の定数、加算、乗算を用意します。
Exprはシールされたインターフェースなので、実装できるのはConstantExpr、PlusExpr、TimesExprに制限されています。
また、前述したレコードクラスを使ってインターフェースを実装しています。

sealed interface Expr
    permits ConstantExpr, PlusExpr, TimesExpr {
    public int eval();
}

record ConstantExpr(int i) implements Expr {
    public int eval() { return i; }
}

record PlusExpr(Expr a, Expr b) implements Expr {
    public int eval() { return a.eval() + b.eval(); }
}

record TimesExpr(Expr a, Expr b) implements Expr {
    public int eval() { return a.eval() * b.eval(); }
}

参考

Java言語アーキテクトのBrian Goetz氏による、シールクラスの解説です。
代数的データ型からの解説(シールクラスは直和型でありenumを一般化したものと考えることができる)や、代数的データ型の例(Java 5でjava.util.concurrent.Futureを追加する時にもしもシールクラス、レコードクラス、パターンマッチングがあったら)が紹介されています。
個人的に目から鱗だったのは、「待って、これってカプセル化に違反してませんか?」の内容で、「直和はより透明性の高い種類のポリモーフィズム」と述べられています。相反するものではなく、メリット・デメリットを判断して選択するものと考えることで、自分の視野が広がった気がします。

instanceof演算子のパターンマッチング

ここからはパターンマッチングに関連する新機能です。

パターンマッチングとは、オブジェクトが特定の構造を持つかをテストし、一致した場合にデータを特定の形で抽出し、対応する処理を行う方法です。
パターンマッチングは、Scala、F#のような関数型の特性を持つプログラミング言語だけでなく、PythonやRustなど幅広い言語で使える機能です。
Javaで使えることを心待ちにしていた方も多いのではないでしょうか。

パターンマッチングと一口に言っても、その機能セットはとても大きいです。
Javaでは細かくJEPを分けて、パターンマッチング関連の機能が追加されています。
例えば、上述のレコードクラス、switch式、シールクラスもパターンマッチングと関連があるものです。
戦略的に機能追加が行われていることが伺えますね。

まずはinstanceof演算子のパターンマッチングです。
実際に例を見てみましょう。

レコードクラスの例で用いたPointクラスのequalsメソッドを考えてみます。
通常のinstanceof演算子を使ったイディオムにのっとり以下のことを行っています。

  1. oがPoint型かをテスト

  2. oをPoint型にキャスト

  3. ローカル変数pointを宣言、2の値で初期化

public class Point {    
        ・・・

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Point)) {
            return false;
        }
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }
}


instanceof演算子のパターンマッチングを用いると、以下のコードになります。
if文の条件中にパターン変数が追加されています。これにより、明示的にキャストしてローカル変数を初期化するコードが不要になります。
僅かな違いではありますが、コードも短く読みやすくなりますし、明示的にキャストがなくなるので安全になります。

public class Point {    
        ・・・

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Point point)) {
            return false;
        }
        return x == point.x && y == point.y;
    }
}

パターン変数のスコープは、instanceof演算子がtrueの場合にプログラムが到達できる箇所になります。
上記例の場合、、instanceof演算子がtrueになるのはif文に入らない場合なので、パターン変数が導入された文の後でも使えます。

参考

switch式およびswitch文のパターンマッチング

switch式およびswitch文が大幅に強化されます。
従来、セレクタ式(switchに指定する式)に指定できるのは以下でした。

  • 数値(char、byte、short、int)

  • 文字列(String)

  • enum

Java 21からは上記に加えて、任意の参照型が指定できるようになります。

またcaseラベルには定数しか指定できませんでしたが、パターンを指定することができるようになります。

型に応じたtoString的な内容を返すメソッドを考えてみます。

public enum Month {
    JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC
}

public record Point(int x, int y) {
}

public static void typeTest(Object obj) {
    switch(obj) {
        case null -> System.out.println("null");
        case String s when s.length() < 5 -> System.out.println("Short String: " + s);
        case String s -> System.out.println("String: " + s);
        case Month m -> System.out.println(String.format("Month: %s(total values: %d)", m, Month.values().length));
        case Point p -> System.out.println("Record class: " + p.toString());
        default -> System.out.println("unknown");
    }
}

1つ目のcaseラベルではnullケースラベルを使って、セレクタ式がnullの場合の処理を行っています。(従来はセレクタ式にnullの場合、必ずNullPointerExceptionがスローされていました)
また、2つ目のcaseラベルではwhen句を使用して、ガード付きパターンラベルにしています。
また網羅性チェックが行われるので、最後のdefaultラベルがないとコンパイルエラーになります。

網羅性チェックとシールクラス

前述のシールクラスの説明で、パターンマッチングとの合せ技で使うことが多そうと述べていましたが、セレクタ式に指定する型が網羅性が担保されているシールクラスの場合は、defaultラベルが不要になります。

public sealed interface S permits A, B{}
final class A implements S {}
final class B implements S {}

public static void test(S s) {
    switch(s) {
        case A a -> System.out.println("This is A");
        case B b -> System.out.println("This is B");
    }
}

参考

レコードパターン

パターンマッチングにおいて特定のレコードクラスに一致する場合、そのレコードを構成する値の抽出も行えます。

printSum関数では、パターン変数pを用いています。
それに対して、printSum2関数ではレコードパターンを用いて、レコードクラスを構成する値である、xとyを直接抽出しています。

public record Point(int x, int y) {};

public static void printSum(Object obj) {
    if(obj instanceof Point p) {
        System.out.println(p.x + p.y);
    }
}

public static void printSum2(Object obj) {
    if(obj instanceof Point(int x, int y)) {
        System.out.println(x + y);
    }
}

上記はinstanceof演算子の例ですが、switchのパターンマッチングでも同様のことが行えます。

レコードクラスの場合、どのような値から構成されるかが唯一に決まるので、このようなパターンマッチングが実現できるということですね。

まとめ

Java 9以降の言語仕様関連の新機能を紹介しました。
新しい機能が身につくまでは意識する必要がありますが、書けるコードの幅が広がることは間違いありません。
この記事が参考になれば幸いです。

もちろんJavaの進化はこれだけではありません。
例えば、とむさんの記事で紹介されているVirtual Threadsという軽量スレッドも追加されています。

また、Java 18で行われたデフォルト文字セットのUTF-8への変更など、バージョンアップ時に注意が必要な変更も追加されています。

Javaがソフトウェア開発における重要なプログラミング言語であることは、今後も大きく変わることはないでしょう。
この記事の執筆の過程で、改めて公式ドキュメントでしっかりと説明されていることがわかりました。
私自身も適切に情報をキャッチアップしていきたいと思います。

#エンジニア#ITエンジニア#開発#ウイングアーク#ウイングアーク1st#テックブログ#エンジニア転職#エンジニア採用

この記事が気に入ったらサポートをしてみませんか?