この記事は セゾンテクノロジー Advent Calendar 2024 (シリーズ 1) 25日目の記事です。
はじめに
Java 開発者のみなさん、もしかしてまだ Java 8 使っていますか?
新バージョン移行のあまりの大変さで、いまだに新バージョンに移行できず Java 8 を使い続けているプロジェクトも少なくないから、ちょっと気まずい質問だったかもしれませんね。1
とはいえ、新しいバージョンでは色々と開発者にとって嬉しい変更が追加されたため、使いこなせるとソースコードの表現力が豊かになり、結果的にメンテナンス性と生産性の向上にもつながるから、それだけでもアップデートする価値は大いにあると思います。
この記事では Java 21 までのそういったソースコードの表現力に関わる変更点を取り上げて、従来の書き方と変更後の書き方を比較しながら紹介していきたいと思います。
開発者にとって嬉しい言語仕様変更
ここから自分にとって使いやすく、便利に感じた、Java 21 までの言語仕様変更点をバージョン順と対応する JEP の番号順で紹介していきます。(Preview はここで取り上げません)
深堀りしたい方向けの参考として、元の JEP へのリンクも併記します。
9: インタフェースのプライベートメソッド
Java 8 でインタフェースに default
と static
メソッドを定義できるようになりました。
// java.util.function の関数インタフェースは default と static を活用している
public interface Function<T, R> {
R apply(T t);
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) { ... }
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) { ... }
static <T> Function<T, T> identity() { ... }
}
その延長線上の機能拡張として、Java 9 ではインタフェース内に private
メソッドを定義できるようになりました。
この変更によってインタフェースの複数デフォルトメソッドや静的メソッドにまたがってロジックを再利用したいときに、プライベートメソッドにすることで余計な API を公開せずに実現できます。
// Java 8
interface ByteArrayGetter {
default byte[] fromString(String s) {
return s.getBytes(Charset.forName("MS932")); // デフォルトの Charset を取得。
}
default byte[] fromMyString(MyString ms) {
return ms.getBytes(Charset.forName("MS932")); // デフォルトの Charset を取得。都度書くのはだるい :(
}
// static Charset defaultCharset() { // private メソッド定義できないから処理を共通化すると余計な API が公開されてしまう :(
// return Charset.forName("MS932");
// }
}
// Java 9 以降
interface ByteArrayGetter {
default byte[] fromString(String s) {
return s.getBytes(defaultCharset());
}
default byte[] fromMyString(MyString ms) {
return ms.getBytes(defaultCharset());
}
private static Charset defaultCharset() { // 余計な API を公開せずに処理を共通化できる :D
return Charset.forName("MS932");
}
}
9: try-with-resources 式の簡潔な書き方
インタフェースのプライベートメソッドと同じプロジェクト Coin の一環として、Java 9 では try-with-resources の簡潔な書き方が導入されました。
この新しい書き方を使うことで、try (...)
から離れたところに宣言されたリソースを、ローカル変数への再代入なしで try (...)
内で使用できるようになります。
// Java 8
CloseableResource resource = ...;
try (CloseableResource r = resource) { // リソースとして扱うのにローカル変数への代入が必要
...
}
// Java 9 以降
CloseableResource resource = ...;
try (resource) { // 宣言済みのローカル変数をリソースとして参照できる
...
}
並べて比較してみると、文法としては確かに便利になりましたね。
しかし、その前段となるリソースの宣言と利用箇所の分割はあまり行儀のいい書き方ではないと思います。(あくまでも個人的な意見ですが)
なぜかというと、リソースを表す変数(サンプルコードの resource
)のスコープが try
ブロックよりも広いので、try
ブロックの後(すなわちリソースがクローズした後)でも利用可能で、メソッドの呼び出しができます。
メタ情報取得のようなメソッドだったら、リソースとしての振る舞いと関係ないから問題ないが、そうでないメソッドを利用すると想定外の動きになります。(大抵の場合は例外が発生すると思います)
CloseableResource resource = ...;
try (resource) { // リソースとして利用開始
...
} // リソースとして利用終了、自動的にクローズされる
resource.getSomething(); // クローズ済みだけどアクセスして大丈夫か?
特殊な事情がない限り、try-with-resources の範囲内でリソースを宣言するのが安全な書き方だと思います。
// リソースを表す変数のスコープは try ブロックと同じなので余計な心配は不要
try (CloseableResource resource = ...) {
...
}
9: 変更不可なコレクション作成に便利なファクトリメソッド
JEP 269: Convenience Factory Methods for Collections
クラスを作る時、たまに変更不可なコレクションやマップを定数として定義したい場面に出くわしますよね。
Java 8 までの基本的なやり方としては、1) コレクションやマップを new
して、2) 中身を入れて、3) Collections
の読み取り専用ビュー作成メソッド unmodifiableXxx
などでラップする、という一連の定型的な作業が必要です。(リストの場合は Arrays.asList
が使えるけど流れとして同じです)
// Java 8
private final static Map<Integer, String> japaneseMonthNames;
static {
// 1) 入れ物を new する
Map<Integer, String> map = new HashMap<>();
// 2) 中身を入れる
map.put(1, "睦月");
...
map.put(12, "師走");
// 3) 読み取り専用ビューにする
japaneseMonthNames = Collections.unmodifiableMap(map);
}
Java 9 ではこういった変更不可のコレクションやマップを作る手段として、いくつか便利なファクトリメソッドが追加されました。
-
java.util.List
-
of()
: 空のリスト -
of(E)
of(E, E)
...:要素 1 個から 10 個までのリスト -
of(E ...)
:もっと多い要素を持つリスト(可変長引数)
-
-
java.util.Set
-
of()
: 空のセット -
of(E)
of(E, E)
...:要素 1 個から 10 個までのセット -
of(E ...)
:もっと多い要素を持つセット(可変長引数)
-
-
java.util.Map
-
of()
: 空のマップ -
of(K, V)
of(K, V, K, V)
...:キーと値が1ペアから 10 ペアまでのマップ -
ofEntries(Map.Entry<K, V> ...)
: キーと値のペアをMap.entry
として指定するマップ(可変長引数) -
entry(K, V)
:キーと値のペアを持つMap.Entry
-
これらのファクトリメソッドを利用することで、少ない記述量で同じことができるようになりました。
// Java 9 以降
private final static Map<Integer, String> japaneseMonthNames = Map.ofEntries(
Map.entry(1, "睦月"),
....
Map.entry(12, "師走")
);
注意点として、これらのメソッドはすべて null
を許容しないので、要素やキーと値に null
入れたい場合は利用できません。
10: ローカル変数の型推定
JEP 286: Local-Variable Type Inference
Java 10 以降、ローカル変数の型推定が可能な場合、型宣言の代わりに新しいキーワード var
が使えるようになりました。
// Java 10 以降
void doSomething() {
var now = Instant.now();
...
}
型宣言がなくなって記述がすっきりする一方、その分ソースコードを読み解く際の手がかりも減ってしまうので、変数定義と利用箇所が離れていると一気に読みづらくなります。
// 一件問題なさそうだけど実は場合によってちょっと危ない変数名
var list = page.getTodoList();
... // 数十行のコード
// あれ?この list ってなんのリストだっけ?
var item = list.stream().filter(x -> x.isDone()).findFirst();
item.ifPresent(...);
var
利用による可読性低下の対策として、読み手が型や意味合いを推測できるように変数名を工夫することで、表現力を損なわずに関心事の目立つ簡潔なコードに仕上がります。
// 変数名を工夫してみた
var todoList = currentPage.getTodoList();
... // 数十行のコード
// この todoList はきっと List<TodoItem> だね!
var latestDoneItem = todoList.stream().filter(TodoItem::isDone).findFirst();
latestDoneItem.ifPresent(...);
実際に var
をプロダクトコードで利用する場合はチーム内でルールや規約をしっかり定めたうえでの運用をおすすめします。
その際は Java 10 のリリースとともに公開された『Style Guidelines for Local Variable Type Inference in Java』 が役に立つと思うので、ぜひご参考ください。
11: ラムダ式の仮引数として var の使用
JEP 323: Local-Variable Syntax for Lambda Parameters
Java 10 で導入されたローカル変数の型推定に続いて、Java 11 ではラダム式の仮引数として var
を使用できるようになりました。
// Java 10 まで
BiConsumer<String, Integer> consumer1 = (s, i) -> { ... };
BiConsumer<String, Integer> consumer2 = (String s, Integer i) -> { ... };
// Java 11 以降
BiConsumer<String, Integer> consumer1 = (var s, var i) -> { ... };
これらのサンプルを見ると「別になくても困らなくない?」と思うかもしれないけど、ラムダ式の引数にアノテーションをつけたいときに、var
と併用することで簡潔な形で記述できるようになります。
void doSomething(Map<String, Integer> map) {
// ラムダ式の仮引数にアノテーションをつけるときに便利
map.forEach((@NotEmpty var s, @Range(min=1, max=12) var i) -> { ... });
}
実際使い場面は少なさそうですが、アノテーションをベースにした静的コード解析を活用するプロジェクトであれば役に立つと思います。
14: スイッチ式
Java 14 から switch
式が導入されて、今までの switch
文をより簡潔な形を表現できるようになりました。
従来の case ラベル:
の形に加えて、新しい case ラベル ->
の形で実行するコードを直接記載することが可能で、その際の break;
は不要になります。
一つの case
に複数のラベルをまとめることもできるので、バグの温床になりやすい fall-through を一掃できます。
static void howMany(int k) {
switch (k) {
case 1 -> System.out.println("one"); // break 不要 :D
case 2, 3 -> System.out.println("two or three"); // fall through も要らない :D
default -> System.out.println("many");
}
}
また、switch
のブロックがまるごと一つの式として扱うことができるので、式の評価結果を戻り値として代入できるようになってます。
複数行の処理が必要な場合、戻り値は新しいキーワード yield
で返します。
int j = switch (day) {
case MONDAY -> 0;
case TUESDAY -> 1;
default -> {
int k = day.toString().length();
int result = f(k);
yield result;
}
};
従来の case ラベル:
の書き方で値を返したい場合も同じ yield
を使います。
int result = switch (s) {
case "Foo":
yield 1;
case "Bar":
yield 2;
default:
System.out.println("Neither Foo nor Bar, hmmm...");
yield 0;
};
15: テキストブロック
Java 15 から、複数行にわたる文字列を定義する手段として、テキストブロックが導入されて、以下の2つの書き方で定義した文字列は同じ値になります。
// Java 14 まで
String lines = "first line\nsecond line\nthird line\n";
// Java 15 以降
String lines = """
first line
second line
third line
""";
上記のサンプルで分かるように、テキストブロックは開始デリミタ(opening delimiter)と終了デリミタ(closing delimiter)である """
間に囲まれた複数行の文字列です。
テキストブロックの記述ルールを簡単にまとめると以下になります。
- 開始デリミタの直後に改行が必須(改行がない場合はエラー)
- 終了デリミタの直前の改行は省略可能
- テキストブロックの左端とインデントの解釈は、ブロック内一番左にある文字または終了デリミタ
また、テキストブロックの機能の一部として String
クラスにいくつかのメソッドが追加されました。
-
String::formatted(Object... args)
:テキストブロックを書式として使う -
String::stripIndent()
:Java コンパイラと同じアルゴリズムで複数行の文字列から偶然のホワイトスペースを削除する -
String::translateEscapes()
:エスケープシーケンスを処理する
詳しいサンプルと使い方については 『Programmer's Guide to Text Blocks』 にご参照ください。
16: instanceof でのパターンマッチング
JEP 394: Pattern Matching for instanceof
Java 16 から instanceof
演算子を利用する際に、対象クラスへのキャストを同時に行うことができるので、instanceof
で判定後に自前のキャスト文記述が不要になりました。
このように instanceof
の判定と同時宣言されたローカル変数はパターン変数(patttern variables)と言います。
// Java 16 より前、お馴染みの instanceof-and-cast イディオム、ちょっとだるいよね :(
boolean equals(Object obj) {
if (obj instanceof Point) {
Point p = (Point) obj;
return x == p.x && y == p.y;
}
return false;
}
// Java 16 以降は instanceof の判定とパターン変数の宣言が一緒にできる :D
boolean equals(Object obj) {
if (obj instanceof Point p) {
return x == p.x && y == p.y;
}
return false;
}
パターン変数は実行フローを意識した "flow scoping" という特殊なスコープを持ちます。
変数の有効範囲は単純に {}
で囲まれたブロックではなく、意味合いと論理的に連続している実行フロー内であれば変数はずっと有効です。
例えば instanceof
判定の直後に &&
演算子で繋いだ式が続く場合、instanceof
の判定が成り立つためパターン変数は &&
演算子の後でも利用できます。
逆に ||
演算子で繋いだ場合、instanceof
の判定が必ずしも成り立つとは限らないので、パターン変数を使用するとエラーとなります。
// パターン変数のスコープは && 演算子のあとに及ぶ
if (obj instanceof String s && s.length() > 5) {
...
}
// パターン変数のスコープは || 演算子のあとに及ばない
if (obj instanceof String s || s.length() > 5) { // エラー
...
}
以下のように、instanceof
判定の条件式反転と早期リターンが合わさった場合、パターン変数のスコープは if
文の外になります。
一見直感に反しますが、条件式反転とともにパターン変数のスコープも一緒に反転したと考えれば理解しやすいです。
boolean isEmptyString(Object obj) {
if (!(obj instanceof String s)) {
// パターン変数のスコープ外
return false;
}
// パターン変数のスコープ内
return s.isEmpty();
}
// 条件式反転前の形
boolean isEmptyString(Object obj) {
if (obj instanceof String s) {
// パターン変数のスコープ内、条件式反転されると if 文の外に行ってしまう
return s.isEmpty();
}
// パターン変数のスコープ外、条件式反転されると if 文の中に入ってしまう
return false;
}
instanceof
のパターンマッチングを利用すると、クラス名の重複記載が省けられるからコードがすっきりします。
今までの instanceof-and-cast イディオムを冗長に感じる方はぜひ活用してほしい機能です。
16: レコードクラス
Java 16 から不変な値クラスを定義・作成する手段として、レコードクラス(records)が導入されました。
従来の手法として、不変な値クラスを作成しようとする際、すべてのフィールドの不変性に気を配りながら、アクセッサを含む定型化された処理を自前で実装する必要がありました。
この作業をサポートするための IDE 機能やライブラリは存在しているが、コード量自体は減らない(ライブラリによってコード量が減る代わりにアノテーションが挿入される)ので、どうしても関心事への集中は薄めてしまいます。
// Java 16 より前、定型化されたコードがたくさん必要 :(
final class Point {
private final int x;
private final int y;
Point(int x, int y) {
...
}
// 本当の関心事はこれらのアクセッサによって取得した値
int x() { return x; }
int y() { return y; }
public boolean equals(Object o) { ... }
public int hashCode() { ... }
public String toString() { ... }
}
レコードクラスの導入よって、不変なクラスの作成が格段楽になりました。
不変なクラスとして設計されたレコードクラスは暗黙的に final
なので、final
修飾子なしでも派生クラスは作れません。
// Java 16 以降はこれだけでいける :D
record Point(int x, int y) {}
レコードクラスの宣言は主に record
キーワードと、クラスの名前、ヘッダー、ボディで構成されます。
上記のサンプルでは (int x, int y)
がヘッダーで、{}
がボディに該当します。
ジェネリックなレコード定義もインタフェースの実装も可能で、その場合は以下のような形になります。
record Pair<K, V>(K key, V value) implements Serializable {}
ヘッダーの中に記述されたクラス内の構成要素リスト(サンプルの int x, int y
や K key, V value
の部分)は状態記述(state description)とも言います。
レコードクラスを定義すると、状態記述の内容をもとに、クラス内の不変なフィールドとアクセッサ、標準コンストラクタ(canonical constructor)、equals
と hashCode
、 toString
などのメンバーが自動的に宣言されます。(必要があればメンバーメソッドのオーバーライドも可能)
Serializable
インタフェースの実装を宣言するレコードクラスの場合、自動的にシリアライズ・デシリアライズ可能になるが、シリアライズ処理とデシリアライズ処理はそれぞれ構成要素と標準コンストラクタによって制御されるのでカスタマイズはできません。
一般クラスのデフォルトコンストラクタと違って、レコードクラスの標準コンストラクタは状態記述と同じシグネチャ持っています。
デフォルトの振る舞いはコンストラクタ引数をそのまま内部のフィールドに入れるだけだが、もし何かしらの前処理(例えば検証や計算など)が必要な場合は標準コンストラクタを自前で実装できます。(実装する際は引数の記述省略可能)
標準コンストラクタ以外のコンストラクタの宣言に関しては通常クラスのそれと同じ扱いになるが、実装としては標準コンストラクタへ委譲しなければなりません。
record Fraction(int num, int denom) {
Fraction { // 暗黙的に状態記述と同じシグネチャ (int num, int denom) を持つ
int gcd = gcd(num, denom);
num /= gcd; // メンバーフィールドではなく、引数に対する計算と再代入
denom /= gcd;
// コンストラクタの最後にメンバーフィールドに対して暗黙的に代入
}
Fraction(int whole, int num, int denom) { // 追加のコンストラクタは定義可能
this(whole * denom + num, denom); // 標準コンストラクタ以外は委譲必須
}
}
ちなみにレコードクラスは、メソッドの中でそのメソッドのスコープ内のみ有効なローカルレコードクラスとして定義することができます。
こういったローカルレコードクラスの想定される使い方は、可読性を向上させるための、一時的な値や処理途中の中間値を保持するヘルパークラスが考えられます。
Merchant findTopMerchant(List<Merchant> merchants, int month) { // 月次最高売上の業者を探す
// 業者の月次個人売上を表すローカルレコードクラス
record MerchantSales(Merchant merchant, double sales) implements Comparable<MerchantSales> { ... }
return merchants.stream()
.map(merchant -> new MerchantSales(merchant, computeSales(merchant, month)))
.sorted()
.findFirst()
.map(MerchantSales::merchant)
.get();
}
なお、ローカルレコードクラスの導入とともに、ローカル enum クラスとローカルインタフェースも定義可能になりました。
17: シールクラス
Java 17 でサブクラスの継承を制限するための修飾子 sealed
が追加されました。
permits
キーワードと併用することで継承を許容するサブクラスを明示的に示すことができます。
package com.example.geometry;
public abstract sealed class Shape
permits Circle, Rectangle, Square { ... }
permits
の対象範囲はスーパークラスの同じモジュール内(スーパークラスが名前付きモジュールに属する場合)、または同じパッケージ内(スーパークラスが無名モジュールに属する場合)のものに限定されます。
サブクラスが別パッケージに属する場合、permits
は FQCN で記述します。
package com.example.geometry;
public abstract sealed class Shape
permits com.example.polar.Circle,
com.example.quad.Rectangle,
com.example.quad.simple.Square { ... }
もしサブクラスが小さく、数も少ない場合、同一ファイル内にサブクラスを内部クラスまたは補助クラスとして置くと permits
は省略可能になります。
abstract sealed class Root { ...
final class InnerSubclass extends Root { ... }
}
final class AuxiliarySubclass extends Root { ... }
後述のスイッチ文のパターンマッチングと併用することで、シールクラスを対象にした switch
文では分岐の網羅性を担保されます。(サブクラスすべて既知なので)
個人的な所感として、sealed
修飾子を使ってクラスを定義する場面は稀な気がするが、スイッチ文でのパターンマッチングと併用するコードを見かける機会は今後増えてきそうなので、パターンマッチング周りの振る舞いを抑えておくと損はないと思います。
21: レコードパターン
Java 21 ではパターンマッチング関連の機能が強化されて、そのうちの一つはレコードクラスを対象にしたパターンのサポートです。
レコードクラスと対象する instanceof
でのパターンマッチングにおいて、レコードクラスのパターン変数を置かずに、レコードの構成要素をパターン変数とすることができます。
record Point(int x, int y) {}
// Java 21 からはレコードの構成要素を直接にパターン変数として扱うことができる
boolean equivalentPoints(Object o) {
return (o instanceof Point(int px, int py)) && x == px && y == py;
}
ジェネリックなレコードの場合、タイプパラメータなし(raw type のまま)でマッチングする場合、タイプパラメータに対する型推論が行われるので var
の利用も可能です。
record MyPair<S, T>(S first, T second) {};
static void recordInference(MyPair<String, Integer> pair){
switch (pair) {
case MyPair(var f, var s) -> { ... } // MyPair<String,Integer>(var f, var s) として扱う
}
}
レコードが複雑な入れ子構造の場合でも、内部の深い構成要素を簡単に取得できます。
そして型推論も効くのでタイプパラメータを省略して更に簡潔な記述にできます。
// As of Java 21
record Box<T>(T t) {}
static void test1(Box<Box<String>> bbs) {
if (bbs instanceof Box<Box<String>>(Box(var s))) {
System.out.println("String " + s);
}
}
// コンパイラは instanceof のパターンを Box<Box<String>> として推論
static void test2(Box<Box<String>> bbs) {
if (bbs instanceof Box(Box(var s))) {
System.out.println("String " + s);
}
}
21: スイッチ文でのパターンマッチング
JEP 441: Pattern Matching for switch
Java 21 で強化されたパターンマッチング関連機能のもう一つが switch
文のパターンマッチングです。
ざっくりいうと switch
文の中に instanceof
でのパターンマッチングみたいなことができるようになったイメージです。
null
も扱えるようになったので、従来の switch
文の前の null
処理を switch
文の分岐の一つとして包含できます。
// Java 21 以降は case 内でパターン変数を宣言できて、null も case として記載できる
public static void printObject(Object obj) {
String formatted = switch (obj) {
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);
case null -> null;
default -> obj.toString();
};
System.out.println(formatted);
}
case null
のない switch
文に null
が入る場合、従来の振る舞い通り NullPointerException
が発生します。
この動きを「NullPointerException
をスローする case null
」として捉えれば、従来の振る舞いも例外的な措置ではなく直感的なものだと理解できるでしょう。
// obj が null の場合は NullPointerException 発生
static void nullMatch2(Object obj) {
switch (obj) {
case String s -> System.out.println("String: " + s);
case Integer i -> System.out.println("Integer");
// case null -> throw new NullPointerException(); // case null 省略時のデフォルトの動作
default -> System.out.println("default");
}
}
パターン変数の値に対して、さらに分岐を細分化するための when
キーワードも追加されました。
これによって今まで case
ブロック内に記述した条件分岐もすべて switch
の case
に統合できるので、よりも見通しのいいコードが書けます。
static void checkResponse(String response) {
switch (response) {
case String s
when s.equalsIgnoreCase("YES") -> System.out.println("次に進んでください。");
case String s
when s.equalsIgnoreCase("NO") -> System.out.println("最初からやり直してください。");
case String s -> System.out.println("「YES」か「NO」で答えてください。");
}
}
シールクラスを対象にする際、 case
記述できるサブクラスはすべて既知なので、switch
文の分岐の網羅性が担保されます。
サブクラスがすべて網羅された場合は default
が不要になり、漏れたサブクラスがある場合はエラーとして検出してくれます。
sealed interface S permits A, B, C {}
final class A implements S {}
final class B implements S {}
record C(int i) implements S {} // 暗黙的 final
static int testSealedExhaustive1(S s) {
return switch (s) {
case A a -> 1;
case B b -> 2;
case C c -> 3;
// S のサブクラスを網羅したので default は不要
};
}
static int testSealedExhaustive2(S s) {
return switch (s) {
case A a -> 1;
case B b -> 2;
// C の処理が抜けて、default もないのでエラーとして検出される
};
}
まとめ
Java 21 までの文法関連の新機能を一通り紹介しましたが、如何だったでしょうか?
新しめの言語と比べて、記述が冗長で定型文も多かった Java 8 ですが、リリースごとの試行錯誤と改善が積み重ねた結果、今ではモダンで使いやすい Java 21 になりました。
取り上げた新機能の中には複雑なものもあって、掘り下げようとすると一機能分だけで記事何本か書けるぐらいなので、ここでは概要程度の説明にとどまりました。
さらに詳しい情報を知りたい方はぜひご自分でそれぞれ機能を試してみてください。
この記事が Java 21 の理解を深めることに少しでもお役に立てれば幸いです。
Java 8 ユーザのみなさん、一日も早く新しい Java に移行できることをお祈り申し上げます。
-
2024 年の New Relic 社のレポート によると、約三割ぐらいのプロジェクトはまだ Java 8 を使っています。 ↩