LINQ 是否缓存计算值

本文关键字:计算 缓存 是否 LINQ | 更新日期: 2023-09-27 18:32:06

假设我有以下代码:

var X = XElement.Parse (@"
    <ROOT>
        <MUL v='2' />
        <MUL v='3' />
    </ROOT>
");
Enumerable.Range (1, 100)
    .Select (s => X.Elements ()
        .Select (t => Int32.Parse (t.Attribute ("v").Value))
        .Aggregate (s, (t, u) => t * u)
    )
    .ToList ()
    .ForEach (s => Console.WriteLine (s));

.NET 运行时在这里实际做了什么? 它是解析属性并将其转换为整数 100 次,还是足够聪明,可以确定它应该缓存解析的值,而不是为范围中的每个元素重复计算?

此外,我自己怎么去弄清楚这样的事情?

提前感谢您的帮助。

LINQ 是否缓存计算值

LINQ 和 IEnumerable<T> 是基于拉取的。这意味着在提取值之前,通常不会执行作为 LINQ 语句一部分的谓词和操作。此外,每次拉取值时,谓词和操作都将执行(例如,没有秘密缓存正在进行)。

IEnumerable<T>中提取是通过 foreach 语句完成的,该语句实际上是语法糖,用于通过调用 IEnumerable<T>.GetEnumerator() 并反复调用 IEnumerator<T>.MoveNext() 来提取值来获取枚举器。

LINQ 运算符(如 ToList()ToArray()ToDictionary()ToLookup())包装foreach语句,以便这些方法执行拉取。对于像Aggregate()Count()First()这样的运营商也是如此。这些方法的共同点是,它们产生必须通过执行foreach语句创建的单个结果。

许多 LINQ 运算符生成新的IEnumerable<T>序列。从结果序列中提取元素时,操作员从源序列中提取一个或多个元素。Select()运算符是最明显的例子,但其他例子是SelectMany()Where()Concat()Union()Distinct()Skip()Take()。这些运算符不执行任何缓存。当从Select()中提取第 N 个元素时,它会从源序列中提取第 N 个元素,使用提供的操作应用投影并返回它。这里没有什么秘密。

其他 LINQ 运算符也会生成新的IEnumerable<T>序列,但它们是通过实际拉取整个源序列、完成其工作然后生成新序列来实现的。这些方法包括Reverse()OrderBy()GroupBy()。但是,运算符完成的拉取仅在拉取运算符本身时执行,这意味着在执行任何操作之前,您仍然需要在 LINQ 语句的"末尾"有一个foreach循环。您可能会争辩说,这些运算符使用缓存,因为它们会立即拉取整个源序列。但是,每次迭代运算符时都会构建此缓存,因此它实际上是一个实现细节,而不是神奇地检测到您正在多次将相同的OrderBy()操作应用于同一序列的东西。


在您的示例中,ToList()将执行拉取。外部Select中的操作将执行 100 次。每次执行此操作时,Aggregate()都会执行另一个拉取来解析 XML 属性。您的代码总共将调用Int32.Parse() 200 次。

您可以通过拉取属性一次而不是在每次迭代时来改善这一点:

var X = XElement.Parse (@"
    <ROOT>
        <MUL v='2' />
        <MUL v='3' />
    </ROOT>
")
.Elements ()
.Select (t => Int32.Parse (t.Attribute ("v").Value))
.ToList ();
Enumerable.Range (1, 100) 
    .Select (s => x.Aggregate (s, (t, u) => t * u)) 
    .ToList () 
    .ForEach (s => Console.WriteLine (s)); 

现在Int32.Parse()只被调用了 2 次。但是,成本是必须分配、存储并最终进行垃圾回收的属性值列表。(当列表包含两个元素时,这不是一个大问题。

请注意,如果您忘记了提取属性的第一个ToList(),代码仍将运行,但具有与原始代码完全相同的性能特征。没有空间用于存储属性,但在每次迭代时都会分析它们。

自从我深入研究这段代码以来已经有一段时间了,但是,IIRC,Select的工作方式是简单地缓存您提供它Func,然后一次在源集合上运行它。因此,对于外部范围中的每个元素,它将像第一次一样运行内部Select/Aggregate序列。没有任何内置缓存正在进行 - 您必须在表达式中自己实现它。

如果你想自己弄清楚,你有三个基本选择:

  1. 编译代码并使用ildasm查看 IL;这是最准确的,但对于 lambda 和闭包,从 IL 获得的内容可能与您放入 C# 编译器的内容完全不同。
  2. 使用dotPeek之类的东西将System.Linq.dll反编译为C#;同样,你从这些工具中得到的东西可能只是与原始源代码大致相似,但至少它将是C#(特别是dotPeek做得很好,而且是免费的。
  3. 我个人的偏好 - 下载 .NET 4.0 参考源并自行查找;这就是它的用途:)您只需要相信 MS 引用源与用于生成二进制文件的实际源匹配,但我认为没有任何充分的理由怀疑它们。
  4. 正如@AllonGuralnek所指出的,您可以在一行内对特定的lambda表达式设置断点;将光标放在lambda主体内的某个位置,然后按F9,它将仅对lambda断点。(如果你做错了,它将以断点颜色突出显示整行;如果你做对了,它只会突出显示 lambda。