【问题标题】:Why I should not modify a collection when I am iterating on it [closed]为什么我在迭代集合时不应该修改它[关闭]
【发布时间】:2013-01-29 17:02:23
【问题描述】:

我知道在 .net 集合类型(或至少某些集合类型)中,当您对其进行迭代时,不允许修改集合。

例如在 List 类中存在这样的代码:

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

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

那我有几个问题:

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

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

  • 当C#编译器生成Enumerator接口实现时会考虑到这种情况吗?

【问题讨论】:

  • ConcurrentDictionary 允许在枚举时进行修改。
  • @ken2k ConcurrentDictionary 以及该命名空间中的其他集合,不返回迭代集合的迭代器。他们将所有元素复制到一个临时命名空间并返回该集合的迭代器,这提供了一个永远不会修改的集合的迭代器。当您期望底层数据在迭代期间发生变化时,这是为序列提供迭代器的一种方法。
  • @Servy:ConcurrentDictionary 的文档明确指出,不能保证它返回的项目组合代表实际存在的状态的快照。例如,如果一个线程按顺序添加“Moe”和“Curly”,而另一个线程正在尝试枚举字典,则不能保证第二个线程在没有看到“Moe”的情况下看不到“Curly”。我相信枚举过程中未添加或删除的项目保证返回一次,枚举过程中添加或删除的项目最多返回一次。

标签: c# .net collections ienumerable


【解决方案1】:

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

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

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

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

当然。

考虑这个迭代器的列表:

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

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

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

public static IEnumerable<T> IterateWhileMutating<T>(
    this ObservableCollection<T> 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;
    }
}
  • 如果从序列中的“较早”中删除一个项目,我们会正常继续而不跳过一个项目。

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

  • 如果一个项目从当前位置之前移动到之后,它将显示两次,但不会跳过或重复其他项目。如果一个项目从当前位置之后移动到当前位置之前,它将不会显示,但仅此而已。如果一个项目从集合中的任何一个稍后移动到另一个位置,没有问题,移动将在结果中看到,如果它从较早的位置移动到另一个较早的位置,一切都很好,移动迭代器不会“看到”。

  • 更换物品不是问题;它只有在当前位置“之后”时才会显示。

  • 重置集合会导致序列在当前位置优雅地结束。

请注意,此迭代器不会处理多线程情况。如果另一个线程在另一个线程迭代时改变了集合,则可能会发生不好的事情(项目被跳过或重复,甚至是异常,例如索引超出范围异常)。 允许在迭代期间发生突变,其中要么只有一个线程,要么只有一个线程正在执行移动迭代器或改变集合的代码。

C#编译器生成Enumerator接口实现的时候会考虑到这种情况吗?

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

【讨论】:

  • 当我们使用 yield 关键字实现一个返回 Enumerable 的方法时,编译器会为我们创建一个实现 IEnumerator 接口的类。你的意思是:“编译器不会生成接口实现;一个人会”
  • @EricJavierHernandezSaura 一个人正在创建迭代器块,并且基于该迭代器块的定义,迭代器可能支持也可能不支持迭代期间的突变。编译器不能只是“变魔术”让它工作,一个人需要。迭代器并非凭空而来,编译器只是简单地获取您提供的代码并将其转换为其他内容。它不是从无到有。
  • @Sevy 好的,这很符合逻辑。我只是接受你的回答。谢谢
【解决方案2】:

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

【讨论】:

  • +1 - 如果“iterator.Next”的解释需要多于一行文本(以涵盖所有情况,例如“从集合中删除当前元素”),它将使 99% 的开发人员感到困惑。
【解决方案3】:

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

值得注意的是,即使使用for 循环枚举List&lt;T&gt; 也会表现出这种线程安全性的缺失。

从这个blog post by JaredPar 中,他创建了一个ThreadSafeList&lt;T&gt; 类:

该集合不再实现 IEnumerable。 IEnumerable 仅在集合未在后台更改时才有效。以这种方式构建的集合无法轻松实现此保证,因此将其删除。

值得一提的是,并非所有IEnumerable 的实现都不允许在枚举期间进行修改。 concurrent collections 确实如此,因为它们保证线程安全。

【讨论】:

  • +1 链接到关于 IEnumerable 行为的良好讨论...请注意,虽然线程安全很有趣,但我认为问题是另一种方式 - 为什么需要这种行为。
  • 这并不能解释为什么它是单线程应用程序中的一个问题。比如为什么阻止人们写:foreach(var item in collection) collection.Remove(item);(假设没有其他线程。)
  • 嗯,如果变异 List&lt;T&gt; 不是线程安全的,可以做某事(枚举)使其更不是线程安全的吗?
【解决方案4】:

使用 yield 语句加载您要修改的元素并在事后这样做

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

【讨论】:

    【解决方案5】:

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

    IEnumerable.GetEnumerator

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

    这避免了像 LinkedList 这样的集合的问题。想象一下,您有一个包含 4 个节点的链表,并且您迭代到第二个节点。然后链表发生变化,其中第二个节点移动到链表的头部,第三个节点移动到链表的尾部。到那时,对您的枚举器进行下一步操作意味着什么?可能的行为将是模棱两可的,不容易猜到。当您通过其接口处理对象时,您不必考虑底层类是什么,以及该类及其枚举器是否允许修改。接口说修改会使枚举器失效,所以事情应该是这样的。

    【讨论】:

      猜你喜欢
      • 2020-12-20
      • 1970-01-01
      • 2013-05-13
      • 1970-01-01
      • 2020-09-13
      • 1970-01-01
      • 2014-07-28
      • 2015-09-07
      • 1970-01-01
      相关资源
      最近更新 更多