在迭代集合时删除元素

322

据我所知,有两种方法:

  1. 遍历集合的副本
  2. 使用实际集合的迭代器

例如,

List<Foo> fooListCopy = new ArrayList<Foo>(fooList);
for(Foo foo : fooListCopy){
    // modify actual fooList
}

Iterator<Foo> itr = fooList.iterator();
while(itr.hasNext()){
    // modify actual fooList using itr.remove()
}

有没有任何理由偏爱其中一种方法而不是另一种方法(例如,因为可读性更好而倾向于第一种方法)?


1
只是好奇,为什么在第一个示例中你创建了 foolist 的副本而不是直接循环遍历 foolist? - Haz
21
注意:在迭代器中限定变量的范围时,建议使用“for”而不是“while”: for(Iterator<Foo> itr = fooList.iterator(); itr.hasNext();){} - Puce
1
我不知道 whilefor 有不同的作用域规则。 - Alexander Mills
3
@AlexanderMills,while循环的作用域规则并没有不同。只是在while循环中,迭代器被声明在循环外部,因此它的作用域更广,即使它只在循环内部使用。 - Vikas Tawniya
既然您已经接受了答案,那么您可能遇到了一个 XY 问题,因为该答案(以及其他一些答案)实际上并没有在迭代过程中删除。如果您稍微思考一下,对于您的名义问题的答案是相当明显的:即时可见效果与延迟/推迟删除。这不仅仅是“风格”或“可读性”的问题。 - Fizz
显示剩余2条评论
9个回答

615

让我举几个例子来避免ConcurrentModificationException异常。

假设我们有以下书籍集合:

List<Book> books = new ArrayList<Book>();
books.add(new Book(new ISBN("0-201-63361-2")));
books.add(new Book(new ISBN("0-201-63361-3")));
books.add(new Book(new ISBN("0-201-63361-4")));

收集并删除

第一种技术是收集所有需要删除的对象(例如使用增强型for循环),在迭代结束后,删除所有找到的对象。

ISBN isbn = new ISBN("0-201-63361-2");
List<Book> found = new ArrayList<Book>();
for(Book book : books){
    if(book.getIsbn().equals(isbn)){
        found.add(book);
    }
}
books.removeAll(found);

假设你想进行的操作是“删除”。

如果你想要“添加”,这种方法也可以,但我会假设你将迭代另一个集合来确定你想要添加到第二个集合中的元素,然后在最后发出 addAll 方法。

使用ListIterator

如果你正在使用列表,那么另一种技术是使用 ListIterator,它支持在迭代过程中删除和添加项目。

ListIterator<Book> iter = books.listIterator();
while(iter.hasNext()){
    if(iter.next().getIsbn().equals(isbn)){
        iter.remove();
    }
}

在上面的示例中,我再次使用了"remove"方法,这是您的问题似乎暗示的,但是您还可以在迭代过程中使用它的add方法来添加新元素。

使用JDK >= 8

对于那些使用Java 8或更高版本的人来说,有几种其他技术可以利用它。

您可以使用Collection基类中的新removeIf方法:

ISBN other = new ISBN("0-201-63361-2");
books.removeIf(b -> b.getIsbn().equals(other));

或使用新的流 API:

ISBN other = new ISBN("0-201-63361-2");
List<Book> filtered = books.stream()
                           .filter(b -> b.getIsbn().equals(other))
                           .collect(Collectors.toList());

在最后一种情况下,为了从集合中过滤元素,您可以重新分配原始引用到已过滤的集合(即books = filtered),或者使用已过滤的集合来removeAll在原始集合中找到的元素(即books.removeAll(filtered))。

使用子列表或子集

还有其他选择。如果列表已排序,并且您想要删除连续的元素,则可以创建一个子列表,然后清除它:

books.subList(0,5).clear();

由于子列表是基于原始列表支持的,因此这将是一种有效的方法来删除这个子集合中的元素。

使用NavigableSet.subSet方法或其中提供的任何切片方法可以实现类似的效果,也适用于排序集。

考虑因素:

