Javaのクラス・record・インターフェース・sealed入門

OpenJDK 26で実行したJava初級講座。カプセル化、equalsだけ実装してHashSetに重複が入るバグ、record、インターフェースと抽象クラスの使い分け表、sealedとswitchの網羅性エラー。

シリーズ:Java講座 2/6(初級)。 main メソッドは書けるようになり、次は自分で型を設計する段階の人向けです。読み終えると、クラス・record・インターフェース・抽象クラス・sealed 階層を状況で選べるようになり、HashSet を黙って壊す equals/hashCode のミスを避けられます。

研修や入門書ではクラスの宣言方法までは教わりますが、実務で時間を取られるのはその先です。「Set に同じものが2つ入っている」「新しいサブクラスを追加したのにどこもエラーにならず、本番で分岐漏れが起きた」「この DTO は record にすべきか」。この記事はこうした疑問に、OpenJDK 26(Temurin 26+35)、Apple Silicon Mac (M1 Pro) で実行したコードと javac の実際のメッセージ、そして悪い hashCode のコストの実測で答えます。

第1回 と同じく、例はすべて java ファイル名.java で動く1ファイルです。

クラス・コンストラクタ・カプセル化

クラスは状態と、その状態を正しく保つルールをまとめたものです。カプセル化とは、そのルールを1か所に置くことです。フィールドは private にし、入口は入力をチェックするメソッドだけにします。

public class Account {
    private final String owner;
    private long balance;              // 円・セント単位の整数。double は使わない

    public Account(String owner, long initial) {
        if (owner == null || owner.isBlank()) throw new IllegalArgumentException("owner is required");
        if (initial < 0) throw new IllegalArgumentException("initial < 0: " + initial);
        this.owner = owner;
        this.balance = initial;
    }
    public Account(String owner) { this(owner, 0); }   // コンストラクタの連鎖

    public void withdraw(long amount) {
        if (amount <= 0) throw new IllegalArgumentException("amount must be > 0");
        if (amount > balance) throw new IllegalStateException("insufficient: " + balance + " < " + amount);
        balance -= amount;
    }
    public long balance() { return balance; }
    public String owner() { return owner; }

    public static void main(String[] args) {
        Account a = new Account("kim", 1000);
        a.withdraw(300);
        System.out.println(a.owner() + " " + a.balance());
        System.out.println(a);
        a.withdraw(5000);
    }
}
kim 700
Account@6f3b5d16
Exception in thread "main" java.lang.IllegalStateException: insufficient: 700 < 5000
    at Account.withdraw(Account.java:15)
    at Account.main(Account.java:26)
  • System.out.println(a) は Account@6f3b5d16 でした。クラス名と16進のハッシュで、実行するたびに変わります。これは Object.toString() の結果で、読める表示にしたければオーバーライドするか record を使います。
  • 別のクラスから a.balance = 1_000_000; と書くと error: balance has private access in Account です。このエラーこそカプセル化の目的で、直し方はフィールドを public にすることではなく deposit のようなメソッドを作ることです。
  • コンストラクタを1つでも書くと、引数なしのコンストラクタは自動では作られません。Point(int x, int y) だけのクラスで new Point() とすると error: constructor Point in class Point cannot be applied to given types; に続けて required: int,int、found: no arguments と出ました。

equals と hashCode の契約、HashSet に重複が入るバグ

HashSet や HashMap は、hashCode() でバケットを選び、その中で equals() で比べる2段階で要素を探します。だから a.equals(b) なら a.hashCode() == b.hashCode() でなければなりません。これを破ると、例外も出ずにコレクションがおかしくなります。

static class P1 {                       // equals だけ実装、hashCode を忘れた
    final int x, y;
    P1(int x, int y) { this.x = x; this.y = y; }
    @Override public boolean equals(Object o) {
        return o instanceof P1 p && p.x == x && p.y == y;
    }
}
static class P2 {                       // 両方とも正しく実装
    final int x, y;
    P2(int x, int y) { this.x = x; this.y = y; }
    @Override public boolean equals(Object o) {
        return o instanceof P2 p && p.x == x && p.y == y;
    }
    @Override public int hashCode() { return Objects.hash(x, y); }
}
// P0 = equals も hashCode もなし、P3 = P2 と同じだがフィールドが可変

