4
2

More than 1 year has passed since last update.

コレクションとジェネリクス~不変と共変~【Java】

Last updated at Posted at 2023-01-05

image.png

この記事ではコレクションとジェネリクスを使いこなせるようになるための基礎知識をまとめています。
ラムダ・ストリーム・オプショナルを使いこなす基礎としても。

1. コレクションとは?

Javaでは提供されている、複数のデータを扱うためのクラスのこと。配列よりも柔軟に複数のデータを扱える。配列は作成された時点で要素数が固定され、状況に応じて柔軟に要素数を変更したい処理には適さない。コレクションを利用すれば、要素数の変更にも柔軟に対応できる。

この記事では集合オブジェクトという言葉には

  • 配列
  • コレクション

を含めます。上記の通り、コレクションと配列を区別します。

コレクションといえば下記で紹介するものになります。

1-1. コレクションの種類

  • リスト(List) 順序通りに並べて格納
  • セット(Set) 順序があるとは限らず格納
  • マップ(Map) ペアで対応づけて格納

柔軟な配列みたいな List 、PHPの連想配列みたいな Map 、 Setは重複を除外できる。

引用元:Javaちょこっとリファレンス

コレクションクラスには大きく分けてList、Map、Setの3種類あります。そして、さらに性質の異なるクラスに分かれています。各コレクションクラスの特徴は以下のようになっています。

【コレクションクラスの比較】
実装クラス  ArrayList LinkedList HashMap TreeMap HashSet TreeSet
インタフェイス List List Map Map Set Set
要素の重複 × × × ×
null値の要素 × ×
自動ソート × × × ×

1-2. それぞれの特徴

1-2-1. List

Listは、配列に非常によく似ている。

  • 重複した要素を含むことができる順序の付けられたコレクション。
  • インデックスによって要素を挿入したり要素にアクセスしたりする位置を自由に変更することができる。
  • 要素数は固定されておらず可変、要素の大きさは未確定なのが配列との決定的な違い。
//ArrayList型で宣言する場合
ArrayList<型> 変数名 = new ArrayList<型>();

//List型で宣言する場合
List<型> 変数名 = new ArrayList<型>();

1-2-2. Set

  • 重複が排除された状態でデータを管理する。
  • インデックスで値を管理していないのでget()できない→ループしてデータを見る
  • setの中身をソートしてくれるのがインターフェースSortedSet。その実装クラスがTreeSet
Set<Integer> store = new HashSet<>();

1-2-3. Map

  • 連想配列
  • newの時にキーと値の両方を型として指定(ジェネリクス)
Map<string, Integer> maps new HashMap<>();

1-3. 配列とListの使い分け

  • 配列

要素の数が固定的に決まっている場合
アクセス速度や処理の軽さが極端に求められる場合

  • List

要素の数が処理によって変わる場合
→基本的には(Array)Listを使い、
要素数が将来にわたって完全に固定である場合のみ
配列を使うとよい。

1-3-1. ListとArrayList

違い

  • Listはインターフェース
  • ArrayListはListインターフェースを実装したクラス

まずインターフェースとは、具体的な処理の内容の記述は無く、実装先のクラスで定義できるようにメソッドの型や変数を記述したもの。そのため、インターフェース自体のメソッドを用いて機能を呼び出すことはできません。それに対しArrayListはインターフェースであるListをもとに実装化したクラスであるため、機能を呼び出し使用する事ができます。

先述ではListインターフェースを直接用いて機能を利用することはできないと記述しましたが、宣言側の型としてListを使用する事は可能です。この場合のインスタンスは実際はArrayListでありながら表面的にはあくまでListとして捉えられるよう、多様性や拡張性を考慮して意図的に生成されたかたちとなります。
しかし、Listはあくまでインターフェースであり、実装されたクラスではないため、以下のような宣言はおこなう事ができません。

List<型> 変数名 = new List<型>();
                       
インターフェース説明
java.util.List順序通りに並んだ要素の集まりを管理するデータ構造
                             
実装元インターフェース実装クラス説明
java.util.Listjava.util.ArrayList配列と同様に0開始の連番で要素を管理

