4

考虑以下代码:

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,但我不明白这一点:

为什么在第一次调用时IEnumerator抛出这个异常?由于不代表第一次调用until的状态,为什么它不能从第一次而不是从开始跟踪更改?MoveNext()IEnumeratorIEnumerableMoveNext()MoveNext()GetEnumerator()

4

3 回答 3

8

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

于 2012-07-02T02:25:02.010 回答
5

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

迭代器(C# 的 IEnumerator 和 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 类的一个片段

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
于 2012-07-02T15:48:58.320 回答
1

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

于 2012-07-02T02:32:35.150 回答