LINQ

LINQ(Language Integrated Query)读作[lɪŋk],是一种用于.NET平台的查询语言集成技术,它能够允许开发人员用类似SQL结构化查询的语法来查询和操作数据源,这里的数据源包括对象集合、XML文档、数据库等。LINQ支持C#和VB.NET,C#中使用LINQ的主要目的是简化查询数据逻辑的写法,提高代码的可读性和可维护性。

下面我们直接看一个LINQ的使用例子,代码中我们查询长度为4的所有字符串。

List<string> data = new List<string> { "Tom", "Jerry", "Kate", "Lucy", "Bob" };
// 使用LINQ查询长度为4的字符串
IEnumerable<string> result = from s in data where s.Length == 4 select s;
// 输出查询结果
foreach (var s in result)
{
    Console.WriteLine(s);
}

有人可能觉得上面例子其实不用LINQ也很容易实现,毕竟迭代判断也不复杂。实际上,LINQ体现了一种结构化查询的思想,这和在关系型数据库中,我们使用SQL语言的理由是一样的。如果集合元素是个复杂的对象,而且有多个集合「关联查询」的情况,LINQ的优势就体现出来了。

LINQ相关概念

LINQ Provider

我们能够使用LINQ从某个数据源中查询数据,其实是被查询的数据源将自身实现为了LINQ提供者程序(LINQ Provider)。.NET Framework和.NET Core中提供了一些默认的LINQ Provider,常见的包括:

LINQ To Objects:最常用的LINQ Provider之一,用于从对象集合中查询数据。

LINQ To XML:用于使用LINQ语法查询和操作XML文档。

LINQ To SQL:早期用于和关系型数据库交互的LINQ Provider,允许开发者用LINQ语法来查询数据库中的数据,后来逐渐被Entity Framework(LINQ To Entities)取代,目前仍存在但已不再演进。

除此之外,我们也可以实现自定义的LINQ Provider。

方法语法 vs 查询语法

LINQ在C#中有两种主要的语法风格:方法语法和查询语法。

方法语法使用类似于函数调用的方式来执行LINQ查询,代码中我们需要使用一系列LINQ扩展方法来构建和链式组合查询操作,下面是使用方法语法查询数据的例子。

List<string> data = new List<string> { "Tom", "Jerry", "Kate", "Lucy", "Bob" };
IEnumerable<string> result = data.Where(s => s.Length == 4);

查询语法更像完整的SQL语句,我们需要在LINQ中使用from、where、select等关键字描述查询操作,下面是使用查询语法查询数据的例子。

List<string> data = new List<string> { "Tom", "Jerry", "Kate", "Lucy", "Bob" };
IEnumerable<string> result = from s in data where s.Length == 4 select s;

上面例子中,方法语法和查询语法是等效的,无论使用哪种风格都可以达到相同的查询目的。实际上,查询语法只是编译器提供的一种语法糖,它会被编译成对应的方法调用(扩展方法Where、Select等)。方法语法通常更紧凑,适合链式组合多个操作;而查询语法更类似SQL语句,对于join、let等较复杂的查询往往可读性更好。实际开发中,我们可以根据实际情况进行选择,甚至在同一条查询中混合使用两种语法。

比如下面这个链式操作,用方法语法表达就非常自然。

List<int> data = new List<int> { 5, 2, 8, 1, 9, 3 };
IEnumerable<int> result = data
    .Where(n => n > 2)
    .OrderByDescending(n => n)
    .Select(n => n * 10);

延迟执行

延迟执行是LINQ最重要的特性之一,也是初学者最容易踩坑的地方:LINQ查询默认是延迟执行的,当我们编写一条查询语句时,它并不会立刻执行,只有在真正迭代结果时才会被执行!

List<int> data = new List<int> { 1, 2, 3, 4, 5 };
// 此时查询并未执行,result只是保存了“查询逻辑”
IEnumerable<int> result = data.Where(n => n > 3);
// 修改数据源
data.Add(6);
// foreach迭代时才真正执行查询,因此也能看到后加的6
foreach (int n in result)
{
    Console.WriteLine(n); // 输出 4 5 6
}

延迟执行带来两个需要注意的后果:

  1. 每次迭代都会重新执行查询:如果对同一个查询结果迭代两次,那么查询逻辑会被执行两次。
  2. 查询会感知数据源和外部变量的后续变化:由于查询在被枚举时才求值,捕获的变量如果在枚举前被修改,会影响最终结果。
int min = 3;
IEnumerable<int> result = data.Where(n => n > min);
min = 5; // 由于延迟执行,此处修改会影响查询结果

