Java : 配列 vs. List
配列とリスト、どちらを使うか迷ったことはありますでしょうか?
明確な理由がなければリストを使うことをおすすめします。
本記事では、配列とリストを比べつつ、リストのメリットについて解説していきます。
概要
Java では、同じ型の複数の要素(値)を管理する方法として、配列 と リスト があります。
// 配列
final String[] array = {"aaa", "bbb", "ccc"};
// リスト
final var list = List.of("xxx", "yyy", "zzz");
どちらも、複数の要素を管理したい、という大きな目的は同じですね。
拡張for文 で簡単に要素に順次アクセスできる点も同じです。
for (final var value : array) {
System.out.println(value);
}
// 結果
// ↓
//aaa
//bbb
//ccc
for (final var value : list) {
System.out.println(value);
}
// 結果
// ↓
//xxx
//yyy
//zzz
配列は Java言語仕様 に組み込まれている機能の1つです。
複数の要素を管理するための必要最低限の機能を持ちます。
一方、リスト(List) は、標準ライブラリの API の1つです。
配列よりも高機能となっています。
特に、リストには次のようなメリットがあります。
- サイズを管理しなくてよい
- 必要に応じて不変オブジェクトにできる
逆にデメリットは次のようなものがあります。
- プリミティブ型 (byte や int など) のリストは作れない
ちなみに、プリミティブ型の配列は作れます。
そこが配列のメリットでもありますね。
それでは、それぞれ詳しく見ていきましょう。
リストのメリット
サイズを管理しなくてよい
リストはサイズを気にしなくてOKです。
最初は空のリストオブジェクトを作成して、必要に応じて要素を追加することができます。
サイズはリストが自動で拡張してくれます。
final var list = new ArrayList<String>();
System.out.println(list); // []
System.out.println(list.size()); // 0
list.add("aaa");
System.out.println(list); // [aaa]
System.out.println(list.size()); // 1
list.add("bbb");
System.out.println(list); // [aaa, bbb]
System.out.println(list.size()); // 2
list.add("ccc");
System.out.println(list); // [aaa, bbb, ccc]
System.out.println(list.size()); // 3
サイズを管理しなくてよい、というのはとても大きなメリットです。
思い出してみましょう…ガベージコレクタはメモリ管理の苦痛からプログラマを救ってくれました。
Java ではメモリリークという不具合はかなり減ったはずです。
同じように、リストはデータサイズの管理からプログラマを救ってくれるのです。
一方、配列のオブジェクトは、最初にサイズを決めてから作る必要があります。
途中でサイズを変更することはできません。
// 初期化子による生成
final String[] array1 = {"aaa", "bbb", "ccc"};
System.out.println(array1.length); // 3
System.out.println(Arrays.toString(array1)); // [aaa, bbb, ccc]
// new演算子による生成
final var array2 = new String[3];
array2[0] = "xxx";
array2[1] = "yyy";
array2[2] = "zzz";
System.out.println(array2.length); // 3
System.out.println(Arrays.toString(array2)); // [xxx, yyy, zzz]
最初からサイズが固定できればよいのですが、途中で要素を追加したい…というときには困ります。
final String[] array1 = {"aaa"};
System.out.println(array1.length); // 1
System.out.println(Arrays.toString(array1)); // [aaa]
// 配列に "bbb" を追加したい。
final var array2 = new String[array1.length + 1];
array2[0] = array1[0];
array2[1] = "bbb";
System.out.println(array2.length); // 2
System.out.println(Arrays.toString(array2)); // [aaa, bbb]
配列に要素を追加する例です。
サイズを+1した配列を新しく作成して…と、ちょっと無理やり感がありますね。
途中でサイズを変えたいのであれば、素直にリストを使ったほうがよいでしょう。
不変オブジェクトにできる
不変オブジェクトについては下記の記事もご参照ください。
メリットなどをいろいろと解説しています。
ざっくりいうと、不変オブジェクトをうまく活用すれば、不具合が発生しづらい品質のよいプログラムになります。
Java の標準ライブラリにも、不変オブジェクトと明示されている API がたくさんあります。
リストには不変オブジェクトに関する便利なメソッドが用意されています。
初期値を使って不変オブジェクト作成したいときは、List.of メソッドを使います。
final var list = List.of("aaa", "bbb", "ccc");
System.out.println(list); // [aaa, bbb, ccc]
// 追加や削除といった操作はできません。
try {
list.add("ddd");
} catch (UnsupportedOperationException e) {
System.out.println("UnsupportedOperationException!");
}
// 結果
// ↓
//UnsupportedOperationException!
他には、List.copyOf メソッドも不変オブジェクトとしてリストを作成します。
最初から最後まで変更しないリストは、これらのメソッドを使って不変オブジェクトとして作成しましょう。
次に、別のケースを考えてみます。
class Sample {
private final List<String> values = new ArrayList<>();
void add(String value) {
values.add(value);
}
List<String> getValues() {
return values;
}
}
Sampleクラスでは、values フィールドでリストを管理しています。
values は追加だけを許容して、削除やクリアなどで要素が減ることはない、という仕様にしたいとします。
実は上記の Sampleクラスには問題があります。
実際に使ってみましょう。
final var sample = new Sample();
sample.add("aaa");
sample.add("bbb");
sample.add("ccc");
System.out.println(sample.getValues()); // [aaa, bbb, ccc]
これだけ見ると問題ないように思えます。
しかし、Sampleクラスの values フィールドは外側から変更できます。
Sampleクラスをよく知らない人が、次のようなコードを書き足したとしましょう。
...
System.out.println(sample.getValues()); // [aaa, bbb, ccc]
sample.getValues().clear();
System.out.println(sample.getValues()); // []
Sample.getValues メソッドで返されたリストオブジェクトを経由して、Sample.values のクリアができてしまいました。
この問題を解決するために、getValues メソッドで返すオブジェクトを不変にします。
class Sample {
...
List<String> getValues() {
// 不変オブジェクトにラップして返します。
return Collections.unmodifiableList(values);
}
}
final var sample = new Sample();
sample.add("aaa");
sample.add("bbb");
sample.add("ccc");
System.out.println(sample.getValues()); // [aaa, bbb, ccc]
try {
sample.getValues().clear();
} catch (UnsupportedOperationException e) {
System.out.println("UnsupportedOperationException!");
}
// 結果
// ↓
//UnsupportedOperationException!
今度は、Sampleクラスの外側から clear メソッドを呼び出すと例外が発生するようになりました。
これで Sampleクラスをよく知らない人でも、すぐに 間違いに気づけます。
この すぐに というのがプログラミングにおいてとても大事です。
問題は早期発見、早期解決が鉄則です。これができているプログラムは品質がよいといえるでしょう。
配列の例も見てみます。
Sample.values は、クラスの外側からは変更させたくないとします。
class Sample {
private final String[] values = new String[3];
void set(int index, String value) {
values[index] = value;
}
String[] getValues() {
return values;
}
}
final var sample = new Sample();
sample.set(0, "aaa");
sample.set(1, "bbb");
sample.set(2, "ccc");
System.out.println(Arrays.toString(sample.getValues())); // [aaa, bbb, ccc]
// getValues 経由で外側から変更できます。
Arrays.fill(sample.getValues(), null);
System.out.println(Arrays.toString(sample.getValues())); // [null, null, null]
Sample.getValues メソッド経由で、values の全要素を null に変更できてしまいました。
配列はどうやっても不変オブジェクトにはできません。
この問題を回避するために、Sample.getValues メソッドで毎回コピーを返す、というのもありです。
class Sample {
...
String[] getValues() {
// コピーを返します。
return values.clone();
}
}
...
System.out.println(Arrays.toString(sample.getValues())); // [aaa, bbb, ccc]
// コピーされたオブジェクトを変更しているだけなので、Sample.values には影響しません。
Arrays.fill(sample.getValues(), null);
System.out.println(Arrays.toString(sample.getValues())); // [aaa, bbb, ccc]
ただし、配列が大きくなればなるほどコピーのコストも大きくなるので注意が必要です。
配列のメリット
プリミティブ型が使える
配列のメリットは、byte や int といった プリミティブ型 を使えることです。
特にバイナリデータを扱うときは、byte[] といった byte配列を使うことも多いです。
final byte[] array = {1, 2, 3, 4, 5};
System.out.println(Arrays.toString(array)); // [1, 2, 3, 4, 5]
List はプリミティブ型を直接使えません。
List<Byte> や List<Integer> のように、ラッパークラスを使う必要があります。
// コンパイルエラー
final var list = new ArrayList<byte>();
// コンパイルOK
final var list = new ArrayList<Byte>();
ラッパークラスはオブジェクトなので、プリミティブ型より多少コストがかかります。(メモリ使用量やオブジェクト生成の処理時間など)
どれくらいメモリ使用量が変わるのかざっくりとテストしてみましょう。
final var runtime = Runtime.getRuntime();
final var count = 10000000;
final var memory1 = runtime.freeMemory();
final var array = new byte[count];
for (int i = 0; i < count; i++) {
array[i] = (byte) i;
}
final var memory2 = runtime.freeMemory();
System.out.println("array memory : " + (memory1 - memory2) / (1024 * 1024) + "MB");
final var list = new ArrayList<Byte>();
for (int i = 0; i < count; i++) {
list.add((byte) i);
}
final var memory3 = runtime.freeMemory();
System.out.println("list memory : " + (memory2 - memory3) / (1024 * 1024) + "MB");
// 結果
// ↓
//array memory : 10MB
//list memory : 164MB
byte で要素 10000000個の例です。
配列(プリミティブ型) | リスト(ラッパークラス) | |
---|---|---|
メモリ使用量 | 約10MB | 約164MB |
大きなサイズだと、やはりメモリ使用量の差も大きくなりますね。
あとは、ラッパークラスはオブジェクトなので null も許容できてしまいます。
null を意図的に非許容にしたいときにはプリミティブ型の配列は便利です。
// 配列
final var array = new byte[2];
array[0] = 1; // OK
array[1] = null; // コンパイルエラー
// リスト
final var list = new ArrayList<Integer>();
list.add(1); // OK
list.add(null); // OK
公式ドキュメントのリンク
配列の詳細については、Java言語仕様に記載があります。
残念ながら日本語の公式ドキュメントはありませんが、詳しく知りたいかたは上記もご参照ください。
List については API仕様に記載があります。
こちらは日本語ドキュメントです。
まとめ
リストのメリットは、
- サイズを管理しなくてよい
- 不変オブジェクトにできる
です。
あえて配列を使いたいケースとしては
- プリミティブ型を使いたいとき (バイナリデータなど)
- 極限までパフォーマンスを気にするとき
などがあります。
特に明確な理由がなければ、配列より List を使うことをおすすめします。
関連記事
- API 使用例
- Collection (コレクション)
- Collections (コレクション操作)
- Comparable
- Comparator
- Iterator
- List (リスト)
- Map (マップ)
- Map.Entry (キーと値のペア)
- Queue (キュー)
- AbstractQueue
- ArrayBlockingQueue (ブロッキング・配列キュー)
- ArrayDeque (両端キュー)
- BlockingDeque (ブロッキング・両端キュー)
- BlockingQueue (ブロッキング・キュー)
- ConcurrentLinkedDeque (並列処理用・両端キュー)
- ConcurrentLinkedQueue (並列処理用キュー)
- Deque (両端キュー)
- LinkedBlockingDeque (ブロッキング・リンク両端キュー)
- LinkedBlockingQueue (ブロッキング・リンクキュー)
- LinkedList (二重リンク・リスト)
- PriorityBlockingQueue (ブロッキング・優先度付きキュー)
- PriorityQueue (優先度付きキュー)
- RandomAccess
- Set (セット)
- Spliterator