私が手がけたすべてのコードベースで動いているJavaバージョンを正確に言える。ほとんどを自分で書いたからだ。2009年に大学でJ2MEにJava 6。BluLogixとAFAQでJava 7と8。途中でJava 12。BytroからはJava 17。2020年からはKotlinを日常的に使っている。

これは言語仕様の要約ではない。現役エンジニアとして、各変曲点で実際に何が変わったか — そして途中の暗黒時代がどんな感じだったか — の話だ。

Java 6:J2ME時代、別名・苦痛

初めてのJavaはJ2ME — Java Micro Edition — で、2009年頃に大学でフィーチャーフォン向けのモバイルアプリを書いていた。J2MEを書いたことがなければ、状況を描写しよう:使えるサブセットにジェネリクスはなく、Collections APIも完全ではなく、デバイスによって画面の抽象化が異なり、バイトとサイズを現代のエンジニアが決して知らないほど親密に知らせるパッケージング形式(MIDletとしてのJAR)があった。

制約こそが要点だった。64KBを超えるJ2MEアプリは特定のオペレーターに却下された。誰にも説明なしには見せられないレイアウトコードを書いた。エミュレーターは誤解させるほど間違っていたので実機でデバッグした。2MBのヒープを持つNokiaの端末で動くアプリを出荷した。

ここで学んだこと:JVMの抽象化はタダではない。何を払っているかを正確に理解すれば安くできる。その教訓はそれ以来毎日役に立っている。

Java 7:try-with-resourcesが三つのバグクラスを一掃した

Java 7は2011年にリリースされた。BluLogix(2013年)でプロとして使い始めたとき、即座に好きになったアップグレードはtry-with-resourcesだった。

これ以前、Javaでリソースをクローズすることは特定の税金だった:finallyブロックが必要で、それ自体にnullチェックが必要だった。リソースがオープンされていないかもしれないし、クローズがスローするかもしれないし、すでに例外の中でクローズがスローしたら元の例外を飲み込んでしまい、幽霊をデバッグしている。パターンは広く知られていて普遍的に嫌われていた:

InputStream is = null;
try {
    is = new FileInputStream(path);
    // do stuff
} catch (IOException e) {
    // handle
} finally {
    if (is != null) {
        try { is.close(); } catch (IOException ignored) {}
    }
}

Java 7では:

try (InputStream is = new FileInputStream(path)) {
    // do stuff
}

これはマイナーな便利機能ではない。以前は規律と警戒が必要だったリソースのライフサイクルをコンパイラが強制する。古いコードベースをtry-with-resourcesに移行することで少なくともダース単位のリソースリークバグを修正したと推定する。それ以来一つも書いていない。

ダイアモンド演算子(<>)もJava 7で登場した — new ArrayList<String>()の代わりにnew ArrayList<>()と書ける。今から見れば笑えるほど小さい。当時は、一日30回遭遇していた特定の摩擦を取り除いた。

Java 8:実際に考え方を書き直した

Java 8(2014年)は私のJavaキャリアで最大の変曲点だ。ラムダの抽象論のためではなく。ラムダが実践的に何を可能にしたか — Streams API、Optional、メソッド参照 — そして三つの組み合わせがデータ変換について推論する方法を変えたからだ。

Java 8以前、Javaでリストを処理するのはfor-eachループ、ローカルアキュムレータ、すべてのアクセスにnullチェック、明示的なコレクション構築だった。コードは正しいが、一目で意図を読みとりにくいほど冗長だった。

Java 8後:

orders.stream()
    .filter(o -> o.getStatus() == ACTIVE)
    .map(Order::getTotal)
    .reduce(BigDecimal.ZERO, BigDecimal::add);

ロジックはパイプラインの中にある。上から下に読めば、データ変換の形が見える。アキュムレーションは暗黙的だ。フィルター、マップ、リデュースは明確なセマンティクスを持つ名前付き操作だ。

実際に気づいたこと:コードレビューで別の会話が始まった。「このループはおかしい、トレースしよう」から「なぜここでマテリアライズするの、レイジーのままでいられない?」「このフィルター+マップはflatMapにできる」に変わった。プリミティブが何について議論するかを変えた。

Optionalは議論を呼んだ。今もそうだ。不在が意味的に重要で呼び出し元にその不在に向き合わせたい戻り値の型には好きだ。フィールド型、メソッドパラメータ、コレクション要素としてOptionalは好きではない。これが、私が思うに、正しい立場だ。完全にたどり着くのに二年かかった。

Java 9モジュール時代:伝説的な混乱

Java 9はJava Platform Module System(JPMS)を出荷した。理論的には:明示的なモジュール境界を宣言し、モジュールが公開するパッケージを制御し、より保守可能な大規模Javaアプリケーションを構築する方法。実践的には:依存関係を持つ誰もにとっても六年間の苦痛。

