66|反復子モード (中): コレクションをトラバースしているときに、コレクション要素を追加または削除できないのはなぜですか?
前回のクラスでは、ArrayList および LinkedList コンテナーの反復子を実装することにより、反復子パターンの原則、実装、および設計意図を学びました。イテレータ パターンの主な機能は、コンテナ コードとトラバーサル コードを分離することです。これは、何度も言及したアプリケーション設計パターンの主な目的が分離であることも裏付けています。
前のレッスンで説明した内容は比較的基本的なものです. 今日はさらに掘り下げてみましょう. イテレータを使用してコレクションをトラバースしながら、コレクション内の要素を追加または削除するとどうなりますか? どのように対処すればよいでしょうか。トラバース中にコレクション要素を安全に削除するには?
早速、今日のコンテンツを正式に開始しましょう。
トラバース中にコレクション要素を追加または削除するとどうなりますか?
コレクション要素をイテレータでトラバースしているときに、コレクション内の要素を追加または削除すると、要素が繰り返しトラバースされたり、トラバースされなかったりする場合があります。ただし、すべての場合にトラバーサルエラーが発生するわけではなく、正常にトラバースできる場合もあるため、この動作を予測不可能な動作または保留動作と呼び、実行結果が正しいか間違っているかは状況によって異なります。
どのように理解していますか?例を挙げて説明しましょう。前のレッスンで実装された ArrayList イテレータの例を続けましょう。ご参考までに、関連するコードをここに再コピーしました。
public interface Iterator<E> {
boolean hasNext();
void next();
E currentItem();
}
public class ArrayIterator<E> implements Iterator<E> {
private int cursor;
private ArrayList<E> arrayList;
public ArrayIterator(ArrayList<E> arrayList) {
this.cursor = 0;
this.arrayList = arrayList;
}
@Override
public boolean hasNext() {
return cursor < arrayList.size();
}
@Override
public void next() {
cursor++;
}
@Override
public E currentItem() {
if (cursor >= arrayList.size()) {
throw new NoSuchElementException();
}
return arrayList.get(cursor);
}
}
public interface List<E> {
Iterator iterator();
}
public class ArrayList<E> implements List<E> {
//...
public Iterator iterator() {
return new ArrayIterator(this);
}
//...
}
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
names.remove("a");
}
}
ArrayList の最下層が array のデータ構造に対応していることが分かります.55 行目のコードを実行すると、a,b,c,d の 4 つの要素が配列に格納され、iterator のカーソルが element を指しています。を。コードの 56 行目を実行すると、カーソルは要素 b を指しますが、ここでは問題ありません。
配列に格納されたデータの連続性を維持するために、配列の削除操作には要素の移動が伴います (詳細な説明については、別のコラム「データ構造とアルゴリズムの美しさ」を参照してください)。コードの 57 行目が実行されると、要素 a が配列から削除され、3 つの要素 b、c、および d が 1 つずつ前方に移動され、カーソルが要素 b を指すようになり、今度は要素 c へのポイントになります。もともと、56 行目のコードを実行した後、3 つの要素 b、c、および d にトラバースできますが、57 行目のコードを実行した後、2 つの要素 c と d にしかトラバースできず、b はできません。横断した。
上記の説明のために、私は絵を描きました、あなたは比較して理解することができます.
ただし、57 行目のコードがカーソルの前の要素 (要素 a) とカーソルの位置の要素 (要素 b) ではなく、カーソルの後ろの要素 (要素 c と d) を削除すると、問題ありません。要素をトラバースできない状況はありません。
したがって、前述したように、トラバーサル中にコレクション要素を削除すると、結果は予測できません. 場合によっては問題がない場合 (要素 c または d の削除) もあれば、問題がある場合もあります (要素 a または b の削除)。 (どの位置要素が削除されるか) によりますが、それが意味することです。
トラバーサル プロセス中にコレクション要素を削除すると、要素がトラバースされない可能性があります。トラバーサル プロセス中にコレクション要素が追加されるとどうなりますか? 先ほどの例と組み合わせて、上記のコードを少し変更し、削除された要素を追加された要素に変更します。具体的なコードは次のとおりです。
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
names.add(0, "x");
}
}
コードの 10 行目を実行すると、配列には 4 つの要素 a、b、c、d が含まれ、カーソルは要素 b を指し、要素 a はスキップされます。11行目のコードを実行した後、添え字が0の位置にxを挿入し、a、b、c、dの4つの要素を1つずつ後ろに移動します。このとき、カーソルは再び要素 a を指します。要素 a は、カーソルによって 2 回繰り返しポイントされます。つまり、要素 a が繰り返しトラバースされます。
削除の場合と同様に、カーソルの後に要素を追加しても問題ありません。したがって、トラバース中にコレクション要素を追加することも、予測できない動作です。
同様に、上記の要素を追加する状況についても、以下に示すように図を描きました。比較して理解できます。
トラバース中にコレクションを変更することによって引き起こされる保留中の動作に対処する方法は?
イテレータを使用してコレクションをトラバースする場合、コレクション要素を追加および削除すると、トラバース結果が予測不能になる可能性があります。実は「予測不可能性」は直接的なミスよりも恐ろしいもので、正しく動くときもあれば、間違って動くときもあり、このように奥深くに潜むデバッグ困難なバグが発生します。では、このような予測不可能な操作結果を回避するにはどうすればよいでしょうか。
単純な解決策は 2 つあります。1 つは、トラバーサル中に要素を追加または削除しないことです。もう 1 つは、トラバーサルが要素の追加または削除後にエラーを報告できるようにすることです。
実際、最初の解決策は実装がより難しく、トラバーサルの開始と終了の時点を決定する必要があります。トラバーサルが開始される時間ノードを簡単に取得できます。イテレータが作成された時点を、トラバーサルの開始時点と見なすことができます。しかし、トラバーサルが終了する時点をどのように決定するのでしょうか?
最後の要素をトラバースした時点で終了と言うかもしれません。ただし、実際のソフトウェア開発では、イテレータを使用して要素をトラバースするたびに、すべての要素をトラバースする必要はありません。以下に示すように、値 b を持つ要素を見つけると、トラバーサルを早期に終了します。
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.currentItem();
if (name.equals("b")) {
break;
}
}
}
}
また、イテレータ クラスに新しいインターフェイス finishIteration() を定義して、イテレータが使い果たされ、要素を追加または削除できることをコンテナに積極的に通知することもできます. サンプル コードは次のとおりです。ただし、これにはプログラマーが反復子を使用した後にこの関数を積極的に呼び出す必要があり、開発コストも増加し、見落としがちです。
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.currentItem();
if (name.equals("b")) {
iterator.finishIteration();//主动告知容器这个迭代器用完了
break;
}
}
}
}
実際には、2 番目の解決策の方が合理的です。Java 言語はそのようなソリューションで、要素を追加および削除した後、トラバーサルにエラーを報告させます。次に、それを実装する方法を見てみましょう。
トラバーサル中にコレクションが要素を追加または削除したかどうかを判断する方法は? コレクションが変更された回数を記録するために、ArrayList にメンバー変数 modCount を定義します. コレクションが要素を追加または削除する関数を呼び出すたびに、modCount に 1 が追加されます. コレクションで iterator() 関数を呼び出してイテレーターを作成する場合、modCount 値をイテレーターの expectedModCount メンバー変数に渡し、イテレーターで hasNext()、next()、currentItem() 関数を毎回呼び出します。 、コレクションの modCount が expectedModCount と等しいかどうか、つまり、反復子が作成されてから modCount が変更されたかどうかを確認します。
2 つの値が同じでない場合は、コレクションに格納されている要素が要素の追加または削除のいずれかで変更されたことを意味し、以前に作成されたイテレータは正しく動作できなくなり、それを使用し続けると予期しない結果が生じる可能性があります. したがって、フェイルファスト ソリューションを選択し、実行時例外をスローしてプログラムを終了し、イテレータの不適切な使用によって発生したバグをできるだけ早くプログラマに修正させます。
上記の説明をコードに変換すると、次のようになります。今説明したことは、コードと組み合わせて理解できます。
public class ArrayIterator implements Iterator {
private int cursor;
private ArrayList arrayList;
private int expectedModCount;
public ArrayIterator(ArrayList arrayList) {
this.cursor = 0;
this.arrayList = arrayList;
this.expectedModCount = arrayList.modCount;
}
@Override
public boolean hasNext() {
checkForComodification();
return cursor < arrayList.size();
}
@Override
public void next() {
checkForComodification();
cursor++;
}
@Override
public Object currentItem() {
checkForComodification();
return arrayList.get(cursor);
}
private void checkForComodification() {
if (arrayList.modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
//代码示例
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
names.remove("a");
iterator.next();//抛出ConcurrentModificationException异常
}
}
トラバース中にコレクション要素を安全に削除するには?
Java 言語と同様に、上記の最も基本的なメソッドに加えて、iterator クラスは remove() メソッドも定義します。このメソッドは、コレクションをトラバースしながらコレクション内の要素を安全に削除できます。ただし、要素を追加する方法は提供されないことに注意してください。結局のところ、反復子の主な機能は横断することであり、反復子自体に要素を追加することは適切ではありません。
個人的には、Java イテレーターで提供されている remove() メソッドは、かなり味気なく、効果が限られていると思います。カーソルが指す前の要素のみを削除できます。next() 関数の後には、remove() 操作を 1 つだけ実行できます。remove() 操作が複数回呼び出されると、エラーが報告されます。例を挙げて説明しましょう。
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
iterator.remove();
iterator.remove(); //报错,抛出IllegalStateException异常
}
}
それでは、一緒に見てみましょう。コレクション内の要素をイテレータで安全に削除できるのはなぜでしょうか? ソースコードの下に秘密はありません。remove() 関数がどのように実装されているか見てみましょう。コードは次のとおりです。Java 実装では、反復子クラスはコンテナー クラスの内部クラスであり、next() 関数はカーソルを 1 ビット戻すだけでなく、現在の要素も返します。
public class ArrayList<E> {
transient Object[] elementData;
private int size;
public Iterator<E> iterator() {
return new Itr();
}
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
Itr() {}
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
}
}
上記のコード実装では、イテレータ クラスは lastRet メンバー変数を追加して、カーソルが指す前の要素を記録します。イテレーターを介してこの要素を削除する場合、イテレーターのカーソルと lastRet 値を更新して、要素の削除が原因で要素がトラバースされないようにすることができます。コンテナーを介して要素を削除し、トラバーサルが正しいことを確認するために反復子のカーソル値を更新する場合、このコンテナーに対して作成された反復子、各反復子がまだ使用されているかどうかなどを維持する必要があります。コードの実装が変更され、より複雑になります。
キーレビュー
では、本日の内容は以上です。集中する必要があることをまとめて一緒に確認しましょう。
コレクション要素をイテレータでトラバースしているときに、コレクション内の要素を追加または削除すると、要素が繰り返しトラバースされたり、トラバースされなかったりする場合があります。ただし、すべてのトラバーサル エラーがすべての場合に発生するわけではなく、正常にトラバースできる場合もあるため、この動作は予測不能動作または保留動作と呼ばれます。実は「予測不可能性」は直接的なミスよりも恐ろしいもので、正しく動くときもあれば、間違って動くときもあり、このように奥深くに潜むデバッグ困難なバグが発生します。
この予測不可能な操作を回避するための 2 つの優れたソリューションがあります。1 つは、トラバーサル中に要素を追加または削除できないことと、もう 1 つは、要素の追加または削除後にトラバーサルがエラーを報告することです。最初の解決策は、反復子の使用がいつ終了するかを判断するのが難しいため、実装がより困難です。2 番目の解決策はより合理的です。Java 言語はそのようなソリューションです。要素を追加および削除した後、フェイルファスト ソリューションを選択して、トラバーサル操作が実行時例外を直接スローするようにします。
Java 言語と同様に、上記の最も基本的なメソッドに加えて、iterator クラスは remove() メソッドも定義します。このメソッドは、コレクションをトラバースしながらコレクション内の要素を安全に削除できます。
クラスディスカッション
1. 記事に記載されている Java イテレーターの実装コードに基づくと、コンテナー オブジェクトが同時に 2 つのイテレーターを作成し、1 つのイテレーターが remove() メソッドを呼び出してコレクション内の要素を削除する場合、もう 1 つのイテレーターはまだ利用可能?別の言い方をすれば、以下のコードの 13 行目を実行した結果はどうなるでしょうか。
public class Demo {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator1 = names.iterator();
Iterator<String> iterator2 = names.iterator();
iterator1.next();
iterator1.remove();
iterator2.next(); // 运行结果?
}
}
2. LinkedList の最下層はリンクされたリストに基づいています. トラバース中に要素を追加および削除すると、どのような予期しない動作が発生しますか?
メッセージを残して、あなたの考えを私と共有してください。何かを得た場合は、この記事を友達と共有してください。