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
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() のように明示的に選びます。
| 状況 | 選ぶもの | 理由 |
|---|---|---|
| 値で同一性が決まる不変データ(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 で確認しました。