☕

なぜJavaのジェネリクスの型推論は同じ型を強制しないのか

に公開
2026/02/06
1件

はじめに

こんにちは。Nstockのtouyuです。

Nstockに入社し、初めて本格的にJavaでコードを書くようになりました。
Javaの書き味は思っていたよりモダンで、他言語からの類推でもある程度書くことができました。加えてCoding Agentの発展も著しく、正直にいうと言語仕様を深く理解せずに書くことも増えていました。

そんなある日、あるジェネリクスを含んだコードが期待していたものとは異なる挙動をすることに気づきました。

期待と異なる挙動

同じ型の2つの引数を取り、何らかの処理をさせるメソッドを用意したいと考え、以下のようなコードを生成してもらいました。

<T> void something(T a, T b) {
    ...
}

私の想定では、このメソッドを呼び出すときに a と b は同じ型でなければコンパイルエラーになるはずでした。

something("hello", "world");  // OK
something(1, 2);              // OK

something("hello", 1);        // コンパイルエラーになることを期待するが、、

しかし実際には、3つ目の呼び出し(1つ目の引数にString、2つ目の引数にInteger)もコンパイルが通ります。

他言語との比較

例えば、SwiftやTypeScriptでは、同様のコードはコンパイルエラーになります。

Swift

func something<T>(_ a: T, _ b: T) { }

something("hello", "world")  // OK
something("hello", 1)        // コンパイルエラー: Cannot convert value of type 'Int' to expected argument type 'String'

TypeScript

function something<T>(a: T, b: T) { }

something("hello", "world"); // OK
something("hello", 1);       // コンパイルエラー: Argument of type 'number' is not assignable to parameter of type 'string'.

これらの言語では、最初の引数で型パラメータ T が String に固定され、2番目の引数も String でなければなりません。私はこの挙動を期待していました。

Kotlin

一方、KotlinではJavaと同様の挙動を示します。

fun <T> something(a: T, b: T) { }

something("hello", "world")  // OK
something("hello", 1)        // OK: コンパイルエラーにならない

なぜJavaやKotlinはこのような挙動なのでしょうか?

Least Upper Bound(LUB)

この挙動の原因は、Javaの型推論で採用されている Least Upper Bound(LUB、最小上界[1])という仕組みにあるようです。

Java Language Specification (JLS) §4.10.4(Javaの言語仕様)では、LUBは次のように定義されています。

The least upper bound, or "lub", of a set of reference types is a shared supertype that is more specific than any other shared supertype (that is, no other shared supertype is a subtype of the least upper bound).
訳:参照型の集合に対する最小上界(least upper bound、lub)とは、共通のスーパータイプのうち、他のどの共通スーパータイプよりも具体的(つまり、それ以上に下位となる共通スーパータイプが存在しない)な型のことである。

全く頭に文章が入ってきません。

噛み砕いていくと、複数の型があるとき、それらすべてを含む共通の上位型(スーパータイプ)は複数存在しえます。その中で一番具体的な(一番下位に位置する)共通の型がLUBです。言い換えると、共通の上位型の中で最も情報を失わない型を選ぶ方法といえそうです。

具体例

something("hello", 1) を呼び出したとき、コンパイラは T を何に推論するでしょうか?

  • "hello" は String 型
  • 1 は Integer 型(正確には、autoboxingにより int リテラルが Integer に変換される)

コンパイラはこれら2つの型の共通のスーパータイプを探します。String と Integer のスーパータイプを一部抜粋すると、以下のようになります。

String: Serializable, Comparable<String>, CharSequence, ...
Integer: Serializable, Comparable<Integer>, Number, ...

すべての参照型は暗黙的に Object を継承しています。そのため Object は常に型階層の最上位に位置する共通のスーパータイプとなります。しかし、これでは汎用的すぎて型情報がほとんど失われてしまいます。そこでLUBは情報をできるだけ失わずに済む、一番具体的な共通部分を探します。

この例では、LUBとして Serializable & Comparable<?> & Object のような交差型が導出され、T はこの型に推論されます。これが、異なる型を渡してもコンパイルが通る理由です。

