【问题标题】:How does deferred LINQ query execution actually work?延迟的 LINQ 查询执行实际上是如何工作的?
【发布时间】:2018-05-02 22:12:34
【问题描述】:

最近我遇到了这样的问题: What numbers will be printed considering the following code:

class Program
{
    static void Main(string[] args)
    {
        int[] numbers = { 1, 3, 5, 7, 9 };
        int threshold = 6;
        var query = from value in numbers where value >= threshold select value;

        threshold = 3;
        var result = query.ToList();

        result.ForEach(Console.WriteLine);
        Console.ReadLine();
    }
}

回答:3, 5, 7, 9

这让我很惊讶。我认为threshold 值将在查询构造时放入堆栈,稍后在执行时,该数字将被拉回并在条件中使用..这没有发生。

另一种情况(numbers 在执行前被设置为null):

    static void Main(string[] args)
    {
        int[] numbers = { 1, 3, 5, 7, 9 };
        int threshold = 6;
        var query = from value in numbers where value >= threshold select value;

        threshold = 3;
        numbers = null;
        var result = query.ToList();
        ...
    }

似乎对查询没有影响。它打印出与前面示例完全相同的答案。

谁能帮助我了解幕后的真实情况?为什么更改threshold 会影响查询执行而更改numbers 不会?

【问题讨论】:

  • 从流利表示法改为函数式表示法更容易理解。 numbers.Where(value => (value >= threshold)).Select(value => value)。现在您看到 threshold 在 lambda 中(因此延迟评估),但 numbers 不在 lambda 中(因此立即评估)。
  • 这是不使用 LINQ 查询语法的一个很好的理由。它向您隐藏了实际发生的事情,即使有任何好处也很少。就个人而言,我什至不觉得它更美观。
  • This great blog by John Skeet 确实帮助我了解了很多关于 LINQ 的知识。
  • @RaymondChen 你为什么要在评论中写答案??
  • 本文解释了 .NET Framework 和 .NET 5 的幕后情况:levelup.gitconnected.com/…

标签: c# linq


【解决方案1】:

最简单的方法是查看编译器将生成什么。您可以使用本站:https://sharplab.io

using System.Linq;

public class MyClass
{
    public void MyMethod()
    {
        int[] numbers = { 1, 3, 5, 7, 9 };

        int threshold = 6;

        var query = from value in numbers where value >= threshold select value;

        threshold = 3;
        numbers = null;

        var result = query.ToList();
    }
}

这是输出:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security;
using System.Security.Permissions;

[assembly: AssemblyVersion("0.0.0.0")]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default | DebuggableAttribute.DebuggingModes.DisableOptimizations | DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints | DebuggableAttribute.DebuggingModes.EnableEditAndContinue)]
[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)]
[module: UnverifiableCode]
public class MyClass
{
    [CompilerGenerated]
    private sealed class <>c__DisplayClass0_0
    {
        public int threshold;

        internal bool <MyMethod>b__0(int value)
        {
            return value >= this.threshold;
        }
    }

    public void MyMethod()
    {
        MyClass.<>c__DisplayClass0_0 <>c__DisplayClass0_ = new MyClass.<>c__DisplayClass0_0();
        int[] expr_0D = new int[5];
        RuntimeHelpers.InitializeArray(expr_0D, fieldof(<PrivateImplementationDetails>.D603F5B3D40E40D770E3887027E5A6617058C433).FieldHandle);
        int[] source = expr_0D;
        <>c__DisplayClass0_.threshold = 6;
        IEnumerable<int> source2 = source.Where(new Func<int, bool>(<>c__DisplayClass0_.<MyMethod>b__0));
        <>c__DisplayClass0_.threshold = 3;
        List<int> list = source2.ToList<int>();
    }
}
[CompilerGenerated]
internal sealed class <PrivateImplementationDetails>
{
    [StructLayout(LayoutKind.Explicit, Pack = 1, Size = 20)]
    private struct __StaticArrayInitTypeSize=20
    {
    }