1-3-2. ArrayList とは

ArrayList は List インターフェイスの実装クラスであり、配列と同じように 0 開始の連番で各要素を管理できるコレクション。要素数は状況に応じて柔軟に増減させることができる。

2. ArrayListのオブジェクト生成:ArrayList型でなくList型が鉄板?

<構文 ArrayList のオブジェクトの生成>

List<型名> 変数名 = new ArrayList<>(); 

ArrayList のオブジェクトだから、ArrayList 型の変数を宣言するかと思いきや実際に ArrayList を使用する場合、List インターフェイスに定義してあるメソッドだけで十分な場合がほとんど。
List 型で定義することにより、ArrayList 以外の List インターフェイスの実装クラスを代入することもできる。そのため、ArrayList を使用する場合は、一般的に List 型の変数に代入することが多いらしい。

2-1. List型で宣言するメリット

  1. 「 List インターフェースにある機能だけで充分、ArrayList 等の独自の機能は必要としていない」ということを明示できる
  2. List型で宣言された他インスタンス(LinkedListなど)と型の違いを気にすることなくまとめて処理をおこなえたり、後で型変更をおこなってもコードの変更範囲が少ない点

→List 型で宣言された変数には、必要であれば、ArrayList だけでなく、LinkedList といった他の List インターフェースを継承したオブジェクトを代入出来るようになるため拡張性が広がる。

2-2. List型で宣言するデメリット

  1. ArrayList固有のメソッドを使用できない

使用したい場合はArrayList型へのキャストをしなければいけない。

  • clone():ArrayListのインスタンスをシャローコピー。要素自体はコピーされない(参照元コピー)
  • ensureCapacity():ArrayListのサイズを確保する→コンストラクタで指定した初期サイズを大きく超えそうなとき使用
  • removeRange():指定したインデックス番号内の要素を削除し、後続要素は左に移動しインデックス値は縮小する
  • trimToSize():ArrayListのサイズを現在の要素数で切り詰め→以降の処理で要素を追加しないような場合

2-3. ArrayList型で宣言する場合のメリット・デメリット

メリット

  1. 「 ArrayList 独自の機能が必要」ということを明示できる
  2. 「 ArrayList 独自の機能」が使える

デメリット

  1. 拡張性や多能性が下がってしまう

2-4. List型とArrayList型のまとめ

ArrayList固有メソッドの使用が予想される場合以外は、List型宣言で良さそう。
みなさんどう思いますか??

ところで、List は上記で紹介しましたが「重複した要素を含むことができる順序の付けられたコレクション」です。 順序が不要であれば本来は List を使うべきではないと思うのですが、その辺りまで加味した運用が意識されているのを今のところ見た事がありません。

参考:インタフェースList

結局は、本来の意図と違っても多数の共通認識に沿った運用が良いんだろうなーとか思ったり。

3. 型の指定 ジェネリクス

ArrayList も配列と同様に扱うデータの型を指定。その際に使用するのが、ジェネリクス(< >)。総称型とも呼ばれ、扱う型を指定するための機能。「<>:ダイヤモンド演算子」内に型を記述することにより、記述された型のデータを ArrayList 内で扱うことができる。

というか、指定された型しか取り扱う事が出来なくなった、のほうがいいのかな?

3-1. ジェネリクスの注意点

  1. ジェネリクスには参照型しか記述できない。
    • 整数を扱う ArrayList を作成する場合、<int>と記述してしまうとコンパイルエラー。プリミティブを扱う場合は、それに対応したラッパークラス(このケースなら Integer)を指定する。
  2. 変数宣言の際に var を使用した場合、ジェネリクスによる型推論ができない
    • 左辺にも右辺にも型の情報がなくなるから
    • ただし、コンパイル可能(型パラメータに object クラスがバインドされる)
    • どのようなクラスでも扱いたいなら良いがそうでないならあかん
    • 型推論とはコンパイラーがコンパイル時に行う。
      宣言されているジェネリクスの型からインスタンス生成時に型を推論すること
  3. 配列は共変で型安全ではない。ジェネリクスは不変のため型安全
    • 共変と反変にする方法もある
      • 境界型パラメータと境界ワイルドカード