なぜLUBが採用されたのか

ではなぜJavaはLUBを採用したのでしょうか?
LUBはJava 5でジェネリクスと共に採用されたため、当時の歴史を振り返ってみることにします。

Java5でのジェネリクス導入

ジェネリクスは2004年にリリースされたJava 5で導入されました。その時点でJavaは既に10年近い歴史があり、膨大なコードベースとライブラリが存在していました。

例えば、Java 1.4以前の ArrayList は以下のように使われていたようです。

// Java 5以前: 型引数なし、要素はObject型
List list = new ArrayList();
list.add("hello");
list.add(123);               // StringとIntegerが混在しても問題なし
Object item = list.get(0);
String str = (String) item;  // 手動でキャストが必要(例外リスクあり)

このListから値を取り出す際には手動でキャストする必要があり、意図しない型が混入していた場合は実行時に例外が発生するリスクがありました。ジェネリクスはこの問題をコンパイル時に検出できるようにするために導入されたようです。

しかし、ジェネリクスの導入には互換性の制約がありました。JLSの序文では当時の状況についてこう述べられています。

JSR 14 represents an unprecedented effort to fundamentally extend the type system of a widely used programming language under very stringent compatibility requirements.
訳:
JSR 14は、広く使われているプログラミング言語の型システムを、極めて厳格な互換性要件のもとで根本的に拡張しようとする、前例のない取り組みです。

JSR14(ジェネリクスを導入するための仕様提案)を見てみると、この互換性要件が明確に定義されています。

C1) Upward compatibility with existing code. Pre-existing code must work on the new system. This implies not only upward compatibility of the class file format, but also interoperability of old applications with parameterized versions of pre-existing libraries, in particular those used in the platform library and in standard extensions.
C2) Upward source compatibility. It should be possible to compile essentially all existing
Java language programs with the new system.
訳:
C1) 既存コードとの上位互換性。既存のコードは新しいシステムで動作する必要があります。これは、クラスファイル形式の上位互換性だけでなく、既存のライブラリのパラメータ化されたバージョンと古いアプリケーションとの相互運用性も意味します。
C2) ソースの上位互換性。基本的にすべての既存のJava言語プログラムを新しいシステムでコンパイルできる必要があります。

この互換性要件を満たすために、型消去とRaw Typesという仕組みが採用されています。

型消去(Type Erasure)

型消去とは、コンパイル時にジェネリクスの型パラメータ情報を消去し、実行時には型情報を保持しない仕組みです。

// コンパイル前
List<String> strings = new ArrayList<>();
strings.add("hello");
String s = strings.get(0);

// コンパイル後(概念的なイメージ)
List strings = new ArrayList();
strings.add("hello");
String s = (String) strings.get(0);

List<String> も List<Integer> も、コンパイル後のバイトコードでは同じ List になります。これにより、ジェネリクスを使った新しいコードでも、既存のライブラリやバイトコードとの互換性を保つことができます

Raw Types

Raw Typesとは、ジェネリクス型を型パラメータなしで使用する方法です。

// Raw Types
List list = new ArrayList();
list.add("hello");

// ジェネリクスを使用した書き方
List<String> list = new ArrayList<>();
list.add("hello");

ジェネリクス導入後も、既存のコードがそのままコンパイルできるようにRaw Typesが許容されました。Java 5以前に書かれた既存コードを書き換えることなく、新しいコンパイラでビルドできるようになります。

決定的な理由は見つからない

ここまで調べ、私は当初、LUBも互換性のために必要だったのではないかと考えていました。しかし、互換性の問題は型消去とRaw Typesで解決できているように思います。LUBを採用した理由は依然として明確になっていません。

当時の論文やドキュメントを追ってみたものの、この意思決定の明確な根拠を見つけることはできませんでした。

JSR14には先ほど紹介した互換性の制約(C1, C2)以外に、以下の制約も記載されています。

