下面是一个使用NUnit风格的测试:
[Test]
public void Test()
{
var i = 0;
var v = Enumerable.Repeat(0, 10).Select(x =>
{
i++;
return x;
}).Last();
Assert.That(v, Is.EqualTo(0));
Assert.That(i, Is.EqualTo(10));
}
但出乎意料的是,它失败了:
Message:
Expected: 10
But was: 1
令人惊讶的是,增加i
的副作用只发生了一次,而不是十次。所以我尝试用我自己的直观/天真的实现来替换那些LINQ方法:
private T MyLast<T>(IEnumerable<T> values)
{
var enumerator = values.GetEnumerator();
while (enumerator.MoveNext())
{
}
return enumerator.Current;
}
private IEnumerable<T> MyRepeat<T>(T value, int count)
{
for(var i = 0; i<count; ++i)
{
yield return value;
}
}
我省略了更改后的代码;但是你可以验证,如果代码使用MyRepeat
而不是Enumerable.Repeat
,或者使用MyLast
而不是Enumerable.Last
,测试通过。显然,这两种方法的实现方式不同。
(The以上都是在.NET 6中测试的,但最初的观察结果是在使用.NET Core 3.1的一段代码中)
所以我的问题是LINQ是如何实现这些方法的,导致了如此奇怪的行为?
2条答案
按热度按时间nnt7mjpx1#
.NET Core LINQ实现包括对已知类型的可编译序列的各种优化。因此,像
Last
和ElementAt
这样的运算符可能会使用更短的路径来返回结果,而不是逐个枚举序列元素。例如,可以优化List<T>
的查询,因为该集合提供对其元素的索引访问,而Queue<T>
则没有。显然,Enumerable.Repeat
和Enumerable.Range
产生的序列也可以优化。下面是另一个例子,再现了你的观察结果:输出
Online demo。
Enumerable.Repeat
的性能优化位于此源代码文件中:wfsdck302#
另一个答案是有用的,但它并没有完全回答“如何”,而是关于“为什么”(优化)。
我深入研究了一下,这是我的发现。
Enumerable.Repeat
、Enumerable.Select
和Enumerable.Last
对于LinQ进行此优化都是至关重要的。因此,替换其中任何一个都将使优化无效,并使测试通过。Enumerable.Repeat
函数给出了一个标记为适合优化的类型,并提供了最终实现。Enumerable.Select
函数检查源类型是否适合优化,如果适合,则将优化委托给它。它的返回类型也被标记为适合优化。Enumerable.Last
函数检查源类型,如果适合优化,则委托优化。有一些简单的方法可以绕过这种优化。例如,在测试用例中,如果唯一重要的是已经完成了多少次迭代,我们可以编写
会过去的。如果
Last
元素确实要在Lambda中计算,因此无法提前确定,则写入教训:在LinQ中使用副作用时请三思
这个问题证明了LinQ不是设计用来处理有副作用的数据集的。所以一般来说,我们不应该写有副作用的LinQ脚本。因此,这种代码模式自然是一种糟糕的代码,即使它可能在您的代码中工作得很好。