为什么在枚举字典键时不能更改其值?

29
class Program
{
    static void Main(string[] args)
    {
        var dictionary = new Dictionary<string, int>()
        {
            {"1", 1}, {"2", 2}, {"3", 3}
        };

        foreach (var s in dictionary.Keys)
        {
            // Throws the "Collection was modified exception..." on the next iteration
            // What's up with that?

            dictionary[s] = 1;  
        }
    }
}

我完全理解在枚举列表时为什么会抛出这个异常。期望在枚举过程中不会改变被枚举对象的结构是合理的。但是,改变字典中的一个值是否也会改变其结构? 特别是它的键的结构?

9个回答

21

由于键和值被存储为一对,因此没有单独的结构来存储键和值,而是将它们作为一组成对值存储在单个结构中。当您更改一个值时,它必须改变包含键和值的单个基础结构。

更改值是否必然改变基础结构的顺序?不一定。这是实现特定的细节,Dictionary<TKey,TValue>类正确地通过允许修改值作为API的一部分而未揭示这一点。


正确的答案是,这些项的类型是KeyValuePair<TKey, TValue>,枚举器迭代的是项,而不是键。 - Danny Varod
据我所知,字典是作为哈希表实现的,这意味着它不是存储为键值对列表。即使它是,值只是寄生在键上的东西-它们是以一种复杂的结构组织的关键字。 - Vitaliy
@Vitaliy 我在回答中非常小心地没有提到“列表(List)”这个词,其中一个原因是底层的结构实现与键和值被视为一对相比而言有些不相关。更改其中一个会影响另一个,因为它们被视为同一对象的一部分。 - JaredPar
6
我认为假定它们被视为同一对象并不是微不足道的(除非您已经看过代码)。事实上,我熟悉的大多数哈希表的实现并不将键和值存储为一对。该结构确实允许更改值而不更改键结构。 - Vitaliy
12
我会尽力进行翻译,请问需要翻译的内容是:“Jared - I would argue completely the opposite; throwing the exception IS exposing the implementation detail. A proper abstraction would not tell you the .Keys collection has changed when in fact it hasn't.”? - Roman Starkov
答案错误。这种行为只是一个不幸的设计选择,可能只是基于枚举条件如何容易被编码人员优化。一旦序列中产生了一个元素,它是否被更改都无关紧要。 - Suncat2000

8

多亏了Vitaliy,我回到代码中仔细查看,似乎这是一个特定的实现决策,禁止了这种操作(见下面的片段)。字典保留了一个名为verrsion的私有值,当更改现有项的值时会自增。当创建枚举器时,它记录了此时的值,然后在每次调用MoveNext时进行检查。

for (int i = this.buckets[index]; i >= 0; i = this.entries[i].next)
{
    if ((this.entries[i].hashCode == num) && this.comparer.Equals(this.entries[i].key, key))
    {
        if (add)
        {
            ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_AddingDuplicate);
        }
        this.entries[i].value = value;
        this.version++;
        return;
    }
}

我不知道为什么这是必要的。你仍然可以修改该值的属性,只是不能将其赋值给一个新值:

public class IntWrapper
{
  public IntWrapper(int v) { Value = v; }
  public int Value { get; set; }
}

class Program
{
  static void Main(string[] args)
  {
    var kvp = new KeyValuePair<string, int>("1",1);
    kvp.Value = 17;
    var dictionary = new Dictionary<string, IntWrapper>(){
      {"1", new IntWrapper(1)}, 
      {"2", new IntWrapper(2)}, 
      {"3", new IntWrapper(3)} };

    foreach (var s in dictionary.Keys)
    {
      dictionary[s].Value = 1;  //OK
      dictionary[s] = new IntWrapper(1); // boom
    }
  } 
}

我对使用KeyValue对数组实现字典的方法持怀疑态度,特别是考虑到两个不同的键可以映射到相同的数值哈希码。因此每个键实际上被映射到多个值,这就需要重写Equals方法。 - Vitaliy
如果你持怀疑态度,你可以去获取 .Net Reflector 的副本并自行反汇编代码。 - Dolphin
3
我查看了实现代码,虽然字典包含多个条目,但为什么不能更改值并不明显。 虽然KeyValuePair是一个结构体,但它并非天生不可变,正如你所说,它的Value属性可以被更改。此外,考虑到给定的实现在枚举期间更改值时会抛出异常,这种行为并不直观,并通过暴露实现细节来破坏封装性。有些人会说这是糟糕的API设计! - Vitaliy
我在阅读实现时太快了。我的另一个答案谈到了机制,尽管我仍然想不出一个好的原因。 - Dolphin
@Dolphin 那是一个很好的例子。我同意没有好的理由禁止更改元素的属性。 - Suncat2000

8

