ジェネリッククラスを作る

生田 陸人
LuaGate エンジニア / 現役エンジニア
編集 LuaGate編集部

このレッスンで分かること

  • class Box<T><T> は「中身の型を後から決める」プレースホルダ
  • TTypeEElementK/VKey/Value の命名慣習
  • プリミティブ型 (int 等) は型パラメータに使えない。Integer などのラッパー型を使う
  • 実行時には 型消去Box<String>Box<Integer> も同じ Box クラスになる

ジェネリッククラスを作る とは

型パラメータ <T> を使って、任意の型を受け取れるジェネリッククラス Box<T> を自分で定義し、文字列を包んで取り出すコードを書いてみよう。

「型を後から決める」仕組み ─ ジェネリクスとは?

前のレッスンで ArrayList<Integer>ArrayList<String> のように、山かっこの中に型を書く ジェネリクス という記法を見ました。あの <...> の正体は、「このクラスは中に入れる値の型を、後から指定して使うよ」というプレースホルダです。Java の標準ライブラリには、こうした「中に入れる型を呼び出し側が決めるクラス」が大量に登場します。

ジェネリクスは Java 5 (2004 年) で導入された仕組みです。それより前は List の中に何でも入れられて、取り出すたびにキャストが必要、しかも実行時にエラーが出る、というつらい時代がありました。ジェネリクスのおかげで、コンパイル時に「この箱には String しか入りません」と保証できるようになりました。

ArrayList<E>EElement (要素) の頭文字で、「ここに後から具体的な型が入る」という枠です。同じ仕組みで Map<K, V>KKeyVValue を表しています。これらは Java 開発者の間で広く使われている命名の慣習で、自分で書くときも倣うと読みやすくなります。

diagram (will load when visible)

本章では、自分で ジェネリッククラス を作る側を体験します。中に入れる型を 1 つの記号で表しておき、new Box<String>() のように呼び出し側で実体を決めてもらう、というスタイルに慣れていきましょう。

ArrayList<E> で見るジェネリクスの便利さ

まずは標準ライブラリの ArrayList<E> のソースコード (概念だけ) を眺めてみます。実際の JDK 内では次のような形で定義されています。

Java

public class ArrayList<E> { private Object[] elements; private int size; public void add(E value) { // 末尾に value を入れる } public E get(int index) { // index 番目を E 型で返す return (E) elements[index]; } }

注目したいのは、add の引数の型と get の戻り値の型が両方とも E になっている点です。ArrayList<String> として使うと、EString に置き換わったのと同じ振る舞いをするので、add("hello") は OK、add(123) はコンパイルエラーになります。String s = list.get(0); のように、取り出した値もそのまま String として受け取れます。

ジェネリクスを使わずに Object[]Object を使って同じことをすると、add には何でも入ってしまい、get の戻り値は Object なので毎回キャストが必要になります。コンパイル時の安全性と読みやすさが大きく違うのが分かります。

ジェネリッククラスを自分で定義する

それでは、今回の主役 Box<T> クラスを書いてみます。中身は「何か 1 つ値を抱える箱」というシンプルなものです。

Java

public class Box<T> { private T value; public Box(T value) { this.value = value; } public T getValue() { return this.value; } }

ポイントは次のとおりです。

  • クラス名の後ろに <T> を書く ─ ここで「このクラスは型パラメータ T を 1 つ持つよ」と宣言している
  • フィールド value の型に T を使う ─ 何が来るかは呼び出し側が決める
  • コンストラクタの引数も T で受け取る ─ 渡されたものをそのまま入れる
  • getValue の戻り値も T ─ 入れたときの型のまま返せるので、利用側でキャスト不要

呼び出し側のコードはこうなります。

Java

Box<String> stringBox = new Box<>("hello"); String s = stringBox.getValue(); // String として取り出せる System.out.println("Box[" + s + "]"); // Box[hello] Box<Integer> intBox = new Box<>(42); Integer n = intBox.getValue(); // Integer として取り出せる System.out.println("Box[" + n + "]"); // Box[42]

new Box<>("hello") の右側のかっこが空っぽなのは、ArrayList のときと同じ ダイヤモンド演算子 です。左側で Box<String> と書いているので、右側はコンパイラが自動的に推論してくれます。

型パラメータの命名規則

型パラメータの名前は理屈の上では何でも構いませんが、Java の世界には次のような強い慣習があります。