HashBug.java(英語版に全文)の出力です。

no equals/hashCode : size=2
equals only        : size=2, equals? true, contains? false
equals + hashCode  : size=1, contains? true
equals only, 10,000 trials: duplicates in 10000
ArrayList.contains : true
mutated key        : contains(p)=false, size=1, remove(p)=false

2行目に注目してください。equals は「等しい」と言っているのに、Set には両方が入り、3つめの等しい点も見つけられません。たまに起きるのではなく、10,000回中10,000回です。既定の hashCode はオブジェクトごとに違うからです。しかも equals しか使わない ArrayList.contains は true なので、リストで書いた単体テストは通り、誰かが Set に替えた日に初めて表に出ます。

最後の行は契約のもう半分です。Set に入れた後で p.x = 99 と変えると、要素数は1のまま contains も remove も届かなくなりました。キーは不変にします。

最初のミスは javac が教えてくれます。

$ javac -Xlint:all HashBug.java
HashBug.java:8: warning: [overrides] Class P1 overrides equals, but neither it nor any superclass overrides hashCode method

よくあるミス:equals(Object o) ではなく equals(P o) と書く

public boolean equals(P o) { return o != null && o.x == x; }   // オーバーロードになっている
p.equals(p2)        : true
o.equals(p2)        : false      // o は Object 型で宣言
List.of(..).contains: false

起きていること:オーバーライドではなく新しいメソッドを足しただけなので、コレクションが呼ぶ equals(Object) は元の同一性比較のままです。直し方は必ず @Override を付けること。付ければ javac が error: equals(P) in P does not override or implement a method from a supertype で止めてくれます。

悪い hashCode のコスト

定数を返す hashCode は規約上は合法です(等しいものは同じハッシュになる)。ただし全キーが1つのバケットに入ります。n 個のキーを HashSet に入れて全部 contains する時間を、record の生成した hashCode と return 42 で比べました(JIT ウォームアップ後3回の最速値、OpenJDK 26、M1 Pro)。

n= 1,000  record hashCode:    0.21 ms   constant hashCode:       2.3 ms
n= 5,000  record hashCode:    0.17 ms   constant hashCode:      61.9 ms
n=20,000  record hashCode:    1.26 ms   constant hashCode:    1246.7 ms
ミリ秒の棒グラフ。5,000 キー:record 0.17、定数 hashCode 61.9。20,000 キー:record 1.26、定数 hashCode 1,246.7。定数 hashCode ではキー数が4倍で時間が約20倍になった。
HashSet に n 個のキーを追加し、全部を検索する時間。record が生成する hashCode と、規約上は合法な定数 hashCode(return 42)の比較。OpenJDK 26、Apple Silicon Mac (M1 Pro)、JIT ウォームアップ後 3 回の最速値。

3回の実行で大きい値のぶれは約4%(1,201〜1,247ms)。キーが4倍で時間は約20倍、20,000キーでは約1,000倍の差です。record 側は値が小さすぎて JIT のノイズが勝ち、5,000 の方が 1,000 より速く出ました。

record:契約が最初から守られたデータクラス

record はコンポーネントを1回書くだけで、private final フィールド、コンストラクタ、アクセサ(getX() ではなく x())、equals・hashCode・toString を互いに矛盾しない形で生成します。

record Point(int x, int y) {}

record Range(int lo, int hi) {
    Range {                                   // コンパクトコンストラクタ
        if (lo > hi) throw new IllegalArgumentException("lo > hi: " + lo + " > " + hi);
    }
    int length() { return hi - lo; }
}

record Tags(List<String> values) {
    Tags { values = List.copyOf(values); }    // 防御的コピー
}

Records.java の出力(main は英語版と同じ):