    internal static readonly <PrivateImplementationDetails>.__StaticArrayInitTypeSize=20 D603F5B3D40E40D770E3887027E5A6617058C433 = bytearray(1, 0, 0, 0, 3, 0, 0, 0, 5, 0, 0, 0, 7, 0, 0, 0, 9, 0, 0, 0);
}

如您所见,如果您更改 threshold 变量,您实际上会更改 auto-generated 类中的字段。因为您可以随时执行查询,所以不可能引用位于堆栈上的字段 - 因为当您退出方法时,threshold 将从堆栈中删除 - 因此编译器将此字段更改为自动生成的类与同一类型的field

第二个问题:为什么 null 有效(在这段代码中不可见)

当你使用:source.Where时,它会调用这个扩展方法:

   public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate) {
        if (source == null) throw Error.ArgumentNull("source");
        if (predicate == null) throw Error.ArgumentNull("predicate");
        if (source is Iterator<TSource>) return ((Iterator<TSource>)source).Where(predicate);
        if (source is TSource[]) return new WhereArrayIterator<TSource>((TSource[])source, predicate);
        if (source is List<TSource>) return new WhereListIterator<TSource>((List<TSource>)source, predicate);
        return new WhereEnumerableIterator<TSource>(source, predicate);
    }

如你所见,它传递引用:

WhereEnumerableIterator<TSource>(source, predicate);

这里是where iterator的源代码:

    class WhereEnumerableIterator<TSource> : Iterator<TSource>
    {
        IEnumerable<TSource> source;
        Func<TSource, bool> predicate;
        IEnumerator<TSource> enumerator;

        public WhereEnumerableIterator(IEnumerable<TSource> source, Func<TSource, bool> predicate) {
            this.source = source;
            this.predicate = predicate;
        }

        public override Iterator<TSource> Clone() {
            return new WhereEnumerableIterator<TSource>(source, predicate);
        }

        public override void Dispose() {
            if (enumerator is IDisposable) ((IDisposable)enumerator).Dispose();
            enumerator = null;
            base.Dispose();
        }

        public override bool MoveNext() {
            switch (state) {
                case 1:
                    enumerator = source.GetEnumerator();
                    state = 2;
                    goto case 2;
                case 2:
                    while (enumerator.MoveNext()) {
                        TSource item = enumerator.Current;
                        if (predicate(item)) {
                            current = item;
                            return true;
                        }
                    }
                    Dispose();
                    break;
            }
            return false;
        }

        public override IEnumerable<TResult> Select<TResult>(Func<TSource, TResult> selector) {
            return new WhereSelectEnumerableIterator<TSource, TResult>(source, predicate, selector);
        }

        public override IEnumerable<TSource> Where(Func<TSource, bool> predicate) {
            return new WhereEnumerableIterator<TSource>(source, CombinePredicates(this.predicate, predicate));
        }
    }

所以它只是简单地在私有字段中保持对我们源对象的引用。

【讨论】:

  • 那么,你能解释一下吗?
【解决方案2】:

您的查询可以用方法语法写成这样:

var query = numbers.Where(value => value >= threshold);

或者:

Func<int, bool> predicate = delegate(value) {
    return value >= threshold;
}
IEnumerable<int> query = numbers.Where(predicate);

这些代码(包括你自己在查询语法中的查询)都是等价的。

当您像这样展开查询时,您会看到 predicate 在该方法中是 anonymous methodthresholdclosure。这意味着它将假定执行时的值。编译器将生成一个实际的(非匿名)方法来处理这个问题。该方法在声明时不会执行,而是在枚举query 时对每个项目执行(执行是延迟)。由于枚举发生在threshold 的值更改之后(并且threshold 是一个闭包),因此使用新值。

当您将numbers 设置为null 时,您将引用设置为无处,但对象仍然存在。 Where 返回的IEnumerable(并在query 中引用)仍然引用它,现在初始引用为null 并不重要。

这解释了这种行为:numbersthreshold 在延迟执行中扮演不同的角色。 numbers 是对枚举数组的引用,而threshold 是一个局部变量,其作用域“转发”到匿名方法。

扩展,第 1 部分:在枚举期间修改闭包

当您替换该行时,您可以将您的示例更进一步...