如果希望查询“立即执行”并把结果固定下来,可以使用ToList()、ToArray()、ToDictionary()等方法,或者Count()、First()这类聚合方法,它们会触发查询并返回具体结果。

// ToList()会立即执行查询,后续再修改数据源也不会影响result
List<int> result = data.Where(n => n > 3).ToList();

LINQ查询结果

LINQ查询可能返回不同类型的查询结果,这取决于具体的查询操作和想要呈现结果的方式(投影)。

IEnumerable:这是LINQ查询最常见的结果类型,表示包含查询结果的可迭代集合。下面例子代码查询了Age属性等于18的对象集合。

List<Student> data = new List<Student>
{
    new Student { Name = "Tom", Age = 18 },
    new Student { Name = "Lucy", Age = 17 },
    new Student { Name = "Bob", Age = 18 }
};
IEnumerable<Student> result = from s in data where s.Age == 18 select s;

匿名类型:LINQ查询可返回匿名类型,这是一种临时的没有明确类名的类型,通常用于临时投影查询结果。

List<Student> data = new List<Student>
{
    new Student { Name = "Tom", Age = 18 },
    new Student { Name = "Lucy", Age = 17 },
    new Student { Name = "Bob", Age = 18 }
};
var result = from s in data where s.Age == 18 select new { Name = s.Name, Age = s.Age };

注意:匿名类型变量由于没有具体的类型名,我们通常使用var声明。

分组结果:使用groupby子句或GroupBy方法时,返回的是IGrouping<TKey, TElement>的集合,每个分组都带有一个Key属性表示分组依据。

单个值:对于一些聚合查询或是查询单个元素等情况,LINQ可能会返回单个值而不是集合。

List<Student> data = new List<Student>
{
    new Student { Name = "Tom", Age = 18 },
    new Student { Name = "Lucy", Age = 17 },
    new Student { Name = "Bob", Age = 18 }
};
double avg = data.Average(s => s.Age);

LINQ查询表达式

查询语法由若干子句(clause)组成,一条查询表达式必须以from子句开头,并以select或groupby子句结尾。

from子句

from子句指定了作为数据源使用的数据集合和迭代变量。

from Type d in data

其中的Type类型可以省略不写,实际开发中一般都将其省略。from子句可以有任意个,等价为多层循环,下面是一个例子。

List<int> data1 = new List<int> { 1, 2, 3, 4, 5, 6, 7 };
List<int> data2 = new List<int> { 2, 4, 6, 8, 10 };
var result = from d1 in data1
    from d2 in data2
    where d1 > 3 && d2 > 6
    select new { D1 = d1, D2 = d2 };

上面代码的计算结果为:

{ D1 = 4, D2 = 8 }
{ D1 = 4, D2 = 10 }
{ D1 = 5, D2 = 8 }
{ D1 = 5, D2 = 10 }
{ D1 = 6, D2 = 8 }
{ D1 = 6, D2 = 10 }
{ D1 = 7, D2 = 8 }
{ D1 = 7, D2 = 10 }

where子句

where子句需要指定筛选条件,LINQ根据该条件来筛选结果数据。where子句可以有多个,它们是AND关系,然而我们一般不会这样写,而是在一个where子句中使用&&连接多个条件。

上面代码中我们已经使用过很多次where子句了,这里就不单独编写例子了。

orderby子句

orderby子句用于对查询结果进行排序。

List<int> data = new List<int> { 7, 6, 5, 4, 3, 2, 1 };
IEnumerable<int> result = from d in data orderby d ascending select d;

orderby子句中我们可以使用ascending和descending指定升序或降序。orderby子句中还可以写多个排序键,用逗号分隔,等价于先按前面的键排序、相等时再按后面的键排序,例如orderby d.Age descending, d.Name ascending。

select子句

select子句用于指定查询结果的最终形态。它可以原样返回元素,也可以通过匿名类型等方式投影出新的结果,这一点在前面的例子中已经反复出现过了。

// 原样返回
IEnumerable<Student> result1 = from s in students select s;
// 投影为匿名类型
var result2 = from s in students select new { s.Name, s.Age };

let子句

let子句接收一个运算表达式并将其赋值给一个标识符,它相当于在查询中定义了一个临时变量,便于后续子句重复使用,下面是一个例子。

List<int> data1 = new List<int> { 1, 2, 3, 4, 5, 6, 7 };
List<int> data2 = new List<int> { 2, 4, 6, 8, 10 };
IEnumerable<int> result = from d1 in data1
    from d2 in data2
    let sum = d1 + d2
    where sum > 5
    select sum;

