LINQ 第 4 部分:深入了解 Queryable 扩展方法






4.80/5 (6投票s)
LINQ 系列的第 4 部分,对比了 System.Linq.Enumerable 和 System.Linq.Queryable 扩展方法,并探讨了表达式树是如何被创建和消费的。
引言
在上一篇文章中,我们介绍了 IQueryable
。那篇文章主要集中于向读者介绍一些基本概念。
System.Linq.Queryable
中的扩展方法通过构建表达式树 (Expression
) 来操作IQueryable
实例。IQueryable
仅仅是将表达式树 (Expression
) 与查询提供者 (IQueryProvider
) 配对。- 查询提供者 (
IQueryProvider
) 负责解释表达式树、执行查询并获取结果。 - 查询提供者 (
IQueryProvider
) 可能会限制表达式树中可以出现的内容。
为了解释这一点,我们提供了一些表达式树的示例。本文的意图是故意模糊表达式树的创建过程。
本文将深入探讨这个主题。它将清晰地对比 System.Linq.Enumerable
和 System.Linq.Queryable
中的扩展方法。它将展示一些简单的表达式树是如何被创建和消费的。
背景
由于本文篇幅较短,我最初考虑将其包含在 LINQ 第 3 部分:IQueryable 简介 中。然而,我担心这些额外的细节可能会分散读者对该文章基本概念的注意力。本文假设您已阅读上一篇文章。
这是 LINQ 系列文章的第四篇。本系列其他文章的链接如下:
- LINQ 第 1 部分:深入了解 IEnumerable
- LINQ 第 2 部分:标准方法 - 工具箱中的工具
- LINQ 第 3 部分:IQueryable 简介
- LINQ 第 4 部分:深入了解 Queryable 扩展方法
准备工作
在本文附带的源代码中,我们检查了 Where
方法的两种不同实现。为此,我们首先创建了 IEnumerable
和 IQueryable
实例。
var things = new[] { "Red Apple", "Green Apple", "Red Balloon", "Green Balloon" };
var enumerable = things.AsEnumerable();
var queryable = things.AsQueryable();
为了让大家更仔细地了解其功能,源代码包含一个近似等同于标准 System.Linq.Enumerable.Where
和 System.Linq.Queryable.Where
方法的实现:MyEnumerable.MyWhere
和 MyQueryable.MyWhere
。
从语法上看,对这两个方法的调用是相同的。
enumerable = enumerable.MyWhere(item => item.StartsWith("Red"));
queryable = queryable.MyWhere(item => item.StartsWith("Red"));
然而,它们会导致两个完全不同的方法调用。由于 enumerable
是 IEnumerable
的实例,它将调用 MyEnumerable.MyWhere
方法。
相比之下,queryable
是 IQueryable
的实例,它将调用 MyQueryable.MyWhere
方法。
编译器的帮助
我们要注意的第一个令人惊讶之处是,这两个方法对于谓词的参数类型完全不同。
IEnumerable<T> MyWhere<T>(this IEnumerable<T> source, Func<T, bool> predicate);
IQueryable<T> MyWhere<T>(this IQueryable<T> source, Expression<Func<T, bool>> predicate);
我们如何能够(表面上)为两种不同的参数类型传递完全相同的值? 简短的回答是:我们不能。
方法签名中的这种细微差别实际上会触发编译器中两种截然不同的行为。
在第一种情况 (Func<T, bool>
) 下,编译器仅为谓词方法构造一个委托。这就是传递给 MyEnumerable.MyWhere
方法的内容。
在第二种情况 (Expression<Func<T, bool>>
) 下,编译器会代表您构建一个完整的表达式树,并将此表达式树作为参数传递给 MyQueryable.MyWhere
方法。 从视觉上看,它构建(并传递)的表达式树如下所示:
名称相同 - 方法截然不同
我们有两个截然不同的 MyWhere
方法实现:MyEnumerable.MyWhere
和 MyQueryable.MyWhere
。 让我们快速看一下它们是如何工作的。
MyEnumerable.MyWhere
在 MyEnumerable.MyWhere
中,我们直接操作原始的 IEnumerable
。这里的逻辑非常简单。
我们创建一个新的 IEnumerable
实例,其中仅包含原始 IEnumerable
中满足谓词条件的项。标准的 Enumerable.Where
方法提供了类似的功能,但也包含了一些性能优化。
public static IEnumerable<T> MyWhere<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
if (source == null)
throw new ArgumentNullException(nameof(source));
if (predicate == null)
throw new ArgumentNullException(nameof(predicate));
// Loop and simply yield each item where the predicate is true...
foreach (T item in source)
if (predicate(item))
yield return item;
}
MyQueryable.MyWhere
在 MyQueryable.MyWhere
中,我们做了一些截然不同的事情。我们创建一个新的 IQueryable
实例,其中包含对原始表达式树(作为参数传入)的略微修改版本。
主要地,我们将原始表达式包装在一个 Method
CallExpression
中,用于标准的 Queryable.Where
方法。 标准的 Queryable.Where
方法通过将原始表达式包装在一个自引用的 Method
CallExpression
提供类似功能。
public static IQueryable<T> MyWhere<T>(this IQueryable<T> source, Expression<Func<T, bool>> predicate)
{
if (source == null)
throw new ArgumentNullException(nameof(source));
if (predicate == null)
throw new ArgumentNullException(nameof(predicate));
// Get the method information for the true Queryable.Where method
MethodInfo whereMethodInfo = GetMethodInfo<T>((s, p) => Queryable.Where(s, p));
// Create arguments for a call to the true Queryable.Where method. Note that
// we quote the Lambda expression for the predicate, which seems to be necessary
// (not certain why).
var callArguments = new[] { source.Expression, Expression.Quote(predicate) };
// Create an expression that calls the true Queryable.Where method
var callToWhere = Expression.Call(null, whereMethodInfo, callArguments);
// Return the new query that wraps the original expression in a call to the Queryable.Where method
return source.Provider.CreateQuery<T>(callToWhere);
}
新的表达式树在视觉上如下所示:
并非真正的函数调用
一位敏锐的观察者可能会想,为什么 Queryable.Where
方法会将表达式包装在自引用的 MethodExpressionCall
中。这似乎会导致无限递归/堆栈溢出。
在这种行为上,Queryable.Where
远非孤例。Queryable
类中的大多数扩展方法都有类似的实现。
那么,这里发生了什么?为什么我们没有看到无限递归/堆栈溢出?
事实上,任何一个有价值的 IQueryProvider
都永远不会真正调用这些方法。将这些 MethodCallExpression
节点视为意图的广告。
通过引用 Queryable.Where
方法的 MethodExpressionCall
节点,我们只是告知查询提供者,它应该将结果限制在符合谓词条件的那些项。我们不期望它会真正调用此方法。
在迭代时,查询提供者 (IQueryProvider
) 负责解释表达式树、执行查询并获取结果。
正如本代码所示,编写自己的 IQueryable
扩展方法是可能的。但是,为了确保任何 IQueryProvider
都能解释您的表达式树,限制其内容为标准方法创建的表达式树中可能出现的内容非常重要。
让我们考虑两个常见的查询提供者:LINQ to Objects 和 LINQ to SQL。
LINQ to Objects (System.Linq.EnumerableQuery)
在我们的示例中,我们通过调用 Queryable.AsQueryable
方法来创建 IQueryable
。这会创建一个 System.Linq.EnumerableQuery
实例。此类实际上只是 LINQ to Objects 扩展方法(在 System.Linq.Enumerable
中)的一个轻量级包装。
注意:Queryable.EnumerableQuery
类同时实现了 IQueryable
和 IQueryProvider
。因此,它在这里身兼二职:既是可查询对象,也是查询提供者。
当我们开始迭代此可查询对象时,我们会调用其 IQueryable<T>.GetEnumerator
方法。此方法在编译和执行之前会重写表达式树。
除其他更改外,它会在树中查找所有引用 System.Linq.Queryable
类中声明的方法的 MethodCallExpression
节点。然后,它会用引用 System.Linq.Enumerable
类中等效方法的 MethodCallExpression
节点替换这些节点。
因此,在我们的示例中,Queryable.Where
变成了 Enumerable.Where
。
我们实际上并没有调用原始表达式树中引用的 Queryable.Where
方法。相反,我们调用的是重写树中引用的等效 Enumerable.Where
方法。
LINQ to SQL (System.Data.Linq.Table)
在 LINQ to SQL 中,会发生一些截然不同的事情。我们创建了 System.Data.Linq.Table
类的一个实例。此类非常复杂。
注意:System.Data.Linq.Table 同时充当 LINQ to SQL 的 IQueryable
和 IQueryProvider
。
当我们开始迭代此可查询对象时,会发生大量操作。为了解释它,我们将省略许多细节,而是从高层次讨论。
基本上,此提供程序会访问表达式树中的每个节点,以便创建完整的 SQL 命令文本(例如 SELECT * FROM Items WHERE Color = 'Red'
)。
在这种情况下,表达式树可能包含 Queryable.Where
方法的 MethodCallExpression
。此方法调用将被转换为 SQL WHERE
子句的文本(例如 WHERE Color = 'Red'
)。
同样,我们实际上并没有调用原始表达式树中引用的 Queryable.Where
方法。相反,该 MethodCallExpression
仅提供格式化为文本的信息。
当完整的 SQL 命令格式化后,查询提供者将使用 ADO 的 DbConnection
/ DbCommand
来执行它。然后,它返回一个 IEnumerator
,该枚举器迭代结果集。
在迭代过程中,它会创建适当的实例并将实例属性设置为相应的值。
当然,这是过度简化。尽管如此,它确实为这个复杂提供者提供了一些高级功能。
名称相同 - 结果相同
在我们的示例源代码中,当我们迭代 enumerable
或 queryable
时,我们得到相同的结果。
WriteItems(enumerable);
// Red Apple, Red Balloon
WriteItems(queryable);
// Red Apple, Red Balloon
为了证明我们确实在处理表达式树,在 queryable
的情况下,我们可以简单地检查其 Expression
属性。注意:源代码包含 System.Linq.Expressions.ExpressionVisitor
的一个简单派生类(用于将表达式树转储到控制台)。
WriteExpression(queryable.Expression); // Call Where // Constant "System.String[]" // Quote // Lambda // Call StartsWith // Parameter item // Constant "Red" // Parameter item
摘要
从本文中,我们现在应该了解 System.Linq.Queryable
类中的大多数方法如下:
- C# 编译器实际上做了很多工作。它从 Lambda 表达式创建表达式树。然后将这些表达式树作为参数传递给相应的方法。
- 大多数方法只是将原始表达式树包装在一个自引用的
MethodExpressionCall
中。然后,它们创建一个新的IQueryable
实例来引用生成的表达式树。 - 创建的表达式树中的自引用的
MethodExpressionCall
永远不会被真正调用。相反,它仅仅向查询提供者 (IQueryProvider
) 宣告意图。 - 查询提供者根据此宣告的意图采取行动。在某些情况下,它可能会将意图转换为等效的方法调用(例如
Enumerable.Where
)。在其他情况下,它可能会将意图转换为某种查询语言的文本(例如 SQL)。
历史
- 2018/4/25 - 上传了原始版本