11
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

7.1 総称型の仕組み~新Java Advanced編 / AI対応版

11
Last updated at Posted at 2023-02-04

はじめに

自己紹介

皆さん、こんにちは、Udemy講師の斉藤賢哉です。私はこれまで、約30年にわたって企業システムの開発に携わってきました。現在も現役のアーキテクトとして、重要システムの技術設計やソリューションの選定を担当しています。直近では、システム開発へのAI適用(AI駆動開発)という全社的な施策でリーダーを務めています。
セミナーでの登壇や雑誌への技術記事の寄稿を行ってきたほか、以下の書籍も執筆しています。

Udemy講座のご紹介

この記事の内容は、私が講師を務めるUdemy講座『新 Java Advanced編 / AI対応版』の一部の範囲をカバーしたものです。『新 Java Advanced編 / AI対応版』はこちらのリンクから購入できます。

この講座は、以下のような皆様にお薦めします。

  • Javaの基礎を学び終え、設計とレビューができるエンジニアを目指す方
  • AIが書いた並行処理や入出力のコードに潜むリスクを、仕組みから見抜けるようになりたい方
  • フレームワークの裏で使われるリフレクションやアノテーションの仕組みを理解したい方
  • 「Oracle認定Javaプログラマ」Goldの範囲を、仕組みから理解したい方

AIとチャットできるオールインワンの学習環境をリリースしました

Udemy講座の演習を動かすための学習環境として、Windows向けのデスクトップアプリ「Codinable(コーディナブル)」をリリースしました。
エディタ、実行結果、テスト結果、SQL、Webブラウザ、そしてAIチャットを1つの画面にまとめた、オールインワンの学習環境です。

Codinableの画面構成

学習に必要なものは、すべてCodinableの中で完結します。

  • 演習の実行環境(Java 25、Gradle、Bashなど)が一式同梱されており、JDKのインストールや環境変数の設定は不要です
  • インストールして「新規コース開始・再開」からコースを選ぶだけで、講座のすべての演習がそのまま動きます
  • 書き換えて動かなくなっても、「初期化」で配布時の状態に戻せます
  • 開いている演習のファイルと実行結果を踏まえて、AIに質問したり(Askモード)、ファイルの書き換えを指示したり(Agentモード)できます

AIに書き換えを指示し、差分を確かめてから実行する、というAI駆動開発の基本の流れを、Codinableの中だけで安全に体験できます。

AIチャットに書き換えを指示し、差分を確かめて実行する

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>も、この原則で宣言されています。引数の型を決める流れを示します。

PECSによる引数の型の決め方

  • 最初の判断は「メソッドの中でその引数から要素を取り出すか」と「その引数へ要素を入れるか」である
  • 取り出すだけなら、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>などのワイルドカードを使っていないかを確かめる
  • ワイルドカードで受け取ったリストに追加するために、原型やキャストで回避していないかを確かめる

このチャプターで学んだこと

本チャプターでは、以下を取り上げました。

  1. 総称型は型の安全性をコンパイル時に保証し、原型・未検査のキャスト・@SuppressWarnings("unchecked")はその保証を崩して実行時のClassCastExceptionにつながること
  2. 型パラメータはコンパイル後に消去され(イレイジャ)、実行時には型引数の情報が無いため、new T()、instanceof List<String>、スタティックなメンバーでの使用、消去後に衝突するオーバーロードができないこと
  3. ジェネリックなメソッドは引数から型パラメータを推論し、境界型パラメータを付けると上限の型のメソッドを呼び出せること
  4. 型パラメータ同士は非変であり、共変の配列が実行時に検出する誤りを、総称型はコンパイル時に検出すること
  5. ワイルドカード型は非変を補い、取り出す側にはextends、入れる側にはsuperを使う(PECS)ことで、型の安全性と柔軟性を両立できること
11
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?