Point[x=1, y=2]
equals=true, sameHash=true, == false
HashSet size=2, contains(1,2)=true
x()=1, length=7
Tags[values=[java]]
values().add -> java.lang.UnsupportedOperationException
Exception in thread "main" java.lang.IllegalArgumentException: lo > hi: 10 > 3
    at Records$Range.<init>(Records.java:8)
  • コンパクトコンストラクタ(引数リストなしの Range { … })はフィールド代入の前に走ります。入力チェックや正規化の場所です。
  • record は「浅い」不変です。List.copyOf がなければ、呼び出し側が後で src.add("oops") した内容が record に漏れていました。コピーしておけば Map のキーにも安全に使えます。
  • record にインスタンスフィールドは追加できません。int z; を足すと error: field declaration must be static(ヒント:consider replacing field with record component)、メソッド内でコンポーネントに代入すると error: cannot assign a value to final variable x でした。
  • record はクラスを継承できません(暗黙に java.lang.Record を継承)。インターフェースの実装はできるので、後半の sealed の例で使います。

インターフェース・default メソッド・抽象クラス

インターフェースは「その型に何ができるか」を表します。Java 8 から default メソッドと static メソッドを持てますが、インスタンスの状態は持てません。抽象クラスはフィールドやコンストラクタ、final メソッドを持てる代わりに、1つしかない親クラスの枠を使います。

interface Notifier {
    void send(String to, String msg);                    // 抽象メソッド
    default void sendAll(List<String> to, String msg) {  // default メソッド
        for (String t : to) send(t, msg);
    }
    static Notifier console() {
        return (to, m) -> System.out.println("[console] " + to + ": " + m);
    }
}

static abstract class Job {
    private int runs;                                     // 状態はインターフェースに置けない
    protected final String name;
    Job(String name) { this.name = name; }
    public final void run() {                             // テンプレートメソッド
        runs++;
        System.out.println("start " + name + " #" + runs);
        doWork();
    }
    protected abstract void doWork();
}
[console] alice: deploy done
[console] bob: deploy done
start cleanup #1
  deleting temp files
start cleanup #2
  deleting temp files

出会うエラーはこの3つです。

Diamond.java:4: error: types A and B are incompatible;
  class C inherits unrelated defaults for hello() from types A and B
AbsNew.java:3: error: Half is not abstract and does not override abstract method doWork() in Job
AbsNew.java:4: error: Job is abstract; cannot be instantiated

1つめは2つのインターフェースが同じ名前の default メソッドを持つときです。オーバーライドして A.super.hello() のように明示的に選びます。

判断図。出発点は新しい型。問1:変更しないただのデータか。はいなら record。いいえなら問2:種類が決まった有限の集合か。はいなら、各ケースを record にした sealed interface。いいえなら問3:実装どうしで状態やコンストラクタ処理を共有するか。はいなら抽象クラス。いいえなら interface(共通の振る舞いは default メソッド)。
新しい型を record・sealed interface・抽象クラス・interface のどれで作るか。この順に問う。
状況 選ぶもの 理由
値で同一性が決まる不変データ(DTO、Map のキー、計算結果) record equals/hashCode/toString が自動で一貫
種類が決まっている(支払い方法、図形、API の結果) sealed interface + record switch の分岐漏れをコンパイラが検出
無関係なクラスが同じ能力を持つ interface(+ default) 複数実装できる
サブクラスがフィールドや処理手順を共有 抽象クラス インターフェースは状態を持てない
不変条件のある可変オブジェクト(口座、カート) 通常のクラス + private フィールド カプセル化。record ではない

sealed インターフェースとパターンマッチの switch

sealed インターフェースは実装クラスをすべて列挙します。するとコンパイラが switch の網羅性を検査できるので、新しい種類を追加したとき、対応を忘れた switch がすべてコンパイルエラーになります。実行時の分岐漏れではなくなるのです。

public class Shapes {
    sealed interface Shape permits Circle, Rect, Triangle {}
    record Circle(double r) implements Shape {}
    record Rect(double w, double h) implements Shape {}
    record Triangle(double b, double h) implements Shape {}

    static double area(Shape s) {
        return switch (s) {
            case Circle c -> Math.PI * c.r() * c.r();
            case Rect(double w, double h) -> w * h;           // record パターン
            case Triangle t when t.b() == 0 -> 0;              // ガード
            case Triangle t -> 0.5 * t.b() * t.h();
        };
    }

