モダンJava:Records・Sealed Classes・パターンマッチングでJava 17〜21のコードをクリーンかつ安全に書く

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

クイックスタート:昔の苦労と新しい解決策(5分)

Javaをしばらく書いていると、お決まりの手順を知っているはずです。データクラスを作成し、コンストラクタを書き、ゲッターを書き、equals()をオーバーライドし、hashCode()をオーバーライドし、toString()をオーバーライドする。プロジェクト内のすべてのDTO、値オブジェクト、レスポンスモデルに対してこれを繰り返すのです。

以前のシンプルなPointクラスがどのようなものだったか見てみましょう:

// Java 8〜15:お決まりのボイラープレート地獄
public final 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 p = (Point) o;
        return x == p.x && y == p.y;
    }

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

    @Override
    public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}

それをJava 16+と比べてみましょう:

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

たった1行。コンパイラが標準コンストラクタ、アクセサ(x()y()getX()ではない)、そして正しいequalshashCodetoStringを自動生成します。recordは暗黙的にfinalかつイミュータブルです。

この一つの変更で、クラスごとに30〜40行が削減されます。20以上のDTOを持つ典型的なSpring Bootプロジェクトでは、600〜800行のボイラープレートが丸ごと消えます。そのコードを読む際の認知的負荷は本物です — 取り除くことでコードレビューが再び意味のあるものになります。

詳細解説:各機能の理解

Records — シンプルなDTO以上の存在

Recordsは単なるシンタックスシュガーではありません。「この型はそのコンポーネントの透明でイミュータブルなキャリアである」という契約をエンコードしています。この意味的な明確さは、インシデント対応中の深夜2時に見慣れないコードを読む際に非常に重要です。

コンパクトコンストラクタでバリデーションを追加できます:

public record Range(int min, int max) {
    // コンパクトコンストラクタ:パラメータリストを繰り返す必要なし
    Range {
        if (min > max) throw new IllegalArgumentException(
            "min %d は max %d 以下でなければなりません".formatted(min, max)
        );
    }
}

バリデーション以外にも、recordsはインターフェースを実装し、staticや追加のインスタンスメソッドを定義できます。一つの明確な制限:宣言されたコンポーネント以外に新しいインスタンスフィールドを持てません。

public interface Measurable {
    double length();
}

public record Segment(Point start, Point end) implements Measurable {
    @Override
    public double length() {
        int dx = end.x() - start.x();
        int dy = end.y() - start.y();
        return Math.sqrt(dx * dx + dy * dy);
    }
}

Sealed Classes — 制御された型ヒエラルキー

オープンな継承には根本的な問題があります:存在するすべてのサブクラスを列挙することができません。Shapeを受け取るメソッドを書いても、誰かどこかで、switchステートメントがカバーしていないHexagonサブクラスを追加していないという保証はありません。

Sealed classesは言語レベルでこの問題を解決します:

// Circle、Rectangle、Triangleのみが許可されている
public sealed interface Shape
    permits Circle, Rectangle, Triangle {}

public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double base, double height) implements Shape {}

permits句は明示的でコンパイル時に検証されます。許可された各サブクラスはfinalsealed(さらに制限される)、またはnon-sealed(意図的に再開放)でなければなりません。

RustのenumやHaskellの直和型を使ったことがあれば、これはJavaの同等物です — 代数的データ型が、ついにJVMに登場しました。

パターンマッチング — 意味をなすSwitch式

パターンマッチングは段階的に導入されました。instanceofバージョンはJava 16で安定版になりました:

// 旧来の方法
if (obj instanceof String) {
    String s = (String) obj;  // 冗長なキャスト
    System.out.println(s.length());
}

// Java 16+のパターン変数
if (obj instanceof String s) {
    System.out.println(s.length());  // sはすでに型付けされている
}

Java 21 LTSでは完全なswitchパターンマッチングが導入され、sealed classesと完璧に組み合わせられます:

double area(Shape shape) {
    return switch (shape) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t  -> 0.5 * t.base() * t.height();
        // defaultは不要 — コンパイラがこれらがすべてのケースだと知っている
    };
}

defaultケースは不要です。switchを更新せずにsealed interfaceにPentagonを追加すると、コンパイラがビルドを拒否します。コンパイル時に検出される — オンコールエンジニアがすでにストレスを抱えている深夜3時ではなく。