你使用的方法可能取决于你打算做什么。

  • collect和removeAll技术适用于任何集合类型(Collection、List、Set等)。
  • ListIterator技术显然只适用于列表,前提是给定的ListIterator实现支持添加和删除操作。
  • Iterator方法适用于任何类型的集合,但只支持删除操作。
  • 使用ListIterator/Iterator方法的明显优势在于不需要复制任何内容,因为我们边迭代边移除。因此,这非常高效。
  • JDK 8流程示例实际上没有删除任何内容,而是查找所需的元素,然后用新的集合引用替换原始集合引用,并将旧的集合回收。因此,我们仅一次遍历集合即可,这非常高效。
  • collect和removeAll方法的缺点在于需要进行两次迭代。首先,我们在for循环中查找与我们的删除条件匹配的对象,一旦找到,我们请求将其从原始集合中删除,这将意味着第二次迭代工作查找该项以便将其删除。
  • 值得一提的是,Iterator接口的remove方法在Javadocs中标记为“可选”。这意味着,如果我们调用remove方法,可能会出现抛出UnsupportedOperationExceptionIterator实现。因此,如果我们不能保证迭代器支持元素的删除,我认为这种方法比其他方法更不安全。

3
太棒了!这是权威指南。 - Magno C
1
这是一个完美的答案!谢谢。 - Wilhelm
8
在你的有关JDK8 Streams的段落中,你提到了removeAll(filtered)。一个简便方法是使用removeIf(b -> b.getIsbn().equals(other)) - ifloop
2
迭代器和列表迭代器有什么区别? - Alexander Mills
1
没有考虑过 removeIf,但它是我祈求的答案。谢谢! - Akabelle
显示剩余2条评论

46

老式钟表经典款(依然可使用):

List<String> list;

for(int i = list.size() - 1; i >= 0; --i) 
{
        if(list.get(i).contains("bad"))
        {
                list.remove(i);
        }
}

好处:

  1. 它仅会遍历列表一次
  2. 不会创建额外对象或引入其他不必要的复杂性
  3. 不会出现尝试使用已移除项目的索引时出现的问题,因为……想想就知道!

1
有时这是唯一可行的解决方案。 - mipasov
15
乍一看可能会忽略,但秘诀是倒序遍历列表。这可以防止每次删除更改未来可能删除的项的索引。 - Delark
3
我更喜欢从列表的开头开始迭代,然后移除项目,再减少计数器。据我看来,这样会更易读。因此,只需使用以下代码: for (int i = 0; i < list.size(); i++) {...remove(i); i--;...} - maxeh
2
如果要删除更多的项,并且它们紧挨在一起,那么这种方法就行不通了。为了解决这个问题,在 list.remove(i); 或者反向操作之后需要加上 i--。看看其他回复,如果你需要在一个条件之后删除更多的项,你必须改进这个方法。 - judovana
1
@SheblaTsama,唯一有效的解决方案对我有用。非常感谢。 - Omer123
显示剩余2条评论

21
在Java 8中,还有另一种方法。Collection#removeIf 例如:
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);

list.removeIf(i -> i > 2);

5
这并未回答问题的提出者,这里没有迭代。 - Uri Loya
2
@UriLoya 这个问题很可能是一个XY问题,因此这个答案。 - éclairevoyant

18

有没有理由更喜欢一种方法而不是另一种方法?

第一种方法可行,但明显需要复制列表。

第二种方法不可行,因为许多容器在迭代期间不允许修改。 这包括ArrayList

如果唯一的修改是删除当前元素,则可以通过使用itr.remove()(即使用迭代器remove()方法,而不是容器的),使第二种方法起作用。 对于支持remove()的迭代器,这将是我首选的方法。