join子句

join子句用于连接两个集合创建一个新的集合,from子句后可以有任意个join子句来生成连接集合。

from d1 in data1
join d2 in data2 on d1.Key equals d2.Key

类似from子句,其中的类型名都可以省略不写。注意join的连接条件使用equals关键字连接两个键,而不是==。

List<Student> students = new List<Student>
{
    new Student { Name = "Tom", Age = 18, CourseId = 1 },
    new Student { Name = "Lucy", Age = 17, CourseId = 1 },
    new Student { Name = "Bob", Age = 18, CourseId = 2 }
};
List<Course> courses = new List<Course>()
{
    new Course() { CourseId = 1, Name = "数学" },
    new Course() { CourseId = 2, Name = "语文" }
};
IEnumerable<Student> result = from student in students
    join course in courses on student.CourseId equals course.CourseId
    where course.Name == "数学"
    select student;

上面代码会查出选修了「数学」这门课的学生,也就是Tom和Lucy。这里join默认是内连接(inner join),只会保留两个集合中键能匹配上的元素。

groupby子句

groupby子句用于对查询结果进行分组,它可以看作一个包含分组功能的select子句。

List<Student> students = new List<Student>
{
    new Student { Name = "Tom", Age = 18, CourseId = 1 },
    new Student { Name = "Lucy", Age = 17, CourseId = 1 },
    new Student { Name = "Bob", Age = 18, CourseId = 2 }
};
IEnumerable<IGrouping<int, Student>> result = from student in students
    group student by student.CourseId;
foreach (var group in result)
{
    foreach (var d in group)
    {
        Console.WriteLine("{0} {1} {2}", group.Key, d.Name, d.Age);
    }
}

上面代码的计算结果为如下。

1 Tom 18
1 Lucy 17
2 Bob 18

分组结果的每一项都是IGrouping<TKey, TElement>类型,其中Key属性就是分组的依据(这里是CourseId),分组本身又是一个可迭代的集合。

into子句

into子句用于把select、groupby或join等子句的结果再作为一个新的数据源,继续编写后续查询子句,实现查询的“接力”。最常见的用法是对分组结果继续进行筛选或排序。

// 分组后继续筛选:只保留人数大于1的课程分组
var result = from student in students
    group student by student.CourseId into g
    where g.Count() > 1
    select new { CourseId = g.Key, Count = g.Count() };

常用方法

实际开发中,方法语法使用得其实更加频繁,这里整理一些最常用的LINQ方法,它们大多也都有对应的查询语法子句。

方法 说明
Where 按条件筛选元素
Select 投影元素,将其转换为新形态
SelectMany 将每个元素的集合“拍平”为一个序列
OrderBy / OrderByDescending 升序 / 降序排序
ThenBy / ThenByDescending 在前一个排序基础上进行次级排序
GroupBy 按指定键分组
Join 按指定键连接两个集合
Any / All 判断是否存在 / 是否全部满足条件
Contains 判断是否包含某个元素
Count / LongCount 计数
Sum / Average / Min / Max 聚合求和 / 求平均 / 求最值
First / FirstOrDefault 取第一个元素 / 取第一个元素(无则返回默认值)
Single / SingleOrDefault 取唯一元素(多于一个会抛异常)
Last / LastOrDefault 取最后一个元素
Distinct 去重
Take / Skip 取前N个 / 跳过前N个
Union / Intersect / Except 并集 / 交集 / 差集
Concat 连接两个序列
Reverse 反转序列
ToList / ToArray / ToDictionary 将结果转为List / 数组 / 字典(会立即执行查询)

注意其中First、Single、Last等带OrDefault后缀的版本在找不到元素时返回类型的默认值(引用类型为null),不带后缀的版本则会抛出异常,使用时需要根据场景选择。

下面这个组合查询例子代码中,它先筛选、再排序,最后取前两条。

List<int> data = new List<int> { 5, 2, 8, 1, 9, 3 };
List<int> result = data
    .Where(n => n > 2)
    .OrderByDescending(n => n)
    .Take(2)
    .ToList();

小结

LINQ为.NET提供了一套统一的、声明式的数据查询能力,无论数据源是内存集合、XML还是数据库,我们都可以用相似的语法进行查询和转换。使用时需要重点关注两点:一是方法语法与查询语法的选择(两者可互相转换),二是延迟执行带来的“何时真正执行”问题。掌握常用的LINQ方法后,我们可以大幅减少繁琐的循环和筛选代码,达到让代码更紧凑、可读性更高的目的。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。