var result = query.ToList();

...与:

List<int> result = new List<int>();
foreach(int value in query) {
    threshold = 8;
    result.Add(value);
}

您正在做的是在数组迭代期间更改threshold 的值。当您第一次点击循环体时(value 为 3),您将阈值更改为 8,这意味着值 5 和 7 将被跳过,下一个要添加到列表中的值是 9。原因是threshold 的值将在每次迭代时再次评估,并且将使用 then 有效值。由于阈值已更改为 8,因此数字 5 和 7 不再评估为大于或等于。

扩展,第 2 部分:实体框架不同

为了让事情变得更复杂,当您使用 LINQ 提供程序创建与原始查询不同的查询然后执行它时,情况会略有不同。最常见的示例是实体框架 (EF) 和 LINQ2SQL(现在很大程度上被 EF 取代)。这些提供程序从枚举之前的原始查询创建一个 SQL 查询。由于这次闭包的值只被计算一次(它实际上不是一个闭包,因为编译器生成一个表达式树而不是匿名方法),枚举期间threshold 的更改对结果。这些更改发生在查询提交到数据库之后。

从中得到的教训是,您必须始终了解您使用的是哪种 LINQ,并且了解其内部工作原理是一个优势。

【讨论】:

  • 也许值得添加一些关于迭代器的内容来解释惰性执行是如何工作的(因为Where 是迭代器)。
  • @Evk:我添加了一些关于延迟执行的细节。比这更多的信息会使答案不必要地复杂化。
【解决方案3】:

变量“numbers”是查询已实例化并对其进行处理的变量。它保留设置查询时的值。当执行查询时,谓词中使用了“阈值”变量,它位于 ToList() 中。到那时,谓词会找到垃圾桶的值。

反正代码不清晰……

