一、什么是LINQ
LINQ是Language-Integrated Query的缩写,它可以视为一组语言和框架特性的集合。LINQ可以对本地对象集合或远程数据源进行结构化的类型安全的查询操作。LINQ支持查询任何实现了IEnumerable<T>
接口的集合类型,无论是数组、列表还是XML DOM,乃至SQL Server数据库中的数据表这种远程数据源都可以查询。LINQ具有编译时类型检查和动态查询组合这两大优点。
二、LINQ的基本用法
2.1 流式语法
流式语法是编写LINQ表达式的最基础同时也是最灵活的方式。它允许我们使用查询运算符链构造更复杂的查询。
首先来介绍几个最常见的查询运算符Where
,OrderBy
,Select
。Where
会根据输入的条件筛选序列;OrderBy
运算符根据输入的序列生成一个排序后的版本;Select
将输入序列中的每一个元素按给定的Lambda表达式进行转换或映射。
string[] names = {"Tom","John","Mary","LiHua","ZhangSan"};
IEnumerable<string> res = names
.Where(e => e.Contains("o"))
.OrderBy(e => e.Length)
.Select(e=>e.ToUpper());
foreach (var item in res)
{
Console.WriteLine(item);
}
// 输出结果:
// TOM
// JOHN
这些查询运算符实际上是在Enumerable
类中对IEnumerable<>
的扩展方法(以Where
为例)。通过接收一个委托来进行元素的筛选。
public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
需要注意的是:查询运算符绝不会修改输入序列,相反,它会返回一个新序列。这种设计是符合函数式编程规范的,而LINQ就是起源自函数式编程。
下面再介绍一些其他的查询运算符
Take
运算符将输出前x个元素,而丢弃其他元素
// Take运算符
var res1 = names.Take(3);
foreach (var item in res1)
{
Console.WriteLine(item);
}
// 输出结果:
// Tom
// John
// Mary
Skip
运算符会跳过集合中的前x个元素而输出剩余的元素
// Skip运算符
var res2 = names.Skip(3);
foreach (var item in res2)
{
Console.WriteLine(item);
}
// 输出结果:
// LiHua
// ZhangSan
Reverse
运算符会将集合中的所有元素反转
// Reverse运算符
var res3 = names.Reverse();
foreach (var item in res3)
{
Console.WriteLine(item);
}
// 输出结果:
// ZhangSan
// LiHua
// Mary
// John
// Tom
First
、Last
、ElementAt
可以返回对应位置的元素
var item1 = names.First(); // Tom
var item2 = names.Last(); // ZhangSan
var item3 = names.ElementAt(3); // LiHua
Count
可以返回元素总数,Min
和Max
可以分别返回最小值和最大值
var count = names.Count();// 5
var min = names.Min();// John
var max = names.Max();// ZhangSan
Contains
可以返回序列是否包含某个元素,Any
可以根据条件判断序列是否存在指定元素
var isContains = names.Contains("ZhangSan"); // True
var isAny = names.Any(e => e.Equals("John")); // True
Concat
运算符会将一个输入序列附加到另一个序列后面,Union
运算符除了附加之外还会去掉其中重复的元素
int[] seq1 = {1, 2, 3};
int[] seq2 = {3, 4, 5};
var seq3 = seq1.Concat(seq2);// 1,2,3,3,4,5
var seq4 = seq1.Union(seq2);// 1,2,3,4,5
2.2 查询表达式
C#为LINQ查询提供了一种简化的语法结构,称为查询表达式。这种语法就像是在C#中内嵌SQL。
下面将前面讲过的常见查询运算符转换为查询表达式形式
IEnumerable<string> query =
from name in names
where name.Contains("o")
orderby name.Length
select name.ToUpper();
foreach (var item in query)
{
Console.WriteLine(item);
}
// 输出结果:
// TOM
// JOHN
其中from
子句的作用是声明范围变量,和foreach
很像。这一点与SQL语句有所不同。LINQ中,变量必须在声明后才能使用,而SQL中SELECT
子句可以在FROM
子句定义之前直接引用表的别名。
三、延迟执行
大部分查询运算符的一个重要性质是它们并非在构造时执行,而是在枚举(即在枚举器上调用MoveNext)时执行。
比如下面这个例子:
var numbers = new List<int>() {1, 2, 3};
var res = numbers.Select(e => e * 10);
numbers.Add(4);
foreach (var item in res)
{
Console.Write(item+" ");
}
// 输出结果:
// 10 20 30 40
从直觉上讲,这段代码的输出结果应该是10 20 30
,毕竟numbers
数组是在查询结束后才发生的改变。但事实并非如此。在查询语句创建结束后,向列表中新添加的数字也出现在了查询结果中。这是因为查询逻辑只有在foreach
执行时才会生效,即延迟执行。
几乎所有的标准查询运算符都具有延迟执行的能力,但以下运算符除外:
- 返回单个元素或标量值的运算符,例如
First
或Count
。 - 转换运算符:
ToArray
、ToList
、ToDictionary
、ToLookup
、ToHashSet
。
以上这些运算符都会立即执行并返回结果。
延迟执行是一个很重要的特性。因为它将查询的创建和查询的执行进行了解耦。这使得查询可以分多个步骤进行创建,尤其适用于创建数据库查询。
3.1 重复执行
延迟执行也带来了一些问题,比如重复执行。当重复枚举时,延迟执行的查询也会重复执行。比如下面这个例子:
var numbers = new List<int>() {1, 2, 3};
var res = numbers.Select(e => e * 10);
numbers.Add(4);
foreach (var item in res)
{
Console.Write(item+" ");
}
// 输出结果:
// 10 20 30 40
Console.WriteLine("");
numbers.Add(5);
foreach (var item in res)
{
Console.Write(item+" ");
}
// 输出结果:
// 10 20 30 40 50
可以看到,虽然第一次枚举时,查询已经执行了。但改变数组后再次枚举,查询执行的结果也会随之改变。也就是说,即便查询执行后也不会缓存此时执行的结果。每进行一次枚举,都会重复执行查询。对于一些计算密集型查询(或依赖远程数据库的查询),这会带来不必要的浪费。
要避免重复执行,可以通过ToList
或ToArray
缓存执行结果。
3.2 捕获变量
延迟执行的另一个问题是,如果Lambda表达式捕获了外部变量,那么该变量也会等到执行时决定。
var numbers = new List<int>() {1, 2, 3};
int factor = 10;
var res = numbers.Select(e => e * factor);
factor = 20;
foreach (var item in res)
{
Console.Write(item+" ");
}
// 输出结果:
// 20 40 60
3.3 延迟执行原理
LINQ查询实际上使用了装饰模式。查询运算符通过返回装饰器序列来提供延迟执行的功能。装饰器序列不同于一般的集合类(如数组或链表),它(一般)并没有存储元素的后台结构。而是包装了一个在运行时才会生成的序列,并永久维护其依赖关系。当向装饰器序列请求数据时,它就不得不向被包装的输入序列请求数据。如下图中,只有当枚举lessThanTen时,才开始真正通过Where装饰器对数组执行查询。
当采用查询运算符链时,生成的装饰器将层层嵌套,像套娃一样
四、解释型查询与本地查询
LINQ提供了两种平行的架构:针对本地对象集合的本地查询,以及针对远程数据源的解释型查询。前面我们介绍的都是本地查询的架构。
本地查询主要针对实现了IEnumerable<T>
的集合类型进行操作。本地查询会(默认)使用Enumerable
类中的查询运算符,进而生成链式装饰器序列。其接受的委托(不论是使用查询语法、流式语法,还是通常的委托)都会完全编译为中间语言代码。
解释型查询是描述性的。它操作的序列实现了IQuerable<T>
接口,并且其查询运算符是定义在Queryable
类中的。它们会在运行时生成表达式树,并进行解释。这些表达式树可以转换为其他语言,例如它可以转换为SQL查询,这样就可以使用LINQ查询数据库了。
4.1 解释型查询工作机制
下面是一段使用EF Core编写一个解释型LINQ查询从数据库中找到所有名字中含有字母“a”的客户的代码
NutshellContext
类的定义如下
首先编译器会将上面的查询语法转换为流式语法(与本地查询一致)
其次,编译器会进一步解析上述查询中的运算符方法,而这也是本地查询和解释型查询的区别所在,解释型查询会解析为Queryable
类中的方法,而本地查询会解析为Enumerable
类中的方法。而上面的代码之所以会解析为Queryable
类中的方法,是因为dbContext.Customers
是一个类型为DbSet<T>
的变量,它实现了IQueryable<T>
接口。
接下来编译器将根据Queryable.Where
的参数将Lambda表达式n=>n.Name.Contains("a")
转换为表达式树(OrderBy
和Select
同理)。EF Core再将表达式树延迟转换为SQL语句。
需要注意的是,不同于本地查询那种层层嵌套的执行方式,解释型查询会遍历所有表达式并直接生成一个独立的清单(SQL语句),并在执行后将结果返回消费者。相当于只有一层装饰器。
五、参考资料
[1].《C#8.0核心技术指南》