为啥有些 Enumerable 可以在 foreach 中更改,而有些则不能?

Posted

技术标签:

【中文标题】为啥有些 Enumerable 可以在 foreach 中更改,而有些则不能?【英文标题】:Why can some Enumerable be changed inside foreach, and others can't?为什么有些 Enumerable 可以在 foreach 中更改,而有些则不能? 【发布时间】:2020-07-25 03:44:06 【问题描述】:

在使用 C# 时,我发现 LINQ 查询结果的一个有趣行为。我试图弄清楚这一点,但找不到正确解释为什么会这样工作。所以我在这里问,也许有人可以给我一个很好的解释(导致这种行为的内部工作)或者一些链接。

我有这门课:

    public class A
    
        public int Id  get; set; 

        public int? ParentId  get; set; 
    

还有这个对象:

var list = new List<A> 
             
                new A  Id = 1, ParentId = null , 
                new A  Id = 2, ParentId = 1 , 
                new A  Id = 3, ParentId = 1 , 
                new A  Id = 4, ParentId = 3 ,
                new A  Id = 5, ParentId = 7 
            ;

还有我的代码,它适用于这个对象:

var result = list.Where(x => x.Id == 1).ToList();
var valuesToInsert = list.Where(x => result.Any(y => y.Id == x.ParentId));

Console.WriteLine(result.Count); // 1
Console.WriteLine(valuesToInsert.Count()); //2

foreach (var value in valuesToInsert)

    result.Add(value);


Console.WriteLine(valuesToInsert.Count()); //3. collection (and its count) was changed inside the foreach loop
Console.WriteLine(result.Count); //4

因此,result 变量的计数为 1,valuesToInsert 计数为 2,并且在 foreach 循环(不会显式更改 valuesToInsert)之后,valuesToInsert 的计数正在发生变化。而且,虽然在开始时valuesToInsertforeach 计数是两个foreach 进行了三个迭代。

那么为什么这个 Enumerable 的值可以在 foreach 内改变呢?并且,例如,如果我使用此代码更改 Enumerable 的值:

var testEn = list.Where(x => x.Id == 1);
foreach (var x in testEn)

    list.Add(new A  Id = 1 );

我收到了System.InvalidOperationException: 'Collection was modified; enumeration operation may not execute.'。它们之间有什么区别?为什么一个集合可以修改而另一个不能?

附:如果我像这样添加ToList()

var valuesToInsert = list.Where(x => result.Any(y => y.Id == x.ParentId)).ToList();

或者像这样:

foreach (var value in valuesToInsert.ToList())

它只进行两次迭代。

【问题讨论】:

撇开问题本身不谈,代码似乎是为它打算实现的目标而设计的。如果你想要父母和它的孩子,你可以list.Where(x =&gt; x.Id == 1 || x.ParentId == 1)。如果你只想要孩子,list.Where(x =&gt; x.ParentId == 1) 是简化的代码,实际上嵌套可能只有两层。 您是否知道在像var testEn = list.Where(x =&gt; x.Id == 1); 这样的语句中,testEn 只是list 的“视图”,只有与谓词匹配的元素,并且不会创建新列表,直到你打电话给ToList()?循环通过testEn就像循环通过list,但有一个条件。 “没有新列表” - 你的意思是内存中没有新的集合? 【参考方案1】:

valuesToInsert 集合在 Where 子句中引用了 result 集合:

var valuesToInsert = list.Where(x => result.Any(y => y.Id == x.ParentId));

因为 Enumerable 使用 yield return 工作,所以它使用最近生成的每个项目的 result 集合。

如果您不希望这种行为,您应该首先使用 ToList()

评估 valueToInsert
foreach (var value in valuesToInsert.ToList())

关于“收藏已修改”异常。您不能在枚举时更改枚举。现在 result 集合已更改,但在枚举时不会更改;它仅在每次 for each 循环请求新项目时被枚举。 (这会降低添加子节点的算法效率,这对于大量集合来说会变得很明显。)

【讨论】:

我的算法不仅要添加孩子,还应该添加孩子的孩子等等。你知道如何写它高效吗?【参考方案2】:

