为什么我在迭代时不应该修改集合

我知道.net集合类型(或至少一些集合类型)不允许在迭代时修改集合。

例如,在List类中存在如下代码:

if (this.version != this.list._version) ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumFailedVersion); 

但显然这是设计迭代器类的开发人员的决定,因为我可以提供IEnumerable一些实现,至少在修改底层集合时不抛出任何exception。

然后我有一些问题:

  • 为什么我在迭代时不应修改集合?

  • 可以创建一个支持在迭代时进行修改的集合,而不会有任何其他问题吗? (注意:第一个答案也可以回答这个问题)

  • 当C#编译器生成Enumerator接口实现时考虑到这样的事情?

为什么我在迭代时不应修改集合?

迭代时可以修改一些集合,因此它不是全局性的。 在大多数情况下,编写一个即使在修改底层集合时也能正常工作的有效迭代器是非常困难的。 在许多情况下,例外的是迭代器作家,他说他们只是不想处理它。

在某些情况下,当底层集合发生变化时,不清楚迭代器该做什么。 有些案例是明确的,但对于其他案例,不同的人会期望不同的行为。 无论何时你处于这种情况,都表明存在更深层次的问题(你不应该改变你正在迭代的序列)

可以创建一个支持在迭代时进行修改的集合,而不会有任何其他问题吗? (注意:第一个答案也可以回答这个问题)

当然。

考虑这个迭代器的列表:

 public static IEnumerable IterateWhileMutating(this IList list) { for (int i = 0; i < list.Count; i++) { yield return list[i]; } } 

如果从基础列表中删除当前索引处或之前的项目,则在迭代时将跳过项目。 如果在当前索引处或之前添加项目,则将复制项目。 但是如果在迭代期间添加/删除当前索引之后的项目,则不会出现问题。 我们可以试着想象一下并尝试查看项目是否已从列表中删除/添加并相应地调整索引,但它无法始终有效,因此我们无法处理所有情况。 如果我们有类似ObservableCollection东西,那么我们可以收到添​​加/删除及其索引的通知,并相应地调整索引,从而允许迭代器处理底层集合的变异(只要它不在另一个线程中)。

由于ObservableCollection的迭代器可以知道何时添加/删除任何项目以及它们的位置,因此可以相应地调整它的位置。 我不确定内置迭代器是否正确处理了变异,但是这里可以处理底层集合的任何变异:

 public static IEnumerable IterateWhileMutating( this ObservableCollection list) { int i = 0; NotifyCollectionChangedEventHandler handler = (_, args) => { switch (args.Action) { case NotifyCollectionChangedAction.Add: if (args.NewStartingIndex <= i) i++; break; case NotifyCollectionChangedAction.Move: if (args.NewStartingIndex <= i) i++; if (args.OldStartingIndex <= i) //note *not* else if i--; break; case NotifyCollectionChangedAction.Remove: if (args.OldStartingIndex <= i) i--; break; case NotifyCollectionChangedAction.Reset: i = int.MaxValue;//end the sequence break; default: //do nothing break; } }; try { list.CollectionChanged += handler; for (i = 0; i < list.Count; i++) { yield return list[i]; } } finally { list.CollectionChanged -= handler; } } 
  • 如果项目从序列中的“早期”中删除,我们将继续正常而不跳过项目。

  • 如果在序列中“更早”添加了一个项目,我们将不会显示它,但我们也不会再显示其他项目两次。

  • 如果项目从当前位置移动到之后将显示两次,但不会跳过或重复其他项目。 如果某个项目从当前位置移动到当前位置之前,则不会显示该项目,但这就是全部。 如果一个项目稍后从集合中移动到另一个点,则没有问题,并且结果中会看到移动,如果它从较早的位置移动到另一个较早的位置,一切都很好并且移动迭代器不会“看到”它。

  • 更换物品不是问题; 只会看到它是在“当前位置之后”。

  • 重置集合会导致序列在当前位置正常结束。

请注意,此迭代器不会处理具有多个线程的情况。 如果另一个线程改变了集合而另一个线程正在迭代,则可能发生错误的事情(跳过或重复的项目,甚至是exception,例如索引越界exception)。 这允许的是迭代期间的突变,其中只有一个线程,或者只有一个线程执行代码来移动迭代器或改变集合。

当C#编译器生成Enumerator接口实现时考虑到这样的事情?

编译器生成接口实现; 一个人呢。

在迭代时不允许修改集合的一个重要原因是,如果集合中的元素被删除或者插入了新元素,它将抛弃迭代。 (在迭代在集合中工作的地方插入或删除了一个元素;现在的下一个元素是什么?新的停止条件是什么?)

一个原因是线程安全。 如果另一个线程正在添加到列表中,则无法保证迭代器以正确的方式从List的后备数组中读取,这可能会导致重新分配到新arrays。

值得注意的是,即使使用for循环枚举List表现出缺乏线程安全性。

在JaredPar的博客文章中,他创建了一个ThreadSafeList类:

该集合不再实现IEnumerable。 IEnumerable仅在集合未在引擎盖下更改时才有效。 使用这种方式构建的集合无法轻松实现此保证,因此它已被删除。

值得一提的是,并非IEnumerable所有实现都不允许在枚举期间进行修改。 并发集合可以实现,因为它们可以提供线程安全保证。

使用yield语句来加载要修改的元素,并在事后进行

如果你必须在迭代时修改一个集合(如果它可以被索引),使用for循环并将该对象与循环声明解除关联…但你要确保在循环周围使用lock语句以确保你是唯一操纵对象的人……并且你要记住你对循环的下一次传递的自己的操作……

也许你可以这样做,但这可能是意外的行为,超出了IEnumerable和IEnumerator接口的意图。

IEnumerable.GetEnumerator

只要集合保持不变,枚举器仍然有效。 如果对集合进行了更改(例如添加,修改或删除元素),则枚举数将无法恢复,并且其行为未定义。

这避免了像LinkedList这样的集合的问题。 想象一下,你有一个包含4个节点的链表,然后迭代到第二个节点。 然后更改链接列表,其中第二个节点移动到链接列表的头部,第三个节点移动到尾部。 接下来你的普查员会做什么甚至意味着什么呢? 可能的行为将是模棱两可的,不容易猜到。 当您通过其接口处理对象时,您不必考虑底层类是什么,以及该类及其枚举器是否容忍修改。 接口说修改使枚举器无效,因此应该是事物的行为方式。