3-2. ジェネリクスが解決してくれた事 ~型安全~

  1. さまざまな型を入れられるコレクションに対し型を限定できるようになった
  2. 目的の型へのキャストが省略できる
  3. ListやMap等のコレクションを返すメソッドの場合、何の型を扱っているかが解りやすい
  4. 間違った型で扱ってしまっても、実行時コンパイルエラーとなるので間違いが発見しやすい

ジェネリクス以前のリストには、何でも格納できました。Object型の参照の集合をリストは扱います。全てのクラスはObjectクラスを暗黙的に継承しています。なので文字列、数値、キャラクタなんでもかんでも放り込めます。そして、このリストから何かを取り出して使うには、キャストしなきゃいけんかったり、型を保証するリストを実現するにはそれ専用のクラスをいちいち作らなくてはいけなかったみたいです。

3-3. ジェネリクスは何をしているのか?

型のパラメータ化
ジェネリクスはクラスの型を変数のようにパラメータとして扱い、型パラメータと呼びます。よく見る<T>とか <S> とかですね。


class Test <T> {
    private final T testValue;

    public Test(T testValue) {
        this.testValue = testValue;
    }

    public  T get() {
        return testValue;
    }
}

このクラスをインスタンス化する際に、型パラメータ <T>にIntegerクラスをバインドします。

new Test<Integer>(123);

上記のクラス Test をジェネリクス化されたクラスと呼びます。

// 型パラメータに Integerクラスをバインド
Test <Integer> test = new Test<Integer>(123);

// Integer クラスにバインドしたので型を検査する必要がない
Integer number = test.get();

<T>を仮型パラメータ、バインドされた後の型パラメータを実型パラメータといいます。仮引数と実引数と同じ考えですね。

コレクションはどんな型でも扱えるがゆえにコレクション内に想定外の型の値が混ざっても、ジェネリクス以前はコンパイルエラーを出せず、実行してみないと例外が発生しませんでした。実行しないとエラーを検出でいないのはかなりまずいですよね(ClassCastException)

ジェネリクスは、コレクションのインスタンス生成時に扱える型の制限・指定をし扱える型に制限をかける事で型安全を保証する仕組みです。型の制限によってバインドされた型以外を代入した場合コンパイルエラーが発生するようになり、実行前に不具合を検出可能になりました。

この型の指定による制限にはいくつかの方法があります。
境界型パラメータや境界ワイルドカードと言います。

3-4. 共変・不変・反変

引用元:【Java】ジェネリックス型の不変、共変、反変とは何か

Object が String のスーパータイプであるとき、

  • 不変(invariant):List と List には関係性がない
  • 共変(covariant):List は List のスーパータイプ
  • 反変(contravariant):List は List のスーパータイプ

と定義されているのです。

これらは、ジェネリックス型同士の継承についての性質です。継承関係にあるサブクラスをジェネリクス型で宣言し、そのインスタンスにスーパクラスの型の参照型を代入させてもたらあかん、ということです。これはジェネリクス登場前のコレクションで起きていたことです。

共変・反変を許容するとジェネリクスでインスタンス生成時に型を指定し制限した意味がなくなってしまいます。いろんな肩がごちゃ混ぜになった時、それを実行時 ClassCastException が発生させないこと、つまり実行しないとエラーが検出できないではなく、コンパイル時に型チェックでエラーを検出するのがジェネリクスの目的なのです。実際に下記のような書き方はコンパイルは通りませんが通ったとしましょう。

