【问题标题】:Does LINQ cache computed values?LINQ 是否缓存计算值?
【发布时间】:2012-05-05 16:59:11
【问题描述】:

假设我有以下代码:

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 次解析属性并将其转换为整数,还是它足够聪明地确定它应该缓存解析的值而不是对范围内的每个元素重复计算?

此外,我将如何自己解决这样的问题?

提前感谢您的帮助。

【问题讨论】:

  • “我将如何自己弄清楚这样的事情” - 最好的办法是研究由此代码生成的 IL。
  • 你可以在 Parse() 方法上设置一个调试器断点,看看它命中的频率。

标签: c# .net linq clr4.0


【解决方案1】:

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

IEnumerable&lt;T&gt; 中拉取是通过foreach 语句完成的,该语句实际上是通过调用IEnumerable&lt;T&gt;.GetEnumerator() 并反复调用IEnumerator&lt;T&gt;.MoveNext() 来获取枚举值的语法糖。

ToList()ToArray()ToDictionary()ToLookup() 等 LINQ 运算符包装了 foreach 语句,因此这些方法将执行拉取操作。对于Aggregate()Count()First() 等运算符也是如此。这些方法的共同点是它们产生一个必须通过执行foreach 语句来创建的结果。

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

其他 LINQ 运算符也生成新的IEnumerable&lt;T&gt; 序列,但它们是通过实际拉取整个源序列、完成它们的工作然后生成新序列来实现的。这些方法包括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(),代码仍将运行,但性能特征与原始代码完全相同。不使用空间来存储属性,但在每次迭代时都会对其进行解析。

【讨论】:

    【解决方案2】:

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

    如果您想自己解决这个问题,您有三个基本选择:

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

    【讨论】:

    • 4.将光标放在=&gt; 之后,然后按 F9。这将在 lambda 中放置一个断点,并在到达它时中断。对每个 lambda 重复一遍,你就可以很好地跟踪什么时候调用的内容。
    • @AllonGuralnek 这是一个很好的观点,我倾向于忘记断点 lambda,因为我通常使用鼠标来设置它们 :)
    • 非常感谢有关按 F9 放置断点的提示。直到现在,我总是重写代码以在不同的行上使用 return 语句,然后在那里放置一个断点。你的方法会为我节省很多时间。
    • @Shredderroy, Michel:是的,这是一个存在鼠标可发现性问题的功能。米歇尔,至于您关于定位光标的注释,VS 实际上并不那么挑剔。您可以将光标放在=&gt; 和 lambda 末尾之间的任何位置以获得正确的位置(基本上是 lambda 主体中的任何位置)。事实上,由于 lambda 仅编译为一个方法,因此您可以通过将光标放在其中一个语句上并按 F9 来在其中的任何一个语句处设置断点,尽管通常 lambdas 只有一个语句或表达式。
    • 是的,你是对的;我遇到的问题是,如果光标在此处的 ^ 处:=&gt;^ Int32.Parse 那么它在 lambda 的“外部”,但如果它在这里:=&gt; ^Int32.Parse 那么它在“内部”。但我最终明白了:)
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2019-03-20
    • 2014-04-01
    • 2021-04-21
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-09-26
    相关资源
    最近更新 更多