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

Side effects executed less than expected (LINQ)

提问人:Earth Engine 提问时间:9/30/2023 最后编辑:Theodor ZouliasEarth Engine 更新时间:9/30/2023 访问量:69

问:

下面是使用 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

令人惊讶的是,增加的副作用只执行了一次,而不是十次。因此,我尝试用我自己的直观/朴素实现替换这些 LINQ 方法:i

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;
    }
}

我省略了更改的代码;但您可以验证,如果代码使用 而不是 ,或者 使用 而不是 ,则测试是否通过。因此,显然这两种方法的实现方式不同。MyRepeatEnumerable.RepeatMyLastEnumerable.Last

(以上内容在 .NET 6 中进行了测试,但最初的观察是在使用 .NET Core 3.1 的一段代码中)

所以我的问题是:LINQ 如何以导致这种奇怪行为的方式实现这些方法?

c# LINQ .net-core 副作用

评论

0赞 Daniel A. White 9/30/2023
看看 enumerable.repeat 是如何实现的
0赞 Dai 9/30/2023
@EarthEngine 你为什么要这样做?

答:

1赞 Theodor Zoulias 9/30/2023 #1

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

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

在线演示

的性能优化位于以下源代码文件中:Enumerable.Repeat

0赞 Earth Engine 9/30/2023 #2

另一个答案是有用的,但它并没有完全回答“如何”,而是关于“为什么”(优化)。

我深入研究了它,这是我的发现。

  • 所有的方法,对于LinQ进行这种优化至关重要。因此,替换其中任何一个都会使优化无效并使测试通过。Enumerable.RepeatEnumerable.SelectEnumerable.Last
  • 该函数给出一个标记为适合优化的类型,并提供最终实现。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));
}

这将通过。如果元素确实要在 Lambda 中计算,因此无法提前确定,请写入Last

[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 不是为使用具有副作用的 lambda 而设计的。所以一般来说,我们不应该编写有副作用的 LinQ lambda。因此,这种代码模式自动成为代码的不良品味,即使它可能在您的代码中工作得很好。

评论

0赞 Theodor Zoulias 9/30/2023
如果你想阻止优化,我的建议是拦截一个隐藏基础集合标识的简单包装器。就像这个答案中的东西一样。HideIdentity