応用編:3つの機能の組み合わせ

3つを組み合わせるところが本当に面白くなります。シンプルな式評価器でそのパターンを明確に示します:

// recordsを使ったsealedヒエラルキー
public sealed interface Expr
    permits Literal, Add, Multiply, Negate {}

public record Literal(double value) implements Expr {}
public record Add(Expr left, Expr right) implements Expr {}
public record Multiply(Expr left, Expr right) implements Expr {}
public record Negate(Expr expr) implements Expr {}

// 評価のためのパターンマッチング — 網羅的で読みやすい
static double eval(Expr expr) {
    return switch (expr) {
        case Literal(var v)        -> v;
        case Add(var l, var r)     -> eval(l) + eval(r);
        case Multiply(var l, var r) -> eval(l) * eval(r);
        case Negate(var e)         -> -eval(e);
    };
}

Literal(var v)の構文はJava 21で利用可能なrecordパターン分解です。caseラベルでrecordのコンポーネントを直接バインドします — ブランチ内でexpr.value()を呼び出す必要はありません。

より細かい制御が必要ですか?whenでガードを追加できます:

String describe(Shape shape) {
    return switch (shape) {
        case Circle c when c.radius() > 100 -> "大きな円";
        case Circle c                        -> "小さな円";
        case Rectangle r when r.width() == r.height() -> "正方形";
        case Rectangle r -> "長方形";
        case Triangle t  -> "三角形";
    };
}

switchは上から下に評価され、最初に一致したcaseが勝ちます。ネストされたif文も、フォールスルーの驚きもありません。

実際のプロジェクトのための実践的なヒント

APIレスポンスモデルにRecordsを使う

Jackson 2.12+はrecordsをそのまま扱えます — JSONフィールドをコンストラクタパラメータに直接マッピングします。APIペイロードに不要なフィールドが含まれる場合は@JsonIgnoreProperties(ignoreUnknown = true)を追加します:

public record UserResponse(
    long id,
    String username,
    String email
) {}

@JsonPropertyもemptyコンストラクタもLombokも不要です。デフォルトでクリーンかつイミュータブルです。

ドメインイベントをSealする

イベントソーシングとCQRSパターンはsealed interfacesから大きな恩恵を受けます。permits句にすべてのイベントタイプを列挙すれば、ハンドラが網羅的になります — コンパイラが本番環境に到達する前に漏れているケースを検出します:

public sealed interface OrderEvent
    permits OrderPlaced, OrderShipped, OrderCancelled {}

public record OrderPlaced(String orderId, List<String> items) implements OrderEvent {}
public record OrderShipped(String orderId, String trackingNumber) implements OrderEvent {}
public record OrderCancelled(String orderId, String reason) implements OrderEvent {}

void handleEvent(OrderEvent event) {
    switch (event) {
        case OrderPlaced e   -> processNewOrder(e.orderId(), e.items());
        case OrderShipped e  -> notifyCustomer(e.orderId(), e.trackingNumber());
        case OrderCancelled e -> refundOrder(e.orderId(), e.reason());
    }
}

採用前にJavaバージョンを確認する

  • Java 16:Records安定版、instanceofパターン安定版
  • Java 17 LTS:Sealed classes安定版 — これらの機能の最小推奨バージョン
  • Java 21 LTS:Switchパターンマッチング安定版、recordデコンストラクション安定版 — 現在の推奨ターゲット

まだJava 11を使っていますか?今すぐアップグレードの計画を始めましょう。Java 17 LTSは2029年まで延長サポートがあり、Java 21 LTSは2031年まで続きます。どちらもあなたを困らせることはなく、この記事で取り上げたすべての機能が利用できます。

Recordsを無理にどこでも使わない

RecordsはデータキャリアとしてDTO、値オブジェクト、レスポンスモデル、設定スナップショットに最適です。JPAエンティティ(可変状態、引数なしコンストラクタが必要)や複雑な継承を中心に構築されたクラスには不向きです。適切なツールを使いましょう。

Records、Sealed Classes、パターンマッチングを組み合わせることで、ドメインのルールを型システムにエンコードできます — 無効な状態が本番環境に紛れ込む代わりに、コンパイル時にエラーとして検出されます。この保証はコードベースが成熟してから後付けするのは困難です。最初から取り入れる価値があります。

Share: