为啥有些 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
的计数正在发生变化。而且,虽然在开始时valuesToInsert
的foreach
计数是两个,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 => x.Id == 1 || x.ParentId == 1)
。如果你只想要孩子,list.Where(x => x.ParentId == 1)
是简化的代码,实际上嵌套可能不只有两层。
您是否知道在像var testEn = list.Where(x => 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()
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#detect 需要 Proc/lambda?
为啥重复 Enumerable 到 Observable 转换块
为啥 Enumerable 不继承自 IEnumerable<T>