はじめに
自己紹介
皆さん、こんにちは、Udemy講師の斉藤賢哉です。私はこれまで、約30年にわたって企業システムの開発に携わってきました。現在も現役のアーキテクトとして、重要システムの技術設計やソリューションの選定を担当しています。直近では、システム開発へのAI適用(AI駆動開発)という全社的な施策でリーダーを務めています。
セミナーでの登壇や雑誌への技術記事の寄稿を行ってきたほか、以下の書籍も執筆しています。
- 「マスタリング Java EE 5」(翔泳社、2007年)
- 「アプリケーションアーキテクチャ設計パターン」(技術評論社、2017年)
- 「Javaエンジニアのためのソフトウェアテスト実践入門」(技術評論社、2024年)
Udemy講座のご紹介
この記事の内容は、私が講師を務めるUdemy講座『新 Java Advanced編 / AI対応版』の一部の範囲をカバーしたものです。『新 Java Advanced編 / AI対応版』はこちらのリンクから購入できます。
この講座は、以下のような皆様にお薦めします。
- Javaの基礎を学び終え、設計とレビューができるエンジニアを目指す方
- AIが書いた並行処理や入出力のコードに潜むリスクを、仕組みから見抜けるようになりたい方
- フレームワークの裏で使われるリフレクションやアノテーションの仕組みを理解したい方
- 「Oracle認定Javaプログラマ」Goldの範囲を、仕組みから理解したい方
AIとチャットできるオールインワンの学習環境をリリースしました
Udemy講座の演習を動かすための学習環境として、Windows向けのデスクトップアプリ「Codinable(コーディナブル)」をリリースしました。
エディタ、実行結果、テスト結果、SQL、Webブラウザ、そしてAIチャットを1つの画面にまとめた、オールインワンの学習環境です。
学習に必要なものは、すべてCodinableの中で完結します。
- 演習の実行環境(Java 25、Gradle、Bashなど)が一式同梱されており、JDKのインストールや環境変数の設定は不要です
- インストールして「新規コース開始・再開」からコースを選ぶだけで、講座のすべての演習がそのまま動きます
- 書き換えて動かなくなっても、「初期化」で配布時の状態に戻せます
- 開いている演習のファイルと実行結果を踏まえて、AIに質問したり(Askモード)、ファイルの書き換えを指示したり(Agentモード)できます
AIに書き換えを指示し、差分を確かめてから実行する、というAI駆動開発の基本の流れを、Codinableの中だけで安全に体験できます。
AIチャットは、受講者ご自身が生成AIサービス(Anthropic、OpenAI、Googleのいずれか)のAPIキーを登録して使うBYOK(Bring Your Own Key)方式です。利用料金は各社から直接請求されます。APIキーを登録しなくても、AIチャット以外の機能はすべて使えます。
Codinableのインストーラは、以下のGitHubのReleasesページからダウンロードできます(64ビット版のWindows 10/11、管理者権限は不要)。
この記事を含むシリーズ全体像
この記事はJava SEの一部の機能・仕様を取り上げたものですが、一連のシリーズになっており、シリーズ全体でJava SEを網羅しています。また認定資格である「Oracle認定Javaプログラマ」(Silver、Gold)の範囲もカバーしています。シリーズの全体像および「Oracle認定Javaプログラマ」の範囲との対応関係については、以下を参照ください。
7.1 総称型の仕組み
チャプターの概要
本チャプターでは、クラスやインタフェース、メソッドに型をパラメータとして指定する総称型(ジェネリクス)を、仕組みから取り上げます。総称型は、コレクションフレームワーク、関数型インタフェース(チャプター4.1)、ストリームAPI(チャプター4.2)、HttpResponse<T>(チャプター6.2)など、これまでのチャプターで使ってきた多くのAPIの土台です。
AIエージェントは、型の不一致によるコンパイルエラーを、原型(raw type)やキャスト、@SuppressWarnings("unchecked")で黙らせるコードを書くことがあります。そうしたコードはコンパイルが通っても、実行時にClassCastExceptionを起こし得ます。本チャプターでは、総称型が何を保証しているのか、型消去によって何ができなくなるのか、型パラメータ同士の互換性(非変)とワイルドカード、PECSによるAPI設計を図で整理し、AIが書いた総称型のコードをレビューする観点を身に付けます。
7.1.1 総称型が保証するもの
本レッスンでは、総称型による型の安全性(タイプセーフ)が「コンパイル時」に保証されることを理解し、それを崩すコードを見分けられるようになります。
コンパイル時の型チェック
List<String>は「Stringを格納するためのList型」という意味です。山括弧の中の型を型パラメータと呼び、右辺のnew ArrayList<>()の<>(ダイヤモンド演算子)は、型を左辺からコンパイラに推論させます。
List<String> list = new ArrayList<>();
list.add("foo");
// list.add(100); // コンパイルエラー
String s = list.get(0); // キャストは不要
-
List<String>にString型以外の値を追加しようとすると、コンパイルエラーになります。 - 取り出した値がString型であることも、コンパイラが保証します。
コンパイラが、追加と取り出しの両方で型を確かめる様子を示します。
- 中央は
List<String>型の変数listである - 左から
add("foo")は受け入れられ、add(100)はコンパイルエラーとして止められる - 右へ
get(0)で取り出した値は、キャストなしでString型の変数に入る - 型の誤りは、プログラムを実行する前のコンパイルの段階で見つかる
総称型に関連する用語を表に整理します。
【表】総称型に関連する用語
| 用語 | 説明 | 例 |
|---|---|---|
| ジェネリックタイプ | 総称型が適用されたクラスやインタフェース | List<E> |
| 型パラメータ | 山括弧の中で宣言した型の変数(慣例でT、E、K、V) |
E |
| 原型 | 型パラメータを指定せずに使ったジェネリックタイプ | List |
| 境界型パラメータ | 上限のクラスやインタフェースを指定した型パラメータ | <E extends Number> |
| ワイルドカード型 |
?を使い、型パラメータの範囲を表した型 |
List<?>、List<? extends Number>
|
原型とキャストが崩すもの
総称型を使わずにObjectで受け取る設計では、型の誤りは実行時まで見つかりません。String型を対象にしたコンパレータで、2つの設計を比べます。
- 07_generics: pro.kensait.java.advanced.lsn_7_1_2.interfaces.StrLengthComparator1
public class StrLengthComparator1 implements Comparator { //【1】
@Override
public int compare(Object o1, Object o2) {
int length1 = ((String)o1).length(); //【2】
int length2 = ((String)o2).length();
return length1 - length2;
}
}
- 【1】原型の
Comparatorを実装しているため、引数はObject型です。Integer型のリストをソートしようとしても、コンパイラは検知できません。 - 【2】String型にダウンキャストしています。サンプルの
Main_2はInteger型のリストをこのコンパレータでソートしており、▶ 実行するとClassCastExceptionが発生します。
総称型を使ったStrLengthComparator2は、implements Comparator<String>と宣言し、compare(String str1, String str2)を実装します。キャストは不要になり、Integer型のリストに使おうとするとコンパイルエラーになります。
2つの設計で、誤りが見つかる時点を比べます。
- 横軸は「コードを書く」「コンパイル」「実行」の時間の流れである
- 上段の原型の
StrLengthComparator1では、Integer型のリストを渡してもコンパイルは通る - 上段では、「実行」の時点で
(String)o1のキャストが失敗し、ClassCastExceptionになる - 下段の総称型の
StrLengthComparator2では、Integer型のリストを渡すと「コンパイル」の時点でエラーになる
レビューの観点
- 原型(
List、Comparator、Mapなど)を使っていないか。コンパイラの警告が出ている箇所を放置していないかを確かめる -
(List<String>) objのような型パラメータ付きのキャストや、@SuppressWarnings("unchecked")で警告を消していないか。消すなら、安全である理由がコメントで説明されているかを確かめる -
@SuppressWarnings("unchecked")が、クラスやメソッド全体ではなく、必要最小限の範囲に付いているかを確かめる -
Object型の引数とinstanceof・キャストの組み合わせで、総称型で表せる汎用処理を書いていないかを確かめる
7.1.2 型消去の仕組みと制約
本レッスンでは、型パラメータがコンパイル後に消えること(型消去)を理解し、そのために書けないコードや実行時の落とし穴を見分けられるようになります。
型消去
任意の型の値を1つ保持するMyContainerクラスを以下に示します。
- 07_generics: pro.kensait.java.advanced.lsn_7_1_2.generictype.MyContainer
public class MyContainer<T> { //【1】
private T property;
public MyContainer(T property) {
this.property = property;
}
public T getProperty() { //【2】
return property;
}
// setProperty()は省略
}
- 【1】クラス名の後に型パラメータ
Tを宣言します。フィールド・コンストラクタ・ゲッターの型がTで連動します。 - 【2】
MyContainer<Integer>として使えば、getProperty()はInteger型を返し、キャストは不要です。
型パラメータは、コンパイルされると、境界を指定していなければObject型に置き換えられます。この変換をイレイジャ(型消去)と呼びます。値を取り出す側には、コンパイラがキャストを自動で追加します。
- ソースコードの
class MyContainer<T>では、フィールドpropertyの型とgetProperty()の戻り値の型が、型パラメータTである - イレイジャによって、コンパイル後の
MyContainerではTがObject型に置き換わる - 利用側の
int num = container1.getProperty();には、コンパイル時に(Integer)のキャストが自動で追加される
実行時には型引数が無い
型消去の結果、実行時のオブジェクトは型パラメータに何が指定されたかを知りません。List<String>とList<Integer>は、実行時にはどちらも同じArrayListクラスのインスタンスです。
- 左はソースコードで、
List<String> a = new ArrayList<>()とList<Integer> b = new ArrayList<>()の2つの変数がある - 右は実行時のヒープで、2つのオブジェクトはどちらも
ArrayListクラスのインスタンスであり、<String>や<Integer>の情報は無い -
a.getClass() == b.getClass()はtrueになる - そのため、
Object型のobjに対するobj instanceof List<String>のような実行時の判定や、(List<String>) objのキャストの検査はできない
型消去から生じる、書けないコードを表にまとめます。
【表】型消去による主な制約
| 書けないコード | 理由 |
|---|---|
new T()、new T[10]
|
実行時にTが何の型か分からない |
obj instanceof List<String> |
実行時に型引数の情報が無い(objがObject型の場合。List<?>型の変数をList<String>かどうか判定することもできない) |
static T value;(クラスの型パラメータをスタティックなメンバーに使う) |
スタティックなメンバーはクラスに1つで、MyContainer<Integer>とMyContainer<String>で共有されてしまう |
void add(T t)とvoid add(S s)の2つのオーバーロード |
どちらも消去後はadd(Object)になり、シグネチャが衝突する |
最後の制約について、消去の前後のシグネチャを比べます。
- 左はソースコードの
class MyContainer<T, S>で、add(T t)とadd(S s)の2つのメソッドがある - 中央の「イレイジャ」によって、
TもSもObjectに置き換わる - 右のコンパイル後は、2つとも
add(Object)という同じシグネチャになる - 同じシグネチャのメソッドは2つ宣言できないため、コンパイルエラーになる
レビューの観点
- 実行時に型引数で処理を分けようとしていないか(
instanceof List<String>の代わりに要素を調べる、Class<T>を引数で受け取る、などの設計になっているか)を確かめる -
T[]を作るために(T[]) new Object[n]のような未検査のキャストをしていないか。配列ではなくList<T>で設計できないかを確かめる - クラスの型パラメータをスタティックなフィールドやメソッドで使おうとしていないかを確かめる
7.1.3 ジェネリックなメソッドと境界型パラメータ
本レッスンでは、メソッド単位の型パラメータと、その上限(境界)が何を可能にするのかを判断できるようになります。
ジェネリックなメソッド
総称型は、個別のメソッドだけに適用することもできます。戻り値の型の前に<型パラメータ名>を書いて宣言します。主にスタティックメソッドによる汎用的なユーティリティで使います。
- 07_generics: pro.kensait.java.advanced.lsn_7_1_4.genericmethod.MyUtil
static <E> List<E> createSameElemList(E element, int count) { //【1】
List<E> list = new ArrayList<>();
for (int i = 0; i < count; i++) {
list.add(element);
}
return list;
}
- 【1】第1引数の
E型と、戻り値のList<E>型が連動します。 -
MyUtil.createSameElemList("Hello", 10)と呼ぶと、引数からEがString型に推論され、List<String>が返ります。
引数の型から型パラメータが決まり、戻り値に伝わる流れを示します。
- 呼び出し元は
createSameElemList("Hello", 10)で、第1引数にString型の値を渡す - コンパイラは、第1引数の型から型パラメータ
EをStringに決める - 決まった
Eが、戻り値の型List<E>に当てはめられ、List<String>になる - 呼び出し元は、戻り値をキャストなしで
List<String>の変数に代入する
境界型パラメータ
型パラメータの中身は後から決まるため、メソッドの中ではObjectのメソッドしか呼び出せません。<E extends Number>のように上限を付けると、Numberのメソッドを呼び出せるようになります。上限がインタフェースでもextendsと書きます。
- 07_generics: pro.kensait.java.advanced.lsn_7_1_4.extend.MyUtil
static <E extends Number> List<E> createSameElemList(E element) { //【1】
List<E> list = new ArrayList<>();
int count = element.intValue(); //【2】
for (int i = 0; i < count; i++) {
list.add(element);
}
return list;
}
- 【1】このメソッドは、
Numberの子クラス(Integer、Long、Floatなど)の値しか受け取れません。 - 【2】
elementがNumberであることが保証されているため、intValue()を呼び出せます。10Lを渡すと10個、3.5Fを渡すと3個の要素を持つリストが返ります。
境界の有無で、メソッドの中で使えるメソッドと消去後の型を比べます。
- 左は境界なしの
<E>で、elementに対して呼び出せるのはtoString()などのObjectのメソッドだけである - 左の
Eは、消去後にObjectになる - 右は境界ありの
<E extends Number>で、intValue()などのNumberのメソッドも呼び出せる - 右の
Eは、消去後にNumberになる - 右に渡せるのは
Integer、Long、FloatなどのNumberの子クラスに限られる
境界の異なる同名のジェネリックなメソッドは、消去後のシグネチャが違えばオーバーロードできます(<T extends CharSequence>と<T extends Comparable<T>>のprocess(T))。ただし、両方を実装するString型を渡すとどちらを呼ぶか決まらずコンパイルエラーになり、MyUtil.<CharSequence>process("100")のように型を明示する必要があります(lsn_7_1_4.overload)。
レビューの観点
- 型パラメータを宣言したのに、メソッドの中で結局
Objectへのキャストやinstanceofで分岐していないか(必要な境界が付いているか)を確かめる - 逆に、境界を付けなくても済む処理に、不要に複雑な境界(
<T extends Comparable<? super T>>など)を付けて読みにくくしていないかを確かめる - 境界だけが違う同名のジェネリックなメソッドで、呼び出し側に型の明示を強いる設計になっていないかを確かめる
7.1.4 型パラメータの互換性とワイルドカード
本レッスンでは、List<Integer>をList<Number>として扱えない理由(非変)と、それを補うワイルドカード型の意味を判断できるようになります。
非変
List<String>型の変数にArrayList<String>を代入できるように、ジェネリックタイプ同士は通常のクラスと同じ互換性を持ちます。一方、型パラメータ同士には互換性がありません。IntegerはNumberの子クラスですが、ArrayList<Integer>をArrayList<Number>の変数に代入することはできません。この性質を非変と呼びます。
- 07_generics: pro.kensait.java.advanced.lsn_7_1_3.Main_Invariant
// 以下はコンパイルエラーになる
// ArrayList<Number> list = new ArrayList<Integer>();
- 仮にこの代入ができると、
ArrayList<Number>の変数を通じてLong型の値を追加でき、Integerだけを格納するはずのリストにLongが紛れ込みます。非変は、これを防ぐための性質です。
要素の型の継承関係と、リストの型の関係を比べます。
- 要素の型では、IntegerとLongがNumberを継承し、NumberがObjectを継承する
-
ArrayList<Integer>とArrayList<Number>の間には互換性がない(非変) - 要素の型には継承関係があっても、リストの型には上下関係がない
配列は、総称型と違って共変です。Integer[]をNumber[]の変数に代入できますが、その代わりに誤りは実行時まで見つかりません。
- 上段の配列では、
Number[] arr = new Integer[1]の代入がコンパイルを通る - 上段では、
arr[0] = 1L(Long型の値)の格納が実行時にArrayStoreExceptionになる - 下段の総称型では、
List<Number> list = new ArrayList<Integer>()の代入がコンパイルエラーになる - 総称型は、配列が実行時に検出する誤りを、コンパイル時に検出する
ワイルドカード型
非変のままでは、「数値のリストなら何でも受け取る」メソッドが書けません。これを補うのが、?を使ったワイルドカード型です。
【表】ワイルドカード型と原型・List<Object>の比較
| 型 | 代入できるリスト | 要素の追加 | 要素の取り出し |
|---|---|---|---|
原型のList
|
どのリストも | 任意の型(型の安全性が崩れる) |
Object型 |
List<Object> |
List<Object>だけ(非変) |
任意の型 |
Object型 |
List<?> |
どのリストも | できない(nullのみ) |
Object型 |
List<? extends Number> |
List<Number>、List<Integer>、List<Long>など |
できない(nullのみ) |
Number型 |
List<? super Number> |
List<Number>、List<Object>
|
Numberとその子クラス |
Object型 |
List<? extends Number>に追加できないのは、実体がList<Long>かもしれないため、Integer型の値を入れると型の安全性が崩れるからです。List<? super Number>からObject型としてしか取り出せないのは、実体がList<Object>かもしれないからです。上限境界と下限境界が受け入れる範囲を示します。
- 型の階層は、上からObject、Number、Integer・Longである
-
? super Number(下限境界)が受け入れる範囲は、NumberとObjectである -
? super Numberでは、追加はNumber型とその子ができ、取り出しはObject型になる -
? extends Number(上限境界)が受け入れる範囲は、NumberとInteger・Longである -
? extends Numberでは、追加はできず、取り出しはNumber型になる
レビューの観点
- 「どんなリストでも受け取る」つもりの引数が、
List<Object>(非変のためList<String>を渡せない)や原型のListになっていないか。List<?>で足りないかを確かめる -
List<? extends T>の引数に要素を追加しようとして、キャストや原型で回避していないかを確かめる - 配列の共変性に頼った設計(
Object[]の引数にString[]を渡して書き込む 等)になっていないかを確かめる
7.1.5 PECSによるAPI設計
本レッスンでは、メソッドの引数にどのワイルドカード型を使うべきかを、「取り出すのか、入れるのか」から判断できるようになります。
取り出す側はextends
数値のリストの合計を表示するユーティリティを、List<Number>で受け取る版とList<? extends Number>で受け取る版で比べます。
- 07_generics: pro.kensait.java.advanced.lsn_7_1_6.boundary_1.MyNumberUtil_2
public static void printSum(List<? extends Number> list) { //【1】
long total = 0;
for (Number num : list) { //【2】
total = total + num.longValue();
}
System.out.println(total);
}
- 【1】
MyNumberUtil_1は引数がList<Number>のため、List<Integer>を渡すとコンパイルエラーになります。List<? extends Number>に変えると、List<Integer>もList<Long>も渡せます。 - 【2】要素は
Numberの子クラスであることが保証されているため、Numberとして取り出してlongValue()を呼べます。
2つの版で、渡せるリストの範囲を比べます。
- 左の
printSum(List<Number> list)に渡せるのは、List<Number>だけである - 左に
List<Integer>やList<Long>を渡すと、コンパイルエラーになる - 右の
printSum(List<? extends Number> list)には、List<Number>、List<Integer>、List<Long>のどれも渡せる - 右のメソッドの中では、要素を
Numberとして取り出すだけで、追加はしない
入れる側はsuper
要素をコピーするユーティリティでは、コピー元から取り出し、コピー先へ入れます。Foo、Bar、Bazの3つのクラスが、上位から下位に向かってこの順に継承関係にあるとします。
- 07_generics: pro.kensait.java.advanced.lsn_7_1_6.boundary_3.MyCopyUtil_2
public static void process(List<? extends Bar> src, //【1】
List<? super Bar> dest) { //【2】
for (Bar bar : src) {
dest.add(bar);
}
}
- 【1】コピー元は
List<? extends Bar>です。Barかその子クラスのリストなので、要素をBar型として取り出せます。 - 【2】コピー先は
List<? super Bar>です。Barかその親クラスのリストなので、Bar型の要素を追加できます。サンプルのMain_2は、List<Bar>からList<Foo>へコピーしています。
コピー元とコピー先に渡せるリストの型を示します。
- BarはFooを継承し、BazはBarを継承する
- コピー元
srcのList<? extends Bar>には、List<Bar>とList<Baz>を渡せる - コピー先
destのList<? super Bar>には、List<Bar>とList<Foo>を渡せる - 要素は、コピー元からBar型として取り出し、コピー先へBar型として追加する
PECSの判断
要素を取り出す側(Producer、値を生み出す側)にはextends、要素を入れる側(Consumer、値を受け取る側)にはsuperを使うという原則は、「PECS(Producer Extends, Consumer Super)」と呼ばれます。Collections.copy(List<? super T> dest, List<? extends T> src)や、Collections.sort()のComparator<? super T>も、この原則で宣言されています。引数の型を決める流れを示します。
- 最初の判断は「メソッドの中でその引数から要素を取り出すか」と「その引数へ要素を入れるか」である
- 取り出すだけなら、
List<? extends T>にする(例:printSum()のlist、copyのsrc) - 入れるだけなら、
List<? super T>にする(例:copyのdest、Comparator<? super T>) - 取り出しも入れもするなら、ワイルドカードを使わず
List<T>にする - 戻り値の型にはワイルドカードを使わない(呼び出し側の扱いが不自由になる)
レビューの観点
- 引数から要素を取り出すだけのメソッドが
List<T>やList<Number>で宣言され、子クラスのリストを受け取れなくなっていないかを確かめる - 引数へ要素を入れるメソッド(コピー先、コールバックの
Consumer、Comparatorなど)が? super Tになっているかを確かめる - 戻り値の型に
List<? extends T>などのワイルドカードを使っていないかを確かめる - ワイルドカードで受け取ったリストに追加するために、原型やキャストで回避していないかを確かめる
このチャプターで学んだこと
本チャプターでは、以下を取り上げました。
- 総称型は型の安全性をコンパイル時に保証し、原型・未検査のキャスト・
@SuppressWarnings("unchecked")はその保証を崩して実行時のClassCastExceptionにつながること - 型パラメータはコンパイル後に消去され(イレイジャ)、実行時には型引数の情報が無いため、
new T()、instanceof List<String>、スタティックなメンバーでの使用、消去後に衝突するオーバーロードができないこと - ジェネリックなメソッドは引数から型パラメータを推論し、境界型パラメータを付けると上限の型のメソッドを呼び出せること
- 型パラメータ同士は非変であり、共変の配列が実行時に検出する誤りを、総称型はコンパイル時に検出すること
- ワイルドカード型は非変を補い、取り出す側には
extends、入れる側にはsuperを使う(PECS)ことで、型の安全性と柔軟性を両立できること














