【问题标题】:How efficient are chained LINQ statements?链式 LINQ 语句的效率如何?
【发布时间】:2020-05-01 18:05:32
【问题描述】:

我有一个案例,我想迭代集合的最后 2 个元素以外的所有元素。

假设我以x.Reverse().Skip(2).Reverse() 之类的奇怪方式进行操作。

每个 LINQ 操作会有效地生成嵌套迭代器或导致枚举等,还是比这更智能?在这种情况下,幕后会发生什么?


澄清:这只是您可能会看到的链式 LINQ 语句的一个示例,其中开发人员喜欢简短而强大的代码而不考虑性能 - 也许他们是计算机科学专业的学生,​​这似乎是“最聪明”的解决方案.我不是在问如何解决这个特定的例子

【问题讨论】:

  • Reverse 必须迭代整个集合。根据底层类型Count 可能不必迭代整个集合。
  • 为什么要避开Count()
  • 您是否可以使用SkipLast() 并让实现处理查询优化?
  • 您可以使用SkipLast 方法,该方法是从.NET Core 2.x 开始添加的
  • 了解每个 Linq 方法的作用很重要。大多数像SelectWhere 可以在迭代源代码时产生结果。但是您必须注意诸如ReverseOrderBy 甚至GroupBy 之类的东西,它们必须迭代整个源代码才能开始产生结果。

标签: c# .net performance linq


【解决方案1】:

首先是的,它正在创建一个“迭代器”,并且实际上并没有进行任何迭代,直到您在 foreach 中实现查询或在其上调用 ToList。当您这样做时,发生的迭代次数取决于基础类型。 Reverse 将为您提供的任何源创建一个缓冲区数组并向后迭代它。如果源是ICollection<T>,那么它将使用其CopyTo 方法来填充数组,这通常会在恒定时间内生成连续数据的单个大容量副本。如果它不是ICollection<T>,那么它会将源迭代到缓冲区中,然后向后迭代它。考虑到这一点,您的特定查询在迭代时会发生什么。

首先最后一个Reverse 将开始迭代其源(不是ICollection<T>)。

然后Skip 将开始迭代其源代码

如果源是ICollection<T>,那么第一个 Reverse 将执行 CopyTo,或者它将源迭代到一个缓冲区数组中,并根据需要调整大小。

然后第一个 Reverse 将向后迭代其缓冲区数组

然后,Skip 将跳过前两个并产生其余的结果

然后最后一个 Reverse 将获取结果并将它们添加到其缓冲区数组中并根据需要调整其大小。

最后最后一个 Reverse 将向后迭代缓冲区数组。

因此,如果您要处理的是 ICollecion<T>,那就是一个 CopyTo,然后对所有值进行 1 次迭代,然后对除 2 个以外的所有值进行 1 次迭代。如果不是ICollection<T>,那基本上是值的 3 次迭代(实际上最后一次迭代是除了 2 之外的所有迭代)。无论哪种方式,它也在过程中使用了两个中间数组。

为了证明查询在实现之前不会进行迭代,您可以查看此示例

void Main()
{
    var query = MyValues().Reverse().Skip(2).Reverse();
    Console.WriteLine($"After query before materialization");
    var results = query.ToList();
    Console.WriteLine(string.Join(",", results));
}

public IEnumerable<int> MyValues()
{
    for(int i = 0; i < 10; i ++)
    {
        Console.WriteLine($"yielding {i}");
        yield return i;
    }
}

哪个产生输出

After query before materialization
yielding 0
yielding 1
yielding 2
yielding 3
yielding 4
yielding 5
yielding 6
yielding 7
yielding 8
yielding 9
0,1,2,3,4,5,6,7

与您拥有x.Take(x.Count() - 2) 的另一个示例相比,它将在您为Count 实现一次之前迭代源(除非它是ICollectionICollection&lt;T&gt;,在这种情况下它将只使用@987654341 @property) 然后它会在你实现它时再次迭代它。

这是相同的示例,但代码和结果不同。

void Main()
{
    var x = MyValues();
    var query = x.Take(x.Count() - 2);
    Console.WriteLine($"After query before materialization");
    var results = query.ToList();
    Console.WriteLine(string.Join(",", results));
}

public IEnumerable<int> MyValues()
{
    for(int i = 0; i < 10; i ++)
    {
        Console.WriteLine($"yielding {i}");
        yield return i;
    }
}

有输出

yielding 0
yielding 1
yielding 2
yielding 3
yielding 4
yielding 5
yielding 6
yielding 7
yielding 8
yielding 9
After query before materialization
yielding 0
yielding 1
yielding 2
yielding 3
yielding 4
yielding 5
yielding 6
yielding 7
0,1,2,3,4,5,6,7

所以哪个更好完全取决于来源。对于ICollection&lt;T&gt;ICollectionTakeCount 将是首选(除非源可能在创建查询和实现查询之间发生变化),但如果这两者都不是你可能更喜欢双Reverse 以避免重复源两次(特别是如果源可以在您创建查询和实际实现查询之间发生变化,因为大小也可能发生变化),但这必须与完成的总迭代次数和内存的增加相权衡用法。

【讨论】:

    【解决方案2】:

    大多数 LINQ 操作不会产生单独的嵌套迭代。虽然Count() 必须遍历完整的序列。

    至于您提问的内容,请参考:How to take all but the last element in a sequence using LINQ?

    【讨论】:

    • Count 确实有一些优化,基于检测底层类型,它实际上不必遍历整个序列。这是source 供参考。
    • 我的问题的内容不是如何取最后一个元素。这只是一个例子。问题是关于链式 LINQ 操作实际上如何工作
    • 如果您认为该问题与另一个问题相同,您应该投票关闭该问题作为重复问题,而不是回答它...
    • @Mr.Boy 如果你真的想了解 LINQ 是如何在底层工作的,你应该查看Enumerable 的参考源,因为它大部分都在此实现。
    • 每个关于 .Net 的问题都可以“阅读源代码”来回答...不是一个特别有用的答案 :) 我已经编辑了我的问题,试图让它更清楚一点
    【解决方案3】:

    当你想知道一个 LINQ 语句是如何工作的,它的效率如何时,看看source code 可能是个好主意(谷歌:参考源 Enumerable reverse)

    在这里您会发现,一旦您开始枚举您的序列(即:使用非延迟方法 = 使用不返回 IEnumerable 的 LINQ 方法,或使用 foreach),第一个 Reverse 将枚举您的完整序列一次,但它在缓冲区中,并从最后一个元素开始向后迭代。

    您的 skip(2) 只会枚举 2 个元素。

    第二个 Reverse 将创建一个新的 Buffer,包含这两个元素并开始向后迭代:所以在你的原始序列中向前。

    如果您看看发生了什么:原始序列的元素被放入缓冲区,最后一个和最后一个元素被放入第二个缓冲区。第二个缓冲区被迭代:pre-last 然后最后一个元素。

    所以每个元素迭代一次,最后两个元素再迭代一次。如果迭代工作量很大,可以考虑创建一个 List,然后取最后两个元素。这只会迭代你的元素一次。

    如果您有其他 LINQ 语句想知道它是如何完成的,请查看源代码

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-02-02
      • 2012-05-14
      • 2011-01-24
      • 1970-01-01
      • 2017-07-09
      • 1970-01-01
      相关资源
      最近更新 更多