はじめに
この記事は J2SE 1.4 で書かれた基幹系システムを Java SE 21 にバージョンアップした際の体験談的備忘録です。
「既存ロジックは一切変更したくないが、バージョンアップには追従してほしい」という、リファクタリングとも言えないような微妙な要件に対する現実的な落としどころを模索したものです。
ほとんどポエムのような内容ですが、何かのお役に立てば幸いです。
レガシーなJavaシステムに対し、変換ツールやAIコードエージェントを活用して云々といったようなバージョンアップの方法論を示すものではありませんのでご注意ください。
1.4時代のコード
Java5ならまだしも、それ以前のコードとなると新しいJavaへと綺麗に対応させるのは難しいです。
目ぼしいところだけ挙げても、ジェネリクス・アノテーション・Enum・拡張for文 …などといった必須級の機能がありません。
コードを少し読むだけでも当時のエンジニアによる凄まじい工夫に思いを馳せることができます。
実はそのまま動く
Javaをバージョンアップすると言語機能の追加によりコードの表現力は増しますが、古い書き方でも概ね問題ありません。
そのため、削除されたAPIや移動したパッケージ(例:javax→jakarta)さえなんとかすれば、いきなりコンパイラを21に差し替えたとしても大部分のコードはそのまま動作するはずです。
Javaの後方互換性の強さに感心しつつも、コマンドラインを見やると信じられない量の警告が吐き出されています。
せっかくコンパイラさんが頑張って警告してくれているので、できる限りなんとかしてあげたいと考えるのがエンジニアの性というものです。(※諸説あり)
バージョンアップに追従する
警告のバリエーションは意外にも少なく、9割はraw型の使用に対する警告でした。1.4にジェネリクスは存在しないため、コードのいたるところで型引数が要求されることになります。
つまり、バージョンアップといっても「既存ロジックは一切変更しない」という要件の下では、raw型をジェネリック化して型安全性を高めていくことに終始するわけです。
ジェネリック化のコツ
主にジェネリック化の対象となるのはListやMapで、基本的に周辺のコードから型引数を推論していくしかありません。
型引数のネストが深くなるなら専用クラスでラッピングしたくなりますが、ジェネリクスのない時代にこのような発想はあまりなく、ListのMapのValueのListのObjectの… というコードも珍しくありません。
複雑な入れ子構造のオブジェクトをジェネリック化するにあたり、意識したポイントは次の通りです。
- 新たにオブジェクトを生成しているメソッドを基点として考える
- 要素の追加箇所を見ると型引数がわかりやすい
- 積極的にvar宣言を用いる(もし許容できるなら変数宣言の統一感は無視する)
コードで例示すると以下のようになります。
void 何らかの処理をするメソッド() {
/* ~何らかの処理~ */
// ★ 新たにオブジェクトを生成しているメソッドを見つける ★
List returnedList = getList();
}
List getList() {
/* raw型の変数たち */
List returnList = new ArrayList();
Map map = new HashMap();
List list = new ArrayList();
// ★ 要素の追加箇所を見る ★
list.add("hoge"); // Listの要素はString
map.put("key", list); // MapのKeyはString、ValueはList
returnList.add(map); // Listの要素はMap
// ★ 新たに生成して返しているので、呼び出し元の変数も同じ型だとわかる ★
return returnList;
}
↓ジェネリック化↓
void 何らかの処理をするメソッド() {
/* ~何らかの処理~ */
// ★ 新たにオブジェクトを生成しているメソッド ★
var returnedList = getList();
}
List<Map<String, List<String>>> getList() {
/* raw型だった変数たち */
var returnList = new ArrayList<Map<String, List<String>>>();
var map = new HashMap<String, List<String>>();
var list = new ArrayList<String>();
// ★ 要素の追加箇所 ★
list.add("hoge"); // Listの要素はString
map.put("key", list); // MapのKeyはString、ValueはList
returnList.add(map); // Listの要素はMap
// ★ 新たに生成して返しているので、呼び出し元の変数も同じ型 ★
return returnList;
}
var宣言に関して
ジェネリック化のコツにて(もし許容できるなら変数宣言の統一感は無視する)と書いたのには理由があります。
var宣言の変数型推論が可能かどうかは、どうしても既存ロジックに振り回されるため、統一感を出すのは難しいということです。
例えば、先に変数宣言だけ行い、後で代入する場合です。
// 先に変数宣言
List returnedList;
/* ~何らかの処理~ */
// 後で代入
returnedList = getList();
これだと、var宣言はできません。
// 先にジェネリック型で変数宣言
List<Map<String, List<String>>> returnedList;
/* ~何らかの処理~ */
// 後で代入
returnedList = getList();
一方で、要素の取得時にはどうしてもvar宣言で書きたくなるのです。
varを使う場合と使わない場合で比較してみましょう。
// 型引数がないため記述は少ない
List returnedList = getList();
Map map = (Map) returnedList.get(0);
List list = (List) map.get("key");
String str = (String) list.get(0);
↓varを使わない場合↓
// キャストは不要になるものの冗長
List<Map<String, List<String>>> returnedList = getList();
Map<String, List<String>> map = returnedList.get(0);
List<String> list = map.get("key");
String str = list.get(0);
↓varを使う場合↓
// すっきり!
var returnedList = getList();
var map = returnedList.get(0);
var list = map.get("key");
String str = list.get(0);
もちろんvarを使わないコードが悪いというわけではありません。IDEに補完してもらえば書くのも大変ではないはずです。
ただ、同様のコードは何千回、何万回と登場します。取得したインスタンスはとりあえずvarに放り込んでおけば良いというのは、気持ち的に楽だと思います。
どうしようもないパターン
1.4時代のコレクションの要素はObject型であり、なんでも入る箱になってしまいがちです。一つのListに様々なオブジェクトを格納していてどうしようもないパターンがあります。
List getList() {
List returnList = new ArrayList();
Map map = new HashMap();
List list = new ArrayList();
// 要素はObject型であり、なんでも入ってしまう
list.add("hoge");
list.add(new Integer(1));
list.add(new Double("2.2"));
map.put("key", list);
returnList.add(map);
return returnList;
}
↓listの型引数はObject型にするしかない↓
List<Map<String, List<Object>>> getList() {
var returnList = new ArrayList<Map<String, List<Object>>>();
var map = new HashMap<String, List<Object>>();
var list = new ArrayList<Object>();
// 型引数をObject型にし、なんでも入るようにする
list.add("hoge");
list.add(1);
list.add(Double.valueOf("2.2"));
map.put("key", list);
returnList.add(map);
return returnList;
}
(Integer・Doubleのコンストラクタは非推奨になったため変えています。)
例示したコードですら複雑で、読むのも諦めてしまいそうです。しかし、Objectにしてしまったが最後、要素の取得時には型チェックとキャストがどこまでもついて回ります。
上記の例では一番内側のListまでは型を確定させられるので、可能な限り型をつけ、Object型をつけるのは最終手段としています。
その他細かいポイント
文字エンコーディング(システムプロパティ)
System.getProperty("file.encoding") ・ Charset.defaultCharset() が返す値は、1.4時代ではOSとロケールに依存していました。(Windows日本語なら MS932 など)
Java21ではデフォルトで UTF-8 が返るようになっています。
文字セットを指定しない場合に内部でデフォルトのものを取得しに行く InputStreamReader や PrintWriter などは注意が必要です。
非推奨となったAPI
- プリミティブ型のラッパークラスのコンストラクタ
new Integer(1)やnew Double("2.2")などは非推奨であるためオートボクシングやvalueOf()を使う。 - クラスからインスタンスを取得する
Class#newInstance()も非推奨です。getDeclaredConstructor()などでコンストラクタを取得してから、Constructor#newInstance()を呼び出す方法で代替します。 - その他、仕様変更のあったAPIは適宜なんとかしましょう。
あとがき
タイトルの壮大さに反し、気付けばジェネリクスの話ばかりになりました 1 。
基幹系システムでは10年, 20年規模の長寿命が求められることも多いでしょう。
本記事のように、一気にバージョンアップしようとすると必ずどこかに無理が出ます。
Javaは息が長いからといって安心せず、「新しい機能も選択肢に入れて」継続的にリファクタリングしていくことがよりよいコードのために重要と考えます。