  • TType、汎用の何か 1 つの型 (最初に出すならこれ)
  • EElement、コレクションの要素 (ArrayList<E> Set<E> など)
  • K VKey Value のペア (Map<K, V> HashMap<K, V>)
  • RResult、関数型インタフェースの戻り値 (Function<T, R>)
  • NNumber、数値系の型パラメータ

1 文字大文字、というのが共通ルールです。<MyType> のように長い名前にしてしまうと、コードを読む人が「これはクラス? インタフェース? それとも型パラメータ?」と一瞬迷います。慣習に従っておくのが無難です。

慣習はあくまで慣習で、Java の文法上は class Container<Anything> でも動きます。とはいえチーム開発では「全員が一目で分かる」ことが大事なので、T E K V を素直に使うのが安全です。

Mermaid で見るジェネリクスの実体化

Box<T> というクラスは 1 つしかありませんが、Box<Integer>Box<String> のように違う型で使うと、まるで別々のクラスのように振る舞います。中身のフィールドや戻り値の型が、それぞれ IntegerString に置き換わるからです。

diagram (will load when visible)

図のように、1 つの設計図 Box<T> から、複数の「型が確定したクラス」が型ごとに展開されるイメージを持っておくと、ジェネリクスの感覚がつかみやすくなります。

よくある間違い

ジェネリッククラスを書き始めると、初学者は同じところでよく詰まります。事前に知っておきましょう。

  • raw 型で書いてしまうBox box = new Box("hello"); のように <> を省略すると raw 型 扱いになり、getValue() の戻り値が Object になってしまいます。コンパイラは警告を出すだけで止めてはくれませんが、現代の Java では絶対に書かないのが鉄則です
  • プリミティブ型を T に入れようとするBox<int> Box<double> はコンパイルエラーです。型パラメータには必ず参照型 (クラス) を渡す必要があるので、intIntegerdoubleDoublebooleanBoolean というラッパー型を指定します
  • Tstatic フィールドに使うstatic T defaultValue; のような書き方はコンパイルエラーになります。static はクラス全体で共有される領域で、インスタンスごとに T が変わるという前提と矛盾するためです。static メソッドにジェネリクスを使いたいときは、メソッド側に <T> を書く「ジェネリックメソッド」という別の仕組みを使います

もう 1 つよく聞かれるのが、「Box<String>Box<Integer> は同じクラス?」という疑問です。実は実行時には Java の 型消去 (type erasure) という仕組みが働いて、Box<String>Box<Integer>Box という同じクラスにまとめられます。型情報はコンパイル時のチェックにだけ使われ、実行時には消えるのです。

型消去 は Java 5 でジェネリクスを導入したときに、それ以前の Java 4 用にコンパイルされたクラスファイルとの互換性を保つために選ばれた設計です。C#Kotlin のように実行時にも型情報を持つ言語と違って、Java のジェネリクスは「コンパイル時の安全性」に特化した仕組みだと覚えておきましょう。

この仕様の影響で、if (obj instanceof Box<String>) のような書き方はできず、if (obj instanceof Box) までしかチェックできません。実用上は気にしない場面が多いですが、リフレクションや高度なライブラリを触るときに思い出すと役に立ちます。

実務で遭遇するパターン

中級レッスンで学んだことが、実際の業務コードでどう登場するかを整理します。

  • API レスポンスの共通ラッパーApiResponse<T> のように status errorCode メタ情報と本体 T をまとめる定番パターン
  • ページネーション結果Page<T>List<T> items int totalCount を持たせ、Page<User> Page<Order> で使い回す
  • Result / Either 型の自作Result<T> に成功時の値か失敗時のエラーを格納し、null を返す代わりに型で表現する
  • キャッシュ層Cache<K, V> のような汎用キャッシュ。社内ライブラリで作っている会社は意外と多い

コードレビューで指摘されがちなポイント

PR を出すとシニアから入りやすい指摘です。先回りで身につけておけばレビューが一発で通ります。

  • raw 型を使っているBox box = new Box(...) のように <> を省くのは新規コードでは原則 NG
  • 型パラメータ名が長すぎるclass Box<TheTypeOfValue> のような書き方。1 文字大文字に直すよう求められる
  • static フィールド / メソッドで型パラメータを使おうとしているstatic T defaultValue; はコンパイルエラー
  • 不要なキャストが残っている ─ ジェネリクスを使えばキャストは不要、削れるはずとレビューが入る

パフォーマンス考慮事項