【讨论】:

    【解决方案4】:

    我认为理解它的最简单方法是逐行查看它并考虑执行的内容和时间,而不是仅在内存中声明。

    //this line declares numbers array
     int[] numbers = { 1, 3, 5, 7, 9 };
    
    //that one declares value of threshold and sets it to 6
     int threshold = 6;
    
    //that line declares the query which is not of the type int[] but probably IQueryable<int>, but never executes it at this point
    //To create IQueryable it still iterates through numbers variable, and kind of assign lambda function to each of the items.
     var query = from value in numbers where value >= threshold select value;
    
    //that line changes threshold value to 6
     threshold = 3;
    
    //that line executes the query defined easier, and uses current value value of threshold, as it is only reference
     var result = query.ToList();
    
     result.ForEach(Console.WriteLine);
      Console.ReadLine();
    

    该机制为您提供了一些不错的功能,例如在多个位置构建查询并在一切准备就绪时执行它。

    numbers 变量的值设置为null 不会改变结果,因为它被立即调用,用于枚举。

    【讨论】:

      【解决方案5】:

      您的 LINQ 查询不会返回所请求的数据,它返回的是获取可以逐个访问数据元素的东西的可能性。

      在软件方面:您的 LINQ 语句的值是 IEnumerable&lt;T&gt;(或 IQueryable&lt;T&gt;,此处不再讨论)。此对象不保存您的数据。 事实上,你不能用IEnumerable&lt;T&gt; 做很多事情。它唯一能做的就是生成另一个实现IEnumerator&lt;T&gt; 的对象。 (注意区别:IEnumerable vs IEnumerator)。这个 `GetEnumerator()' 函数是我第一句话中的“get something that can access ...”部分。

      您从IEnumerable&lt;T&gt;.GetEnumerator() 获得的对象实现了 IEnumerator。该对象也不必保存您的数据。它只知道如何生成数据的第一个元素(如果有的话),如果它有一个元素,它知道如何获取下一个元素(如果有的话)。这是我第一句话中的“可以逐个访问数据的元素”。

      所以IEnumerable&lt;T&gt;Enumerator&lt;T&gt; 都不需要(必须)保存您的数据。它们只是帮助您以定义的顺序访问数据的对象。

      在早期,当我们没有List&lt;T&gt; 或实现IEnumerable&lt;T&gt; 的类似集合类时,实现IEnumerable&lt;T&gt;IEnumerator&lt;T&gt; 函数ResetCurrent 是相当麻烦的和MoveNext。事实上,现在很难找到不使用同样实现IEnumerator&lt;T&gt; 的类来实现IEnumerator&lt;T&gt; 的示例。 Example

      关键字Yield 的引入大大简化了IEnumerable&lt;T&gt;IEnumerator&lt;T&gt; 的实现。如果函数包含Yield return,则返回IEnumerable&lt;T&gt;

      IEnumerable<double> GetMySpecialNumbers()
      {   // returns the sequence: 0, 1, pi and e
          yield return 0.0;
          yield return 1.0;
          yield return 4.0 * Math.Atan(1.0);
          yield return Math.Log(1.0)
      }
      

      请注意,我使用术语序列。它不是List,也不是Dictionary,只能通过请求第一个来访问元素,并反复请求下一个。

      您可以使用IEnumerable&lt;T&gt;.GetEnumerator()IEnumerator&lt;T&gt; 的三个函数访问序列的元素。这种方法已经很少使用了:

      IEnumerable<double> myNumbers = GetMySpecialNumbers();
      IEnumerator<double> enumerator = myNumbers.GetEnumerator();
      enumerator.Reset();
      
      // while there are numbers, write the next one
      while(enumerator.MoveNext())
      {   // there is still an element in the sequence
          double valueToWrite = enumerator.Current();
          Console.WriteLine(valueToWrite);
      }
      

      随着foreach 的引入,这变得容易多了:

      foreach (double valueToWrite in GetMySpecialNumbers())
          Console.WriteLine(valueToWrite);
      

      在内部这将执行 GetNumerator()Reset() / MoveNext() / Current()

      所有通用集合类,如 List、Array、Dictionary、HashTable 等,都实现了 IEnumerable。大多数情况下,函数返回一个 IEnumerable,您会发现它在内部使用这些集合类之一。

      yieldforeach 之后的另一项伟大发明是引入了扩展方法。见extension methods demystified

      扩展方法使您能够获取一个您无法更改的类,例如List&lt;T&gt;,并为它编写新功能,只使用您有权访问的功能。

      这是 LINQ 的提升。它使我们能够为以下所有内容编写新功能:“嘿,我是一个序列,你可以要求我的第一个元素和我的下一个元素”(= 我实现了 IEnumerable)。

      如果您查看source code of LINQ,,您会发现诸如 Where / Select / First / Reverse / ... 等 LINQ 函数被编写为 IEnumerable 的扩展函数。它们中的大多数使用通用集合类(HashTable、Dictionary),其中一些使用yield return,有时您甚至会看到基本的IEnumerator 函数,如Reset / MoveNext

      您通常会通过连接 LINQ 函数来编写新功能。但是请记住,有时yield 会使您的函数更易于理解,从而更易于重用、调试和维护。

      示例:假设您有一系列生成的Products。每个Product 都有一个DateTime 属性ProductCompletedTime,表示其产品生产完成的时间。

      假设您想知道两个完成的产品之间有多少时间。 问题:无法为第一个产品计算。

      有了收益,这很容易:

      public static IEnumerable<TimeSpan> ToProductionTimes<Product>
          (this IEnumerable<Product> products)
      {
          var orderedProducts = product.OrderBy(product => product.ProductionTime;
          Product previousProduct = orderedProducts.FirstOrDefault();
          foreach (Product product in orderedProducts.Skip(1))
          {
              yield return product.ProductCompletedTime - previouseProduct.ProductCompletedTime;
              previousProduct = product;
          }
      }
      

      尝试在 Linq 中执行此操作,会更难理解会发生什么。

      结论 一个 IEnumerable 不保存您的数据,它只保存一个一个访问您的数据的潜力。

      访问数据最常用的方法有foreach、ToList()、ToDictionary、First等。

      每当您需要编写一个返回困难IEnumerable&lt;T&gt; 的函数时,至少考虑编写一个yield return 函数。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2021-04-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2021-01-21
        相关资源
        最近更新 更多