    public static void main(String[] args) {
        Shape[] all = { new Circle(1), new Rect(2, 3), new Triangle(4, 5) };
        for (Shape s : all) System.out.printf("%-30s area=%.2f%n", s, area(s));
        area(null);
    }
}
Circle[r=1.0]                  area=3.14
Rect[w=2.0, h=3.0]             area=6.00
Triangle[b=4.0, h=5.0]         area=10.00
Exception in thread "main" java.lang.NullPointerException
    at java.base/java.util.Objects.requireNonNull(Objects.java:220)
    at Shapes.area(Shapes.java:8)

default はありませんし、要りません。Triangle の2行を消すと javac が止まります。

Shapes.java:8: error: the switch expression does not cover all possible input values
        return switch (s) {
               ^

これが sealed にする理由です。もし default -> 0 と書いていたら、三角形の対応漏れはコンパイルが通り、面積0を黙って返していました。permits にない型を実装すると error: class is not allowed to extend sealed class: Shape (as it is not listed in its 'permits' clause) です。

実行して分かった細かい点が2つあります。パターンの switch は null で NullPointerException を投げます(case null -> … を足すと正常終了しました)。また、ガード付きの Triangle t when … は、ガードなしの Triangle t より前に書く必要があります。

「Java N から」を実際に確かめる

javac 26 で --release を下げてコンパイルしました。Shapes17.java は同じ sealed 階層を switch ではなく if (s instanceof Circle c) で書いたものです。

$ javac --release 15 Shapes17.java
error: sealed classes are not supported in -source 15
error: records are not supported in -source 15
  (use -source 16 or higher to enable records)
$ javac --release 16 Shapes17.java
error: sealed classes are not supported in -source 16
  (use -source 17 or higher to enable sealed classes)
$ javac --release 17 Shapes17.java && java -cp out Shapes17
Rect[w=2.0, h=3.0] 6.0
$ javac --release 17 Shapes.java
Shapes.java:9: error: patterns in switch statements are not supported in -source 17
  (use -source 21 or higher to enable patterns in switch statements)
Shapes.java:10: error: deconstruction patterns are not supported in -source 17

record は 16、sealed は 17、パターンの switch と record パターンは 21 からです。現場がまだ Java 17 なら sealed 階層は作れますが、instanceof の連鎖で書くことになり、網羅性チェックは得られません。

正直な限界とバージョンについて

  • すべて OpenJDK 26(Temurin 26+35)、Apple Silicon Mac (M1 Pro) でコンパイル・実行しました。--release は javac 26 で古い言語レベルを再現したもので、JDK 16・17・21 本体はインストールしていません。
  • ハッシュの計測はウォームアップ後の best-of-3 で、JMH ではありません。比率(20,000 キーで約1,000倍)として読んでください。
  • HashMap は長くなったバケットを木構造に変えますが、効くのはキーが Comparable のときです。今回の Bad キーはそうではないので最悪のケースです。

前回: Java講座 1/6:型・文字列・等価性。次回: Java講座 3/6:コレクションと性能。関連:Python 講座 2、VB.NET 講座 3:クラス・継承・インターフェース、C# の インターフェース。

よくある質問

equals だけオーバーライドして hashCode を忘れると何が起きますか?

等しいはずの2つのオブジェクトが別々のハッシュ値になり、HashSet に両方入り、contains も false になります。同じ点を2回 add する試行を10,000回行うと、10,000回すべてでサイズが2になりました。一方 ArrayList.contains は true なので、リストで書いたテストは通ってしまいます。javac -Xlint:all で [overrides] 警告が出ます。

record と普通のクラスはどう使い分けますか?

値で同一性が決まる不変のデータなら record です。コンストラクタ・アクセサ・equals・hashCode・toString が自動生成され、コンパクトコンストラクタで検証もできます。状態が変わるもの、隠したいフィールドがあるもの、他のクラスを継承したいものは通常のクラスです。record は Java 16 で正式化され、--release 15 ではコンパイルできませんでした。

sealed とパターンマッチの switch は Java のどのバージョンから使えますか?

sealed クラス・インターフェースは Java 17、switch のパターンマッチと record の分解パターンは Java 21 からです。--release 17 では patterns in switch statements are not supported in -source 17 というエラーになりました。どちらも javac 26 の --release で確認しました。

  • #Java
  • #初級
  • #OpenJDK 26
  • #record
  • #sealed
  • #インターフェース
  • #equals
  • #hashCode

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