【问题标题】:Enumerator.MoveNext() throws 'Collection was Modified' on first callEnumerator.MoveNext() 在第一次调用时抛出 'Collection was Modified'
【发布时间】:2012-07-02 11:37:31
【问题描述】:

考虑以下代码:

List<int> list = new List<int>();
IEnumerable<int> enumerable = list;
IEnumerator<int> enumerator = enumerable.GetEnumerator();
list.Add(1);
bool any = enumerator.MoveNext();

在运行时,最后一行抛出:

InvalidOperationException:集合已修改;枚举操作可能无法执行。

我了解IEnumerators 需要在IEnumerable 更改时抛出“集合已修改”异常,但我不明白这一点:

为什么IEnumeratorMoveNext()第一个 调用中抛出此异常?既然IEnumerator 不代表IEnumerable 的状态,直到第一次调用MoveNext(),为什么它不能从第一个MoveNext() 而不是GetEnumerator() 开始跟踪变化?

【问题讨论】:

    标签: c# .net multithreading iterator ienumerable


    【解决方案1】:

    可能是因为“如果基础集合被修改,则枚举器无效”规则比“如果在第一次调用 MoveNext 后修改基础集合,则枚举器无效”规则更简单。或者这只是它的实施方式。另外,假设 Enumerator 表示创建 Enumerator 时底层集合的状态是合理的,并且依赖于不同的行为可能是错误的来源。

    【讨论】:

      【解决方案2】:

      我觉得需要快速回顾一下迭代器。

      迭代器(IEnumerator 和 C# 的 IEnumerable)用于以有序方式访问结构的元素,而不暴露底层表示。结果是它允许您拥有诸如以下的外部通用函数。

      void Iterator<T, V>(T collection, Action<V> actor) where T : IEnumerable<V>
      {
          foreach (V value in collection)
              actor(value);
      }
      
      //Or the more verbose way
      void Iterator<T, V>(T collection, Action<V> actor) where T : IEnumerable<V>
      {
          using (var iterator = collection.GetEnumerator())
          {
              while (iterator.MoveNext())
                  actor(iterator.Current);
          }
      }
      
      //Or if you need to support non-generic collections (ArrayList, Queue, BitArray, etc)
      void Iterator<T, V> (T collection, Action<V> actor) where T : IEnumerable
      {
          foreach (object value in collection)
              actor((V)value);
      }
      

      正如在 C# 规范中所见,需要权衡取舍。

      5.3.3.16 Foreach 语句

      foreach(expr 中的类型标识符)嵌入语句

      • expr开头的v的明确赋值状态与stmt开头的v的状态相同。

      • v 在控制流转移到嵌入语句或到 stmt 的终点的明确赋值状态与 expr 结束时 v 的状态。

      这仅仅意味着值是只读的。为什么它们是只读的?这很简单。由于foreach 是一个如此高级别的语句,它不能也不会假设您正在迭代的容器有任何东西。如果您正在遍历二叉树并决定在 foreach 语句中随机分配值怎么办。如果foreach 没有强制只读访问,那么您的二叉树将退化为一棵树。整个数据结构将处于混乱状态。

      但这不是您最初的问题。您甚至在访问第一个元素之前就修改了集合并引发了错误。为什么?为此,我使用ILSpy 深入研究了 List 类。这是List类的sn-p

      public class List<T> : IList<T>, ICollection<T>, IEnumerable<T>, IList, ICollection, IEnumerable
      {
          private int _version;
      
          public struct Enumerator : IEnumerator<T>, IDisposable, IEnumerator
          {
              private List<T> list;
              private int version;
              private int index;
      
              internal Enumerator(List<T> list)
              {
                  this.list = list;
                  this.version = list._version;
                  this.index = 0;
              }
      
              /* All the implemented functions of IEnumerator<T> and IEnumerator will throw 
                 a ThrowInvalidOperationException if (this.version != this.list._version) */
          }
      }
      

      枚举器使用父列表的“版本”和对父列表的引用进行初始化。 所有 迭代操作检查以确保初始版本等同于引用列表的当前版本。如果它们不同步,则迭代器不再有效。为什么 BCL 会这样做?为什么实现者不检查枚举器的索引是否为 0(表示新的枚举器),如果是,只需重新同步版本?我不知道。我只能假设团队希望所有实现 IEnumerable 的类之间保持一致,并且他们也希望保持简单。因此,只要元素在范围内,列表的枚举器(我相信大多数其他人)就不会区分元素。

      这是您的问题的根本原因。如果您绝对必须拥有此功能,那么您将必须实现自己的迭代器,并且最终可能不得不实现自己的 List。在我看来,对于 BCL 的流程来说,工作量太大了。

      这是GoF 在设计 BCL 团队可能遵循的迭代器时引用的一段话:

      在遍历聚合时修改聚合可能很危险。 如果从聚合中添加或删除元素,您最终可能会 访问一个元素两次或完全丢失它。一个简单的 解决方案是复制聚合并遍历副本,但那是 总体来说太贵了

      BCL 团队很可能认为它在时空复杂性和人力方面过于昂贵。这种理念贯穿于整个 C#。允许在 foreach 中修改变量可能太昂贵了,让 List 的 Enumerator 区分它在列表中的位置太昂贵,并且太昂贵以至于无法支撑用户。希望我已经解释得足够好,以至于人们可以看到迭代器的强大功能和约束。

      参考

      什么会改变列表的“版本”,从而使所有当前的枚举器无效?

      • 通过索引器更改元素
      • Add
      • AddRange
      • Clear
      • Insert
      • InsertRange
      • RemoveAll
      • RemoveAt
      • RemoveRange
      • Reverse
      • Sort

      【讨论】:

      • 我认识到,如果在枚举期间修改了集合,并且如果枚举器无法以合理的方式返回内容,则要求未损坏的 IEnumerator&lt;T&gt; 不得以不稳定的方式运行是合理的语义上,最好的替代方法是抛出异常(尽管应该为此目的使用不同的异常类型,以将其与 InvalidOperationException 出于某种原因与修改后的集合无关的情况区分开来)。但是,我不喜欢异常是“首选”行为的概念。
      • 除了抛出异常之外,还有什么方法呢?我只能考虑添加一个 Valid 属性标志,但这会产生它自己的副作用。我相信,由于尝试修改当前枚举值会导致编译器错误,因此如果底层结构被修改,如果继续枚举则抛出异常是有道理的。
      • 另一种做法是继续枚举,并保证在整个枚举过程中存在的任何项目都将仅返回一次,并且在枚举的一部分中存在的任何项目都将返回最多一次。某些类型的集合很难做出这样的保证(在这种情况下抛出异常是合适的),但集合提供这样的保证可能很有用。如果枚举在集合更改时终止,那么 ConcurrentDictionaryGetEnumerator 方法会有多大用处?
      • 一般来说,我建议不要遍历当前正在修改的集合,即使枚举器可以支持对底层结构的读写,如您提到的ConcurrentDictionary
      • 人们不应该期望在这种条件下执行的枚举能够代表对象在任何特定时刻的状态。另一方面,有许多场景,例如更新 GUI 控件以表示“实时”并发集合的状态,其中枚举是否包含在控件刷新时添加的项无关紧要(因为新项目将在下一次刷新时出现),但是如果要求在 UI 更新期间冻结集合会破坏首先使用并发集合的目的。
      【解决方案3】:

      这是因为List&lt;T&gt; 中有一个私有的version 字段,在调用MoveNext 时会检查该字段。所以现在我们知道如果我们有一个自定义的MyList&lt;T&gt; 来实现IEnumerable&lt;T&gt;,我们可以避免检查version,并且即使集合被修改也允许枚举(但这可能会导致意外行为)。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2011-03-30
        • 1970-01-01
        相关资源
        最近更新 更多