执行的副作用低于预期(LINQ)

juzqafwq  于 2023-10-13  发布在  其他
关注(0)|答案(2)|浏览(110)

下面是一个使用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是如何实现这些方法的,导致了如此奇怪的行为?

nnt7mjpx

nnt7mjpx1#

.NET Core LINQ实现包括对已知类型的可编译序列的各种优化。因此,像LastElementAt这样的运算符可能会使用更短的路径来返回结果,而不是逐个枚举序列元素。例如,可以优化List<T>的查询,因为该集合提供对其元素的索引访问,而Queue<T>则没有。显然,Enumerable.RepeatEnumerable.Range产生的序列也可以优化。下面是另一个例子,再现了你的观察结果:

Test(new List<int>(Enumerable.Range(0, 10)));
Test(new Queue<int>(Enumerable.Range(0, 10)));
Test(Enumerable.Range(0, 10));

static void Test(IEnumerable<int> source)
{
    int iterations = 0;
    int result = source.Select(x => { iterations++; return x; }).ElementAt(5);
    Console.WriteLine($"{source}, result: {result}, Iterations: {iterations}");
}

输出

System.Collections.Generic.List`1[System.Int32], result: 5, Iterations: 1
System.Collections.Generic.Queue`1[System.Int32], result: 5, Iterations: 6
System.Linq.Enumerable+RangeIterator, result: 5, Iterations: 1

Online demo
Enumerable.Repeat的性能优化位于此源代码文件中:

wfsdck30

wfsdck302#

另一个答案是有用的,但它并没有完全回答“如何”,而是关于“为什么”(优化)。
我深入研究了一下,这是我的发现。

  • 所有方法Enumerable.RepeatEnumerable.SelectEnumerable.Last对于LinQ进行此优化都是至关重要的。因此,替换其中任何一个都将使优化无效,并使测试通过。
  • Enumerable.Repeat函数给出了一个标记为适合优化的类型,并提供了最终实现。
  • Enumerable.Select函数检查源类型是否适合优化,如果适合,则将优化委托给它。它的返回类型也被标记为适合优化。
  • Enumerable.Last函数检查源类型,如果适合优化,则委托优化。

有一些简单的方法可以绕过这种优化。例如,在测试用例中,如果唯一重要的是已经完成了多少次迭代,我们可以编写

[Test]
public void Test()
{
    var i = 0;
    var v = Enumerable.Repeat(0, 10).Select(x =>
    {
        i++;
        return x;
    }).Append(0).Last();

    Assert.That(v, Is.EqualTo(0));
    Assert.That(i, Is.EqualTo(10));
}

会过去的。如果Last元素确实要在Lambda中计算,因此无法提前确定,则写入

[Test]
public void Test()
{
    var i = 0;
    var v = Enumerable.Repeat(0, 10).Select(x =>
    {
        i++;
        return x + i;
    }).Concat(new int[] {}).Last();

    Assert.That(v, Is.EqualTo(10));
    Assert.That(i, Is.EqualTo(10));
}

教训:在LinQ中使用副作用时请三思

这个问题证明了LinQ不是设计用来处理有副作用的数据集的。所以一般来说,我们不应该写有副作用的LinQ脚本。因此,这种代码模式自然是一种糟糕的代码,即使它可能在您的代码中工作得很好。

相关问题