关于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〕


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