きっかけ
拡張for文でリストをループしながら要素を削除しようとして、ConcurrentModificationException が出た。
「あ、これ確かダメなやつだ」と思いつつ、正直なぜダメなのかをうまく説明できなかった。その場はIteratorを使って対処したが、そもそも Iterable と Iterator の関係を自分がちゃんと把握できているか怪しかったので、この機会に整理しておくことにした。
IterableとIteratorは別物
まずここが自分の中でぼんやりしていた部分だった。
Iterable は java.lang パッケージのインタフェースで、拡張for文(for-each文)が使えるようにするためのもの。java.lang は暗黙的にimportされるので、import文を書く必要はない。
Iterator はコレクション内の要素を順番にたどるための仕組みで、反復子とも呼ばれる。要素を指し示す矢印のようなイメージで、hasNext() で次があるかを確認しながら next() で一つずつ取り出していく。
この2つの関係を整理すると、Iterable が持つ iterator() メソッドが Iterator オブジェクトを返す、という構造になっている。拡張for文は内部でこの仕組みを利用している。
なぜArrayListで拡張for文が使えるのか
これも「なんとなく使えるもの」として認識していたが、理由がある。
ArrayList は List インタフェースを実装しており、List は Collection を継承している。そして Collection が Iterable を継承している。つまり継承の連鎖をたどると、ArrayList はIterableの実装クラスということになる。
Iterable
└── Collection
└── List
└── ArrayList(実装クラス)
同様に HashSet、ArrayDeque、LinkedList なども同じ経路でIterableを継承しているため、拡張for文が使える。
Iterableの3つのメソッド
Iterableインタフェース自体が持っているメソッドは3つ。
-
iterator():抽象メソッド。Iteratorオブジェクトを返す -
forEach():デフォルトメソッド。Java 8から追加 -
spliterator():デフォルトメソッド。並列処理向け
iterator() だけが抽象メソッドで、Iterableを実装するクラスは必ずこれをオーバーライドしなければならない。残り2つはデフォルトメソッドとして実装済みのため、サブクラス側で改めて定義しなくてもそのまま使える。
forEach() は個人的によく使う。ラムダ式と組み合わせることで、反復処理がすっきり書ける。
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
class Main {
public static void main(String[] args) {
List<Integer> numbers = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
// ラムダ式
numbers.forEach(n -> System.out.println(n));
// メソッド参照でさらに短く
numbers.forEach(System.out::println);
}
}
余談だが、forEach() に慣れてから通常のfor文が少し冗長に感じるようになってしまった。可読性を重視する場面では引き続きfor文を選ぶが、単純な反復であればラムダ式のほうがスッキリ読めることが多い。
IteratorのhasNext()とnext()の基本
Iteratorの使い方の基本はこうなる。
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<>(Arrays.asList("Java", "JavaScript", "Python"));
Iterator<String> it = list.iterator();
while(it.hasNext()) {
System.out.println(it.next());
}
}
}
hasNext() で次の要素があるかを確認し、next() で取り出す。これだけなら拡張for文と大差ないように見える。
ハマったポイント:ループ中の要素削除
冒頭の話に戻ると、ループ中に要素を削除する場面でIteratorの必要性が出てくる。
インデックスを使ったfor文で削除しようとすると、削除後にインデックスがずれて一部の要素をスキップしてしまう。
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<>(Arrays.asList("Java", "JavaScript", "Python", "PHP", "C++"));
for(int i = 0; i < list.size(); i++) {
if(list.get(i).startsWith("J")) {
list.remove(i); // インデックスがずれてJavaScriptがスキップされる
}
}
System.out.println(list); // [JavaScript, Python, PHP, C++]
}
}
"Java" を削除した後、インデックスが詰まるせいで "JavaScript" が飛ばされてしまう。最初これに気づかず、「あれ、消えてない」と少し時間を使った。
拡張for文の場合は実行時例外になる。
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<>(Arrays.asList("Java", "JavaScript", "Python", "PHP", "C++"));
for(String s : list) {
if(s.startsWith("J")) {
list.remove(s); // ConcurrentModificationException が発生する
}
}
}
}
ConcurrentModificationException はコレクションを反復中に構造変更が起きたときに発生する例外。拡張for文が内部でIteratorを使っている関係上、ループ外からリストを変更すると検知されて弾かれる、ということだった。
Iteratorの remove() を使うとこの問題が解決する。
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<>(Arrays.asList("Java", "JavaScript", "Python", "PHP", "C++"));
Iterator<String> it = list.iterator();
while(it.hasNext()) {
String s = it.next();
if(s.startsWith("J")) {
it.remove(); // Iterator経由で削除するのが正しい
}
}
System.out.println(list); // [Python, PHP, C++]
}
}
it.remove() は直前の next() で取り出した要素を安全に削除する。Iteratorが内部の状態を把握しているため、インデックスのずれも例外も起きない。
it.remove() は必ず it.next() を呼び出した後に使うこと。next() を呼ばずに remove() だけを呼び出すと IllegalStateException が発生する。
振り返って気づいたこと
今回整理できたのは主にこの3点だった。
まず、Iterable と Iterator は別のインタフェースで、前者が後者を返す iterator() メソッドを定義している、という関係性。次に、拡張for文はIteratorを内部で使っているため、ループ中に外側からコレクションを変更すると例外になる、という理由。そして、ループ中に要素を削除したい場合はIteratorの remove() を使うのが正しい方法である、という点。
ConcurrentModificationException の発生条件については実装クラスごとに挙動が異なる場合もあると聞いたことがある。ArrayList 以外のコレクションでの挙動や、ListIterator との使い分けについてはまだ詰められていないので、機会があれば確認したい。
この記事を書いた人について
株式会社Flexibilityでエンジニアをしています。
DX推進・システム開発を軸に、エンジニアが自律的に動ける環境を大事にしている会社です。
技術的に面白いことをやっていきたい方や、働き方に柔軟さを求めている方は、
よかったら一度のぞいてみてください。
- 会社サイト: https://www.flexi-inc.com/
- Qiita Organization: https://qiita.com/organizations/flexi-inc