关于c#:Enumerator.MoveNext()在第一次调用时抛出’Collection was Modified’

Enumerator.MoveNext() throws 'Collection was Modified' on first call

考虑以下代码:

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

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

InvalidOperationException: Collection was modified; enumeration operation may not execute.

我理解当IEnumerable更改时,IEnumerators需要抛出‘collection was modified’异常,但我不理解这一点:

为什么IEnumerator在第一次调用MoveNext()时抛出这个异常?由于IEnumerator在第一次调用MoveNext()之前并不代表IEnumerable的状态,为什么不能从第一个MoveNext()而不是从GetEnumerator()开始跟踪变化?

  • 你应该接受一个答案。


可能是因为规则"如果修改了基础集合,则枚举器无效"比规则"如果在第一次调用moveNext之后修改了基础集合,则枚举器无效"。或者只是它的实现方式。另外,可以合理地假定枚举器表示创建枚举器时基础集合的状态,并且依赖不同的行为可能是错误的来源。


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

迭代器(IEnumerator和IEnumerable for C)用于按顺序访问结构的元素,而不公开底层表示。其结果是,它允许您具有如下的外部通用函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 ( type identifier in expr ) embedded-statement

  • The definite assignment state of v at the beginning of expr is the same as the state of v at the beginning of stmt.

  • The definite assignment state of v on the control flow transfer to embedded-statement or to the end point of stmt is the same as the
    state of v at the end of expr.

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

但这不是你最初的问题。在访问第一个元素之前,您正在修改集合,并引发了一个错误。为什么?为此,我使用ilspy挖掘了list类。这是列表类的一个片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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的所有类之间的一致性,他们也希望保持简单。因此,列表的枚举器(我相信大多数其他枚举器)不区分元素,只要它们在范围内。

这是你问题的根本原因。如果您绝对必须具有此功能,那么您将必须实现自己的迭代器,最后可能不得不实现自己的列表。在我看来,太多的工作阻碍了BCL的发展。

在设计BCL团队可能遵循的迭代器时,这里引用了GOF的一句话:

It can be dangerous to modify an aggregate while you're traversing it.
If elements are added or deleted from the aggregate, you might end up
accessing an element twice or missing it completely. A simple
solution is to copy the aggregate and traverse the copy, but that's
too expensive to do in general

BCL团队很可能认为它在时空复杂性和人力方面过于昂贵。这种哲学贯穿于整个C。允许修改foreach中的变量可能太贵了,让list的枚举器区分它在列表中的位置太贵了,而把用户放在摇篮中太贵了。希望我已经很好地解释了它,我们可以看到迭代器的强大和约束。

参考文献:

是什么改变了列表的"版本",从而使所有当前的枚举器失效?

  • 通过索引器更改元素
  • 埃多克斯1〔8〕
  • 埃多克斯1〔9〕
  • 埃多克斯1〔10〕
  • 江户十一〔11〕。
  • 埃多克斯1〔12〕
  • 埃多克斯1〔13〕
  • 埃多克斯1〔14〕
  • 埃多克斯1〔15〕
  • 江户十一〔16〕号
  • 埃多克斯1〔17〕

  • 我认识到,如果在枚举过程中修改集合,则要求未中断的IEnumerator的行为不应出现不稳定的情况是合理的,并且如果枚举器无法返回具有合理语义的内容,则最好的选择是引发异常(尽管为此应使用不同的异常类型),将其与发生InvalidOperationException的情况区分开来,因为某些原因与修改后的集合无关)。不过,我不喜欢那种认为例外是"首选"行为的观点。
  • 除了抛出一个例外,另一个行动方案是什么?我只能考虑添加一个有效的属性标志,但这会产生它自己的副作用。我认为,由于尝试修改当前枚举值会导致编译器错误,因此如果修改了基础结构,则在继续枚举时引发异常是有意义的。
  • 另一个操作过程是继续枚举,并保证在枚举过程中存在的任何项都将返回一次,并且对于部分枚举存在的任何项都将返回一次。有些类型的集合很难做出这样的保证(在这种情况下抛出异常是合适的),但对于集合提供这样的保证可能很有用。如果枚举在集合更改时终止,那么ConcurrentDictionaryGetEnumerator方法有多有用?
  • 一般来说,我建议不要迭代当前正在修改的集合,即使枚举器可以支持对底层结构的读写,如您提到的ConcurrentDictionary
  • 人们不应该期望在这种情况下执行的枚举能够及时地表示对象在任何特定时刻的状态。另一方面,有许多场景,例如更新一个GUI控件以表示"实时"并发集合的状态,在这种情况下,枚举是否包含一个项并不重要,该项是在刷新控件时添加的(因为新项将出现在下一次刷新时),但需要冻结集合的位置。在用户界面更新期间,首先会破坏使用并发集合的目的。
  • 啊,当然。我没想到。嗯,也许如果C团队从头开始,他们会设计不同的枚举器。
  • 如果他们为我们公开了这个版本,这似乎会很有帮助。


这是因为在List中有一个私有的version字段,当调用MoveNext时将检查该字段。所以现在我们知道如果我们有一个自定义的MyList实现IEnumerable,我们可以避免检查version,甚至允许枚举集合被修改(但它可能会导致意外的行为)。