問題はエコシステムだった。JPMSはすべてのライブラリが適切な名前付きモジュールとして出荷するか、ビルド間で変わりうる発見的に導出されたモジュール名を持つ「自動モジュール」として扱われるかを要求した。Javaエコシステムの大部分は準備ができていなかった。結果:プロジェクトに--module-pathフラグを追加し、その後ライブラリが依存するリフレクション用のスプリットパッケージの競合、名前なしモジュール、--add-opensの呪文を解決するために一日を費やす。

--add-opens java.base/java.lang=ALL-UNNAMEDを数えきれないほど書いた。やるたびに魂の一部が去っていく。

Java 10、11、12それぞれが周辺部分を改善した。ローカル変数型推論(var)がJava 10で登場し、すぐに採用した — 文字数の節約ではなく、コンストラクタがすでに何であるかを言っているときに型を二回書かなくて済むからだ:

var connections = new HashMap<String, ConnectionPool>();

モジュールの状況はゆっくりと改善した。Spring、Hibernate、その他の主要なものが態勢を整えた。Java 17になる頃には、JPMSのドラマはアクティブなインシデントというよりほぼバックグラウンドノイズになっていた。

Java 17:ついに現代的なJava

Java 17はLTSリリース(2021年9月)で、Javaをトータルで見て「これはモダンな言語だ」と思う最初のバージョンだ。

レコード:

record Point(double x, double y) {}

それだけだ。イミュータブルな値型、equalshashCodetoStringがすべて生成される。Lombokは不要。40行のクラスも不要。データが何であるかの宣言だけ。今ではレコードをあらゆる場所で使う — DTO、コマンドオブジェクト、イベントペイロード、ドメイン値オブジェクト。Java値型のボイラープレートコストが「うんざり」から「ゼロ」になった。

封印クラス+パターンマッチング:

sealed interface Shape permits Circle, Rectangle {}

double area = switch (shape) {
    case Circle c -> Math.PI * c.radius() * c.radius();
    case Rectangle r -> r.width() * r.height();
};

コンパイラはShapeCircleRectangleのみであることを知っている。三つ目のpermits型を追加してswitchでの処理を忘れると、コンパイルエラーが出る。これはJavaに代数的データ型が登場したということだ。MLが25年前から持っていたものが。遅ればせながら — Javaバージョンは実用的で、慣用的で、既存の型システムとクリーンに統合される。

テキストブロック — エスケープ地獄なしの複数行文字列リテラル — はJava 15で登場し、SQL、テストのJSONスニペット、2行を超える文字列に常に使っている。

Kotlinの台頭

正直に言いたい:Java 17がレコードと封印型で登場した頃、私はすでに二年間BytroでKotlinを書いていて、期待が再調整されていた。データクラス。型システムに組み込まれたNull安全性。拡張関数。非同期用コルーチン。instanceofチェーンの半分を排除するスマートキャスト。

KotlinはJavaの影の自己だ — 1995年ではなく2012年頃に一から設計されていたらJavaはこうなっていた、というもの。JVM上で動き、Javaとほぼ完全に相互運用できる。Javaがバージョン17まで修正するのにかかるほとんどのことを修正する。

Kotlinを書きたいときはKotlinを書く。プロジェクトがJavaで言語依存関係を導入したくないときはJava 17を書く。どちらも日常的に使う。両方の下にあるJVMは同じJVMだ。つまり、GCチューニングの知識、ヒープ診断スキル、スレッドダンプの読み方、JXMモニタリング — すべてが移転する。JVMは永続的な投資で、JavaとKotlinはその上の言語レイヤーだ。

JVM上で15年間が実際に教えてくれること

正直な答え:アロケーションに驚かなくなり、それについて戦略的になる。数字で擁護できるガベージコレクターのポーズについて意見を持つようになる。Stringインターニングがなぜ重要で、いつ重要でいつそうでないかを正確に理解する。

言語の改善は本物だ。Java 17はJava 6よりも圧倒的に書きやすい。しかし根本的なスキル — コストモデルの理解、JVM診断の読み方、スレッドセーフティについての推論、テスト可能性のための設計 — は構文が変わるほどには変わらない。

2009年のJ2MEがバイトを数えることを教えてくれた。2014年のStreamがパイプラインで考えることを教えてくれた。2021年のレコードが、言語がようやく私が数年間値型について考えてきた方法に追いついたことを告げた。

私はまだここにいる。JVMはまだここにいる。JavaはVersion 21(LTS)になった。仮想スレッドについて意見がある(Project Loomは本物で良い)。Java 25で何が出荷されようとも意見を持つだろう。

これは、私が思うに、プラットフォームとの15年間の関係がどんな様子か、ということだ。