public class Main {
    public static void main(String[] args) {
        List<Object> list = new ArrayList<String>();
        list.add(new Object);

        list<String> stringList = list;
        for (String str : stringList) {
            System.out.println(str);
        }
 }

ここでは本来、Object型から String型へのダウンキャストによる実行時例外が発生します。実行しなくても事前に検査するためのジェネリクスでした。

List<Object> list = new ArrayList<String>();

この時点でジェネリクスによる型チェックでコンパイルエラーが起きます。Objectクラスと Stringクラスは継承から見た際の上下関係と型の互換性がありますが、ジェネリクスではその関係性は成立しません。

3-5. 境界型パラメータとワイルドカード

ジェネリクスで型を指定する際に、単一の型だけでは制限がきつすぎて使い勝手が悪いという事が起こるそうです。型パラメータに継承関係の制約を持たせたい時に使用するのが境界型パラメータです。

public class NumberDeal <T extends Number> {
    .....

これは Numberクラスのサブクラスを実型パラメータに指定します。Integer / Double など。
この境界型パラメータを利用して、型パラメータの継承関係の制限をコントロールする事ができます。

前述したようにジェネリクス型は不変ですが実は、境界型パラメータとワイルドカードを使用する事で不変から共変・反変へ性質を変化させ、その度合いをコントロールする事ができます。

境界型パラメータとワイルドカードを合わせて使用する際は「境界型ワイルドカード」とよびます。
境界型ワイルドカードは以下のように表現できます。

  • 上限境界型
    具体例:List<? extends Number>
    Numberクラス以下のサブクラスを扱える、Number より上のクラスは扱えない
    共変性を持たせる
    値の取得のみが可能であり、書き込むことはできない
    「? extends Object」という「共変」化したリストの目的は、「Object以下の要素を持つリストから値を取得して何かする」ためのもので、書き込みは禁止されている。
    値を追加するのは不変なジェネリックで行い受け取る時には境界ワイルドカードで幅広く受け取る。

  • 下限境界型
    具体例:List<? super Number>
    Numberクラスのスーパークラスを扱える、Number より下のクラスは扱えない
    反変性を持たせる
    「? extends Integer」という反変化したリストの目的は「取得はできないが、書き込みはできる」オブジェクト作成

    いつ使うんだ??

List<Number> uppers = ArrayList<>();                       // → uppers は不変の性質
List<? extends Number> uppers1 = new ArrayList<Object>();  // → コンパイルエラー
List<? extends Number> uppers2 = new ArrayList<Number>();  // → 問題なし
List<? extends Number> uppers3 = new ArrayList<Integer>(); // → 問題なし
// → 上限境界型の使用で共変性を持たせる

List<Number> lowers = ArrayList<>();                     // → lowers は不変の性質
List<? super Number> lowers1 = new ArrayList<Object>();  // → 問題なし
List<? super Number> lowers2 = new ArrayList<Number>();  // → 問題なし
List<? super Number> lowers3 = new ArrayList<Integer>(); // → コンパイルエラー
// → 下限境界型の使用で反変性を持たせる

Effective Javaでは「API の柔軟性向上のために境界ワイルドカードを使用する」という記述があります。不変による型安全の制約が、APIの柔軟性を損ねる場合がありその場合は境界型ワイルドカードを使えと言っています。ぶっちゃけよく分かりまん。

3-5-1. GET & PUT 原則(PECS)

PECS(Producer-Extends、Consumer-Super)

Producer、すなわちオブジェクトを生成するジェネリックス引数は extends(上限境界型ジェネリックス)を使うべきである。

Consumer、すなわちオブジェクトを消費するジェネリックス引数は super、(下限境界型ジェネリックス)を使うべきある。

プロデューサーとは関数内で、何らかの値を生成(提供)する引数。コンシューマーとは関数内で、何らかの値を消費(利用)する引数のこと。関数型インターフェイスあたりでよく聞く言葉ですね。

これで、標準ライブラリのジェネリクスの仮型パラメータとか見ても混乱しないですみそうですね。
ラムダ・ストリーム・オプショナル使いこなしたいですね。

ちなみに、HashMapの「HashMap」はK、Vという2つの型(この場合であれば、キーと値の型)を受け取れることを意味します。
型パラメータは、慣例的に大文字一文字で表すのが普通です。E(要素)、K(キー)、V(値)などが良く使われるそうです。

4. 終わりに

これまでの記事の紹介です。併せて他の記事も読んでいただけると嬉しいです。

ありがとうございました!

4
2
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
4
2