这里有多个问题:

因此,在第一次查询结果变量的计数为 1 后,在第二次查询 valuesToInsert 计数为 2 之后,在 foreach 循环(不会显式更改 valuesToInsert)之后,valuesToInsert 的计数正在发生变化。

正如预期的那样,因为我们在变量中的引用与 valuesToInsert 变量所持有的引用相同。所以对象是相同的,但多个引用指向同一个。

你的第二个问题:

那么为什么这个 Enumerable 的值可以在 foreach 中改变呢?

当我们将集合作为 IEnumerable 类型的引用时,IEnumerable 集合是只读的,但是当我们在其上调用 ToList() 方法时,我们有一个指向同一个原始集合的集合的副本,但我们现在可以添加更多项目收藏。

当我们将集合设置为IEnumerable 时,可以迭代和读取集合,但在枚举时添加更多项目会失败,因为集合应该按顺序读取。

第三:

它只进行两次迭代。

是的,因为在那个时刻,无论集合中的项目数量都被枚举了,并且对它的引用被存储为一个新列表,而它仍然指向同一个对象,即 IEnumerable,但现在我们可以添加更多项目到期其类型为 List。

见:

var result = list.Where(x => x.Id == 1).ToList(); 
// result is collection which can be modified, items add, remove etc

var result = list.Where(x => x.Id == 1);
 // result is IEnumerable which can be iterated to get items one by one
 // modifying this collection would error out normally

【讨论】:

>当我们将集合设置为 IEnumerable 时,可以迭代和读取集合,但在枚举时添加更多项目会失败,因为集合应该按顺序读取。 “枚举时添加会失败”是什么意思?我们显然不能在 IEnumerable 上使用 .Add() 方法,但是在我的代码中,对象 are 在枚举时添加到 IEnumerable 集合(在 foreach 集合的开头 valuesToInsert 只有两个对象,然后它会增长到三个,foreach,总共进行 三个 迭代)。 但是您正在使用列表引用添加【参考方案3】:

这段代码:

foreach (var value in valuesToInsert)

    result.Add(value);

...由 C# 编译器转换为等效的代码块:

IEnumerator<A> enumerator = valuesToInsert.GetEnumerator();
try

    while (enumerator.MoveNext())
    
        var value = enumerator.Current;
        result.Add(value);
    

finally

    enumerator.Dispose();

List 发生突变时,List 返回的枚举器无效,这意味着如果在突变后调用方法MoveNext 将抛出InvalidOperationException。在这种情况下,valuesToInsert 不是 List,而是 LINQ 方法 Where 返回的可枚举。该方法通过枚举它的源惰性获得的枚举器来工作,在本例中为list。因此,枚举一个枚举器会间接导致另一个枚举器的枚举,这隐藏在神奇的 LINQ 链中更深。在第一种情况下,list 在枚举块内没有发生变异,因此不会引发异常。在第二种情况下,它发生了变异,导致异常从一个MoveNext 传播到另一个,并最终被foreach 语句抛出。

值得注意的是,此行为不属于List 类的公共合同的一部分,因此可以在未来的 .NET 版本中进行更改。所以你应该避免依赖这种行为来保证你的程序的正确性。这个警告不是理论上的。在 .NET Core 3.0 中使用 Dictionary 类进行类似 has already happened 的更改。

【讨论】:

行为的哪一部分可以改变?如果在突变后调用 MoveNext 方法会抛出 InvalidOperationException 吗? @helgez 是的,这个。在 .NET 的未来版本中,可能不会引发异常。

以上是关于为啥有些 Enumerable 可以在 foreach 中更改,而有些则不能?的主要内容,如果未能解决你的问题,请参考以下文章

为啥 Enumerable 在 Ruby 中没有长度属性?

为啥 Enumerable#detect 需要 Proc/lambda?

为啥重复 Enumerable 到 Observable 转换块

为啥 Enumerable 不继承自 IEnumerable<T>

为啥 Enumerable.Range 实现 IDisposable?

为啥 Enumerable 不实现 IEnumerable?