实际上,我理解你的观点。这里大部分答案未能注意到的是,你正在遍历键列表,而不是字典本身的项。如果.NET框架程序员想要的话,他们可以相当容易地区分对字典结构所做的更改和对字典值所做的更改。尽管如此,即使人们遍历集合的键,他们通常最终也会获取其中的值。我猜测.NET框架设计者认为,如果您正在遍历这些值,您肯定希望知道是否有某些变化正在干扰您,就像任何列表一样。或者他们认为这个问题不够重要,不值得投入编程和维护的时间来区分一种变化和另一种变化。


感谢指出。虽然这是一个旧答案,但我也想指出一些事情。最令人困惑的是,在VS调试器中,人们可以深入了解Object.Dictionary.List(我希望我描述得足够清楚),并在那里看到与Object.Dictionary.Items中相同的值列表。难怪我们会循环遍历错误的集合... - Oak_3260548

5

有可能您刚刚向字典中插入了一个新的键,这确实会改变dictionary.Keys。虽然在这个特定的循环中永远不会发生这种情况,但一般情况下,[]操作可能会改变键列表,因此被标记为突变。


5

Dictionary上的索引器可能会改变集合的结构,因为如果不存在此键,则会添加一个新条目。但显然这里并非如此,但我预计Dictionary契约被故意保持简单,所有对对象的操作都分为“可变”和“不可变”,所有“可变”操作都会使枚举器无效,即使它们实际上没有更改任何内容。


1

从文档(Dictionary.Item 属性)中可以看到:

您还可以使用 Item 属性通过设置不存在于 Dictionary 中的键的值来添加新元素。当您设置属性值时,如果该键在 Dictionary 中,与该键关联的值将被分配值所替换。如果该键不在 Dictionary 中,则该键和值将被添加到字典中。相反,Add 方法不会修改现有元素。

因此,正如 John 所指出的那样,框架无法知道您是否已更改列表的内容,因此它假定您已经更改了内容。


3
我不同意。这个框架确实知道你已经访问了索引器属性。检查键的结构是否失效非常简单,只有在这种情况下才会引发异常。 - Vitaliy
@Vitaliy 不仅是微不足道的,.net还必须为Add方法做这件事,实际上它已经做到了 - Evgeniy Berezovsky

1

对于那些想知道如何解决这个问题的人,以下是 Vitaliy 修改后可行的代码:

class Program
{
    static void Main(string[] args)
    {
        var dictionary = new Dictionary<string, int>()
        {
            {"1", 1}, {"2", 2}, {"3", 3}
        };

        string[] keyArray = new string[dictionary.Keys.Count];
        dictionary.Keys.CopyTo(keyArray, 0);
        foreach (var s in keyArray)
        {
            dictionary[s] = 1;
        }
    }
}

答案是将键复制到另一个可枚举对象中,然后对该集合进行迭代。由于某种原因,没有KeyCollection.ToList方法可以简化操作。相反,您需要使用KeyCollection.CopyTo方法,将键复制到数组中。

这并没有解决问题,伙计。你仍然会收到“集合已修改”错误。尝试不错,但还差一点。 - Obi Wan
@ObiWan:在我使用的VS 2013编写的.NET 4.5控制台应用程序中没有错误。你使用的是哪个版本的.NET? - Simon Elms
1
当时我使用的是4.6.1,可能没有完全按照你的方法去做。我为自己的愤世嫉俗的评论道歉,因为我感到沮丧。在我看来,如果我不改变键集合,只要迭代键集合并修改值就可以了,这似乎很荒谬。整个问题都是由于Dictionary类的糟糕设计而导致的,我认为它不应该禁止这样做。 - Obi Wan
@ObiWan:没问题,我并不生气。如果你无法重现我的结果,提出这个问题是很合理的。谢谢。 - Simon Elms

0

这是因为他们设计了 .Net 具有在多个线程中迭代集合的能力。所以你要么允许迭代器是多线程的,要么防止它并允许在迭代期间修改集合,这将需要将对象限制为在单个线程中进行迭代。两者不能兼得。

实际上,你问题的答案是,你输入的代码实际上会生成一个编译器生成的([CompilerGenerated])状态机,允许迭代器维护集合状态以提供 yield 魔法。这就是为什么如果你不同步你的集合,并在一个线程中进行迭代和在另一个线程中进行操作,你会遇到一些奇怪的问题。

请查看:http://csharpindepth.com/articles/chapter6/iteratorblockimplementation.aspx

此外:http://docs.oracle.com/javase/7/docs/api/java/util/concurrent/ConcurrentHashMap.html “迭代器被设计为只能由一个线程使用。”


0
简短的回答是,您正在修改字典集合,即使您实际上没有更改任何键。因此,在您更新后访问集合的下一次迭代会抛出异常,指示自上次访问以来已修改集合(这是正确的)。
为了做到您想要的,您需要通过不同的方式迭代元素,以便更改它们不会触发迭代器异常。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接