抱歉...我应该使用迭代器的remove方法,而不是容器的。复制列表会产生多少开销?它不可能很大,而且由于它的作用域限定在一个方法中,所以应该很快被垃圾回收。请参见编辑.. - user1329572
2
@aix 我认为值得一提的是,在Javadocs中,Iterator接口的remove方法被标记为可选的,这意味着可能会有UnsupportedOperationException异常抛出的Iterator实现。因此,我认为这种方法比第一种方法更不安全。根据所需使用的实现,第一种方法可能更合适。 - Edwin Dalorzo
1
@EdwinDalorzo 在原始集合上使用 remove() 也可能会抛出 UnsupportedOperationException 异常:https://docs.oracle.com/javase/7/docs/api/java/util/Collection.html#remove(java.lang.Object)。Java 容器接口的定义非常不可靠(老实说,这违背了接口的初衷)。如果您不知道运行时将使用的确切实现,则最好以不可变的方式进行操作 - 例如,使用 Java 8+ Streams API 将元素过滤并将其收集到新容器中,然后完全用它替换旧容器。 - Matthew Read

9

只有第二种方法是可行的。你只能在迭代过程中使用iterator.remove()修改集合。其他任何尝试都会导致ConcurrentModificationException异常。


4
第一次尝试是在副本上迭代,这意味着他可以修改原始内容。 - Colin D

1

你不能使用remove()方法在迭代器上执行第二个操作,因为会抛出异常

个人而言,我更喜欢对于所有Collection实例使用第一个方法,尽管创建新的Collection会增加额外的开销,但我发现这样做在其他开发人员进行编辑时更不容易出错。在某些集合实现中,支持迭代器remove(),而在其他集合实现中则不支持。您可以在迭代器的文档中了解更多信息。

第三种选择是创建一个新的Collection,迭代原始集合,并将第一个Collection中不需要删除的所有成员添加到第二个Collection中。根据Collection的大小和删除的数量,与第一种方法相比,这样做可以显著节省内存。

实际上,关于这个问题的Java文档非常令人困惑。 "迭代器允许调用者在迭代期间以明确定义的语义从基础集合中删除元素。" 迭代器本身的 remove() 方法可能 不会 抛出并发修改异常(请参阅 https://docs.oracle.com/javase/8/docs/api/java/util/Iterator.html 中的 remove 部分)。此外,“如果在迭代进行时以任何方式修改基础集合,而不是通过调用此方法,则迭代器的行为是未指定的。” - Fizz

0
我会选择第二个,因为你不需要复制内存,而且迭代器的工作速度更快。所以你可以节省内存和时间。

迭代器工作更快。 有什么支持这个说法的证据吗?另外,复制列表的内存占用非常微不足道,特别是它将在方法范围内,并且几乎立即被垃圾回收。 - user1329572
2
在第一种方法中,缺点是我们必须迭代两次。我们在for循环中寻找元素,一旦找到它,就要求从原始列表中删除它,这将意味着需要进行第二次迭代来查找此给定项。这支持了这样的说法,即至少在这种情况下,迭代器方法应该更快。我们必须考虑到只有集合的结构空间被创建,集合内部的对象并没有被复制。两个集合都将保留对相同对象的引用。当GC发生时,我们无法判断! - Edwin Dalorzo

0

你可以看到这个例子;如果我们想从一个列表中删除奇数值:

public static void main(String[] args) {
    Predicate<Integer> isOdd = v -> v % 2 == 0;
    List<Integer> listArr = Arrays.asList(5, 7, 90, 11, 55, 60);
    listArr = listArr.stream().filter(isOdd).collect(Collectors.toList());
    listArr.forEach(System.out::println);
}

-6
为什么不是这个?
for( int i = 0; i < Foo.size(); i++ )
{
   if( Foo.get(i).equals( some test ) )
   {
      Foo.remove(i);
   }
}

如果它是一个映射而不是列表,你可以使用keyset()


5
这种方法有很多主要缺点。首先,每次删除一个元素时,索引都会重新组织。因此,如果您删除元素0,则元素1将成为新的元素0。如果您要这样做,至少要反向进行以避免此问题。其次,并非所有List实现都提供对元素的直接访问(如ArrayList所提供的那样)。在LinkedList中,这将非常低效,因为每次发出get(i)命令时,您都必须访问所有节点,直到找到第i个节点。 - Edwin Dalorzo
从未考虑过这一点,因为我通常只使用它来删除我正在寻找的单个项目。好知道。 - Drake Clarris
5
我来晚了,但是在Foo.remove(i);之后的if代码块中,你肯定应该加上i--;对吧? - Bertie Wheen
因为它有错误。 - Jack

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接