  • オートボクシングのコストBox<Integer> を大量に作るとヒープが汚れる。CPU バウンドでは int 専用クラスのほうが速い
  • 型消去の影響 ─ 実行時には Box.class 1 つ。instanceof Box<String> のような細粒度チェックはできない
  • 配列との混在 NGnew Box<String>[10] は generic array creation エラー。ArrayList<Box<String>> を使う
  • 深くネストする型Map<String, Map<Integer, List<User>>> 等は IDE 推論が重くなる。型エイリアスクラスで見通しを上げる
この章のポイント

ここまでの要点 クラス名の後ろに <T> で型パラメータを宣言、フィールド / メソッドで T を使う。T/E/K/V の慣習に従い、static フィールドや配列との混在は避ける。

やってみよう

それでは今回の課題に挑戦します。やることは次のとおりです。

  1. Solution クラスの中で、Box<T> 相当のジェネリッククラスを定義する (static class Box<T> でも、別ファイル相当の作りでも OK)
  2. コンストラクタで受け取った値を value フィールドに保存し、getValue() で取り出せるようにする
  3. Solution.boxedValue(String s) の中で new Box<String>(s) (もしくは new Box<>(s)) を作り、getValue() の結果を取り出す
  4. 取り出した文字列を "Box[" + s + "]" の形に連結して返す

戻り値のフォーマットは Box[hello] のように、Box[ で始まり ] で終わる単純な文字列です。s が空文字 "" の場合は "Box[]" を返してください。Java という入力なら "Box[Java]" です。一度書けたら、Box<Integer> 版も自分で書いてみると、ジェネリクスの「型を変えるだけで同じ作りが使える」便利さを実感できます。

慣れてきたら、BoxsetValue(T newValue) を足してみたり、フィールドに値を 2 つ持つ Pair<K, V> クラスを書いてみたりすると、Map.Entry<K, V> などの標準ライブラリで使われている設計の感覚がつかめます。次のレッスンでは、メソッド単位で型パラメータを取る「ジェネリックメソッド」を扱う予定です。

よくある質問

Q. ジェネリクスを使うと何が嬉しいですか?

A. 型安全と再利用性が両立します。Box のように型パラメータで宣言すれば Box、Box として使い回せ、コンパイル時に型不一致を検出できます。キャストや instanceof が減るため、実行時エラーも防げます。

Q. ワイルドカード ? extends と ? super の違いは?

A. ? extends T は「T のサブクラス」で読み取り専用、? super T は「T のスーパークラス」で書き込み専用というイメージです。PECS(Producer Extends, Consumer Super)の頭文字で覚えるのが定番です。データを取り出すなら extends、入れるなら super を選びます。

Q. ジェネリクスはコンパイル後も残りますか?

A. Java は型消去(erasure)方式のため、実行時には Object になります。そのため List と List は実行時に区別できず、instanceof List はできません。リフレクションで型情報を取りたい場合は TypeToken のようなトリックが必要です。

次のレッスン

次は ジェネリックメソッド で、ジェネリックメソッド を学びます。

事前確認 — 進む前に次の 3 つができることを確認しましょう。

  1. ジェネリッククラス の要点を自分の言葉で説明できる
  2. このレッスンの最小コード (または操作手順) を見ずに書ける
  3. 練習問題やクイズで間違えた箇所を読み直して理解した

理解度チェック (30 秒)

Q. ジェネリッククラス とは何か、1 文で説明してください。

この章のポイント

A. 本文の「このレッスンで分かること」または冒頭の説明文を見直し、自分の言葉で要約できれば OK。詰まったら本レッスンの最初の H2 セクションを読み返してみましょう。

関連レッスン

要件

  1. Solution クラスの中 (もしくは同ファイル) に、型パラメータ <T> を持つ Box 相当のジェネリッククラスを定義すること
  2. Box クラスは値を保存するフィールドと、それを取り出す getValue() 相当のメソッドを持つこと (raw 型 Box のままにせず、必ず <> で型を指定すること)
  3. boxedValue の内部で Box<String> を生成し、getValue() で取り出した値を使って "Box[" + value + "]" の文字列を返すこと

入出力例

test-cases.txt

boxedValue("hello")"Box[hello]" boxedValue("Java")"Box[Java]" boxedValue("")"Box[]" boxedValue("こんにちは")"Box[こんにちは]"

ヒント

main.java
main.java
学習モード

メモ

ジェネリッククラスを作る

⌘S で保存