C7) Preservation of the spirit of the Java programming language. The generic language should integrate well with the existing design and philosophy of the Java programming language.
訳: Java言語の精神の保持。ジェネリクスを備えた言語は、Java言語の既存の設計と哲学とうまく統合されるべきである。

Javaの「精神」や「哲学」がどういったものか具体的に定義されていませんが、例えば、コンパイルエラーにするよりもできる限りコンパイルを通す方向を好んだのではないか、といった仮説も考えられます。しかし、これもまた推測の域を出ませんでした。

当時の設計者の見解

当時の状況をより深く知るために、JSR14の提案者であり、ジェネリクスの設計に深く携わったGilad Bracha氏にメールで当時の話について尋ねたところ、ありがたいことに回答をいただくことができました。

Bracha氏によると、当時の正確な動機は記憶にないと前置きしつつ、後から振り返っての推測として、LUBを使う方がプログラマにとって意外性が少なく、サブタイピングともより整合的であり、

"I'd argue that it's more flexible and less surprising to the programmer, and a better fit with subtyping."

また、Javaがこのようなケースを拒否しなかったのは、単にそうする強い理由がなかったからだろう、とのことでした。

"I don't see a strong argument to reject these cases."

まとめ

当初、私はこの振る舞いには何か強い理由があるはずだと考えていました。
今回の話の結論として、強い理由がありLUBを採用するに至ったというより、同じ型を要求する強い理由がなかったというものでした。
過去の出来事を振り返るとき、必ず積極的な理由があるはずだという思い込みがあると、「別の選択をする理由がなかった」という、ある種の消極的な判断を見落としてしまいがちですね。

余談: 同じ型を強制する方法はある?

これまでの話で、Javaの一般的なジェネリクスの利用では T に対して同じ型を強制できないことが分かりました。
もしどうしても同じ型を強制したい場合、いくつかのアプローチが考えられます。

1. 型トークンの利用

Class<T> を引数に追加することで、型を明示的に指定させる方法です。

<T> void something(Class<T> type, T a, T b) { ... }

something(String.class, "hello", "world");  // OK
something(String.class, "hello", 1);        // コンパイルエラー

2. 呼び出し側で型を明示

this.<String>something("hello", 1);  // コンパイルエラー

3. 自己参照制約の利用

Comparable<T> のような型パラメータが自分自身を参照するインターフェースを利用することで、同じ型のみを受け付ける状態を作ることができます。

<T extends Comparable<T>> void something(T a, T b) { }

something("hello", "world");  // OK
something(1, 2);              // OK
something("hello", 1);        // コンパイルエラー: The method something(T, T) in the type PlanResolver is not applicable for the arguments (int, String)

String は Comparable<String> を、Integer は Comparable<Integer> を実装しています。something("hello", 1) を呼び出すと、T extends Comparable<T> を満たす共通の型が必要ですが、Comparable<String> と Comparable<Integer> を同時に満たす T は存在しないため、推論が失敗します。

一般的な話として、上限境界(T extends X)を使わずにこういったメソッドを書くケースはあまりなく、このパターンが適用できるケースは多いと思われます。
ただし、独自のインターフェースを用いる場合は、String などの標準ライブラリの型には適用できません。(後から実装させることができないため)

4. メソッドのオーバーロード

ジェネリクスを使わず、型ごとにメソッドを定義する方法です。

void something(String a, String b) { ... }
void something(Integer a, Integer b) { ... }

something("hello", "world");  // OK
something(1, 2);              // OK
something("hello", 1);        // コンパイルエラー

ただし、対応したい型の数だけメソッドを用意する必要があり、汎用性は失われます。

参考資料

脚注
  1. 上界(Upper Bound)とは、主に数学的な文脈で使用され、ある集合の全ての要素以上となる値のこと。 ↩︎

Nstock Tech Blog

Discussion

こひな(ゆでだこの方🐙)こひな(ゆでだこの方🐙)

タイトルを見たときは「型消去されるからだろうなぁ」くらいにしか考えていなかったのですが、記事内では思ったより深く追求されていて感動しました。
現代からの視点で見ると言語仕様に手探り感があって興味深いですね。

1