Maple Systems Advent Calendar 2025 12月14日(日)の記事を書かせていただきます。
12月2日(火)の記事は技術関係なしの自社の方向けの自己紹介的な内容でしたので
今回は業務でも使用しているJavaのジェネリクスについてJavaユーザーに向けて書こうかと思います。
ちなみに皆さんジェネリクスって使ってますか?
型安全に扱うための <T> とか書かれてるアレです。
これから以下の流れで記事を書こうと思っています。
- ジェネリクスの基本形
- ワイルドカード
- 下限/上限ワイルドカード
- PECS原則(覚えやすくなる)
- まとめ
前半はあくまで 助走 ですので、
PECS 原則の章から読んでも全く問題ありません。
それではまず、ジェネリクスの基礎から見ていきましょう。
①ジェネリクス(基本)
①のサンプルコード
import java.util.List;
public class Step1 {
static void printFirstElement(List<Number> list) { // ←このList<Number>の「<>」部分がジェネリクス
Number firstElement = list.get(0);
System.out.println(firstElement);
}
public static void main(String[] args) {
List<Number> numbers = List.of(1, 2, 3);
printFirstElement(numbers); // numbersはList<Number>なのでOK
// List<Integer> integers = List.of(4, 5, 6);
// printFirstElement(integers); // integersはList<Integer>なのでコンパイルエラー
}
}
printFirstElement は List<Number> のみを受け取れるため
List<Integer> などは渡せず、型安全が保たれています。
あえてジェネリクスを使わない場合は以下のようになります。
②ジェネリクス未使用
②のサンプルコード
import java.util.List;
public class Step2 {
static void printFirstElement(List list) { // ←ジェネリクス未使用(raw型)
Object firstElement = list.get(0); // 何が渡されるか分からないのでNumberからObjectに変更
System.out.println(firstElement);
}
public static void main(String[] args) {
List<Number> numbers = List.of(1, 2, 3);
printFirstElement(numbers); // List<Number>なのでOK
// List<Integer> integers = List.of(4, 5, 6);
// printFirstElement(integers); // List<Integer>なのでOK
}
}
raw型は 型情報が失われる ため List<Integer> でも List<String> でも渡せてしまいます。
これは型安全性が崩れるため 基本的に非推奨 です。
では次に以下だとどうでしょう。変わったのはListからList<?>に変更した点のみです。
③非境界ワイルドカードを使用したジェネリクス <?>
③のサンプルコード
import java.util.List;
class Step3 {
static void printFirstElement(List<?> list) { // ←ListからList<?>に変更
Object firstElement = list.get(0);
System.out.println(firstElement);
}
public static void main(String[] args) {
List<Number> numbers = List.of(1, 2, 3);
printFirstElement(numbers); // 変わらずOK
List<Integer> integers = List.of(4, 5, 6);
printFirstElement(integers); // 変わらずOK
}
}
raw型と似ていますが、性質は明確に違います。
②raw型 … ジェネリクス情報が失われる(危険)
③<?> … ジェネリクス情報が保持される
そして <?> の最大の特徴は add が禁止される こと。
このおかげで型安全が守られます。
裏を返せば②のRaw型は危険なコードということでもあります。
ではどう危険なのか簡単なサンプルコードで見てみましょう。
④raw型が危険な理由
④のサンプルコード
class Step4 {
// Raw型(危険)
static void handleRaw(List list) {
// コンパイラによる型チェックがないため、何でも add できてしまう
list.add("String型の値をadd"); // ← ここで異なる型を注入
}
public static void main(String[] args) {
List<Integer> integers = new ArrayList<>(); // List<Integer>として宣言
integers.add(1);
integers.add(2);
integers.add(3);
// Rawメソッドを呼ぶと、List<Integer> に String が混入してしまう
handleRaw(integers);
// 実行時エラーが発生する箇所
for (Integer n : integers) { // Integer として読み取ろうとする
System.out.println(n); // ここで ClassCastExceptionが発生する
}
}
}
Raw型は「なんでも add できる」ため、一見便利に見えるかもしれません。
しかし実際には、呼び出し元の List に String が混入し、
コンパイルでは気づけず実行時に突然落ちる(ClassCastException)
という 最悪の事態 を引き起こします。
追加した場所(handleRaw)と落ちる場所(for 文)が離れると
デバッグが非常に困難になります。
だから raw型は避けるべきとされています。
ちなみにList<?>にした場合は以下のようにコンパイルエラーとなって危険なコードの混入を早期発見できます。
ここまでで、
raw型 → 危険(add できてしまう)
List> → 安全(add 禁止)
という違いが分かりました。
しかし このままだとList<?> は読み取りしかできないため、
「値を追加したい場合はどうすればいいの?」
という疑問が必ず生まれます。
そこで登場するのが <? super T> 下限境界ワイルドカード です。
⑤下限境界ワイルドカード <? super T>
⑤のサンプルコード
class Step5 {
static void addNumbers(List<? super Integer> list) {
// Integer(およびそのサブタイプ)なら安全に追加できる
list.add(100);
list.add(200);
// --- 読み取りも一部可能 ---
// <? super Integer> は「Integer の親型のリスト」なので
// 具体的な型は分からず 最上位のObject としてのみ読み出し可能
Object firstElement = list.get(0);
System.out.println("先頭の要素(Object扱い): " + firstElement);
System.out.println("追加後のリスト: " + list);
}
public static void main(String[] args) {
List<Integer> integers = new ArrayList<>();
addNumbers(integers); // List<Integer> には当然 Integer を安全に追加できる
List<Number> numbers = new ArrayList<>();
addNumbers(numbers); // List<Number> に Integer を安全に追加できる(NumberはIntegerの親クラス(スーパークラス)だから)
List<Object> objects = new ArrayList<>();
addNumbers(objects); // List<Object> にも追加できる(ObjectもIntegerの親クラス(スーパークラス)だから)
// List<String> strings = new ArrayList<>();
// addNumbers(strings); // List<String> に Integer は追加できない(StringはIntegerの親クラス(スーパークラス)ではないから)
}
}
※上記コードのコメント記載の通り、Objectとしての読出しは許可されています。
super は「〜の親」という意味なので、右から読んで
<? super Integer> = 「Integer の親(super)である何か(?)」
というニュアンスになります。
一般化した表現として <? super T> と書くことが多く、
この場合の T は利用時に具体的な型が決まる ため、
「T の親である何か」という意味になります。
また、下限境界があるということは上限境界ワイルドカードもありそうと思いませんか?はい、あります。
書き方は<? extends T> で 「T を継承(extends)した何か(?)」 を受け取れるワイルドカードです。
こちらは主に 「読み取り専用で、読み取った値を T として扱いたい」 場面で使います。
T または T のサブタイプであることが保証されているため 読み取りは T として扱えば安全
ただし 書き込み(add)は禁止(何が入るか完全には特定できないため)です。
⑥ 上限境界ワイルドカード <? extends T>
⑥のサンプルコード
import java.util.List;
class Step6 {
static void printNumbers(List<? extends Number> list) {
// 読み取りは Number として安全に実施できる
for (Number n : list) {
System.out.println("値: " + n);
}
// list.add(10); // コンパイルエラー(書き込み不可)
// list.add(new Integer(10)); // これも同様に書き込みのためコンパイルエラー
}
public static void main(String[] args) {
List<Integer> intList = List.of(1, 2, 3);
printNumbers(intList); // Integer は Number の子クラス(サブクラス)なのでOK
List<Double> doubleList = List.of(1.1, 2.2, 3.3);
printNumbers(doubleList); // Double も Number の子クラス(サブクラス)なのでOK
List<Number> numberList = List.of(10, 20, 30);
printNumbers(numberList); // Number 自体ももちろんOK
}
}
なぜ書き込みできないのか?
理由はシンプルで、<? extends Number> では「具体的にどのサブクラスか」が分からないからです。
例えばList<? extends Number> list = List.of(1.1, 2.2, 3.3); // 実際は List<Double>
の時に list.add(10) を許してしまうと、
Double だけが入るべきリストに Integer が混入してしまうと型安全が崩壊し、実行時エラーの原因になるという事態が生じます。
そのため Java は コンパイル時点で add を禁止 しています。
なぜ<? super T>が下限で<? extends T>が上限と呼ぶのか
これは我流の覚え方でしてUMLで決まってるわけではありませんがこんなイメージで覚えてます。
まぁ実際に重要なのは正式名称なんかではありません
本当に意識すべきなのは、
どちらが読み取り専用なのか(Producer = extends)
どちらが書き込み可能なのか(Consumer = super)
という点です。
これを覚えるのに最適な原則があります。
それはPECS原則です。
PECS原則とは
PECS = Producer Extends, Consumer Super の頭文字を取った原則です。
P = Producer(生産者) → Extends を使う
→ 「値を取り出す側」は <? extends T>:読み取り専用
C = Consumer(消費者) → Super を使う
→ 「値を受け取る側」は <? super T>:書き込み可能
この原則を知っていると、
あるリストから別のリストへ、型安全にデータを移し替える実装で迷いづらくなります。
⑦PECS 原則を使ったサンプルコード(移し替え処理)
⑦のサンプルコード
import java.util.ArrayList;
import java.util.List;
class Step7 {
// Producer(生産者)→ Extends
// Consumer(消費者)→ Super
static void copyList(List<? extends Number> src, List<? super Number> dest) {
dest.addAll(src);
}
public static void main(String[] args) {
// Integer のリスト(src)
List<Integer> intSrcList = List.of(1, 2, 3);
// Number のリスト(dest)
List<Number> numDestList = new ArrayList<>();
// 移し替え(PECS が成立する組み合わせ)
copyList(intSrcList, numDestList);
System.out.println(numDestList); // intSrcListにセットしていた[1, 2, 3]がコピーできていることが確認できる。
}
}
フレームワークなど既存コードを読む際にも役立ちます。
<? extends T> と書かれていれば P(Producer)=読み取り専用だな と、
そのコードの意図を素早く読み取れるようになります。
まとめ
今回は、ジェネリクスが覚えづらい理由のひとつである
extends / super の意味と使い分けを、PECS 原則を通して整理しました。
原則は縛るためのものではなく、理解を助け、迷ったときのコンパスになるものだと思っています。
原則を知ったうえで
「どこまで使うか」「どう応用するか」
を自分で判断できるようになると、また一段レベルアップできるはずです。
最後に、今回紹介した PECS 原則は載っていませんが、
世の中には本当にたくさんの原則が存在します。
それらを理解するうえでの導入として、とても良かった本を紹介しておきます。
さらに、こちらはもう少し具体的で、
世の中で誤って広まってしまっている原則についても、正しい解釈を明示的に説明してくれている本です。
原則をもう少し深く理解したい方にはこちらもおすすめです。

