LINQ,学了会,会了忘,忘了学,学了又会,会了又忘……

受不了了!今天这篇文章就来讲解一下 C# 中的 LINQ,先入门,然后带你从代码内涵上,帮助记忆 LINQ 语法。

标准 LINQ 的使用前提

在 C# 的世界里,有许许多多各式各样的对象,也有这些对象的集合,比如我们熟悉的 List<T>

LINQ 就是为了对这些集合类型的数据进行查询和处理而生的。它的全名叫做 Language-Integrated Query,语言中集成的查询,很好的名字不是吗?

在正式开始查询之前,我们先来说说什么情况下能用。

动动手,打开 VS 创建一个 List 实例,把光标放在这个类型上面,然后按下 F12 导航到定义(反编译源码)。仔细观察这种“集合”类型的对象实现了哪些接口:

1
public class List<T> : ICollection<T>, IEnumerable<T>, IEnumerable, IList<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, ICollection, IList

嗯,有个非常有趣的东西 IEnumerable<T>

你一定知道 I 表示它是个接口,后面尖括号里面的 T 表示泛型,现在我们来看看它的命名:

Enumerable,可枚举的。字面意思理解,就是这个类里面有一堆东西,你可以一个一个遍历枚举出来(记得 foreach 循环吗?我们经常使用这种枚举来执行 List 操作)。

而标准 LINQ 的使用前提就是类实现了该接口(或能够隐式转换),很符合直觉,如果你这个类都无法枚举,还怎么查询数据呢?

在本文的后半,会介绍怎么自己实现这个接口,不过现在我们先记住这个前提,然后来用一下 LINQ 感受一下吧!

快速开始查询数据

要开始使用 LINQ,只需一行 using 即可,此时你会发现,对于实现了IEnumerable 的集合来说,可用的方法增加了!

1
2
3
4
5
6
7
8
9
10
11
12
using System.Linq;
...
List<int> numbers = new List<int>() { 1, 114, 514, 233, 322, 44432, 23232 };
var result = numbers.Take(2);
// 1
// 114
foreach (var number in result)
{
Console.WriteLine(number);
}
// 23232
Console.WriteLine(numbers.Last());

上面的代码,用了 Take Last 这两个 LINQ 方法,顾名思义,take 取出头部的几个元素,last 取得最后单个元素。

当然 LINQ 的强大之处是使用方法串链,一次完成 2 步查询:

1
2
var result = numbers.Take(2).Last();
Console.WriteLine(result); // 114

先取出前两个,再取出结果中最后一个!

怎么样,是不是已经有点查询的样子了?你可以自己去看一下 IDE 的智能提示,里面还有许多查询方法可用。

声明式查询

基本声明式查询

但是等等!LINQ 不仅支持通过方法使用(其实这叫做扩展方法,但并不在这篇文章的讨论范围内),还支持声明式查询,这是另一种更加清晰可感的形式

什么意思呢?来看看下面的代码:

1
2
3
4
5
6
7
8
9
List<int> numbers = new List<int>() { 1, 114, 514, 233, 322, 44432, 23232 };
var result = from num in numbers
where num > 500
orderby num
select num + 1;
foreach (var item in result)
{
Console.WriteLine(item);
}

把上面的代码键入到 Main 方法尝试一下吧!先根据含义猜测一下会输出什么。

1
2
3
515
23233
44433

猜对了吗?这就是一个非常简单的 LINQ 声明式查询示例,这里的查询没有一点调用方法的样子!

现在我们来仔细拆解一下这到底是怎么工作的。我们可以把整个查询拆开成几部分(称为子句):

1
from num in numbers

先看这一部分。from 子句(数据从哪里来?)首先从要查询的对象里面“取”出数据,存入变量。in (从什么里面取?)指定查询的对象,然后存入 from 后面的变量。这个 num 是一个范围变量,有一个范围,里面是多个 int,迭代时会处理每一个变量

1
where num > 500

再看这个 where 子句。它的含义就非常清晰好记:过滤出大于 500 的数据。

1
orderby num

再看 orderby 子句。这个子句会对列表内的数据从低到高排序。因为 num 是一个 int 类型,所以可以直接排序。

1
select num + 1;

最后是 select。select 会生成最后添加到列表的数据。这里的含义是,把经过前面子句处理的列表中,每个数据加上 1。

虽然整个查询看起来你都知道在说什么,但是我们还得深入挖一挖,这样你才能写自己的 LINQ 声明式查询。

首先要明确一点,声明式查询语句,是在声明一种对应关系。当查询执行的时候,C# 会遍历 from 子句中 in 后面的列表,然后对每一个元素执行你的查询。

我们可以这么理解查询的过程(这只是帮助你理解,不代表 C# 真正的运行逻辑):

  • 首先,from num in numbers 遍历取出 numbers 的每一个元素生成临时列表。然后,声明一下,以后每次遍历都创建叫做 num 的变量(也就是令下面的语句,都以 num 来自 numbers 作为前提)
  • 然后,where num > 500 过滤出大于 500 的数据。刚才提到遍历的变量叫做 num,因此类似 foreach 的循环遍历检查刚才的列表,过滤出结果。
  • 过滤后的元素列表来到 orderby num。由于是 int 类型,默认数字从低到高排序。
  • 然后,select num + 1;,遍历刚才排序后的列表中的每一个元素,全部 +1。

where orderby select 都很清晰啊,就是这个 from 也太难记忆了吧!怎么办!?

其实,我们得理解它语义上的含义,这样才好记。

from,从……来。in,在……里面。from 表示的就是一种从哪里来,到哪里去的映射关系。也就是说,告诉 LINQ:这个查询使用 numbers 集合作为数据来源,遍历时各个成员都要用 num 表示。

分组查询 group

既然我们已经知道了查询的基础方法,现在可以来点更加深入的东西——比如,LINQ 对查询的结果可以进行分组。先来看看下面的一个 enum 和一个 User 类。

1
2
3
4
5
6
7
8
9
10
enum Status
{
Offline,
Online
}
class User
{
public int Id { get; set; }
public Status Status { get; set; }
}

然后,让我们构造一些数据来查询:

1
2
3
4
5
6
7
8
9
10
User[] users = new User[]
{
new User { Id = 1, Status = Status.Online },
new User { Id = 2, Status = Status.Online },
new User { Id = 3, Status = Status.Offline },
new User { Id = 4, Status = Status.Online },
};
var result=from user in users orderby user.Id
group user by user.Status into userGroup
select userGroup;

查询的第一行你肯定已经明白了,就是按照 ID 从小到大排序。

现在我们来拆解这个分组。首先,我们必须明白:这个 var,到底是什么类型呢?

把鼠标悬浮在上面,IDE 已经给出了答案:

1
2
...IEnumerable<out T>
T 是 IGrouping<Status, User>

也就是,分组结果是个 IEnumerable,一个内含多个 IGrouping 的可迭代列表。那么问题来了,里面的元素,IGrouping 又是什么?

按住 Ctrl 点一下,转到定义:

1
2
3
4
5
// Represents a collection of objects that have a common key.
public interface IGrouping<out TKey, out TElement> : IEnumerable<TElement>, IEnumerable
{
TKey Key { get; }
}

它又是实现了 IEnumerable 的一组数据,里面有一个 Key。

也就是说,在我们的情境下,IGrouping 是一个分组,里面包含多个 User 元素,这些 User 元素有相同的 Key(也就是相同的 Status,是分组的依据)。明白了这一点,下面的查询就不难理解了:

1
group user by user.Status into userGroup

这个子句的含义是:

  • 把 user 对应的列表根据 Status 分组,相同 Status 的 User 分到一组
  • 每个 IGrouping 表示一个分组,包含多个 User 元素
  • Status 作为每个 IGrouping 分组的 Key(by 后面的就是 key)
  • 最后把这些 IGrouping 分组塞到一个新的集合中,叫做 userGroup。

看看下面的图片吧,瞬间秒懂。

示意图

这下就简单了!我们可以用两层 foreach 循环,来验证一下:

1
2
3
4
5
6
7
8
9
foreach (var group in result)
{
Console.WriteLine("Group " + group.Key);
foreach (var item in group)
{
Console.WriteLine("User ID #" + item.Id
+ " Status: " + item.Status);
}
}

结果是:

1
2
3
4
5
6
Group Online
User ID #1 Status: Online
User ID #2 Status: Online
User ID #4 Status: Online
Group Offline
User ID #3 Status: Offline

是不是和我们的预想完全一样呢?当然,我们这里是拿 Enum 作为 key,这只是一个比较符合现实、又合理的例子。

LINQ 会把 Key 完全相同的元素分到一组中。所以,你当然可以用其它类型——比如相同的 int 类型,把相同年龄的 User 分到一组(除非有非常好的理由,真的有人会这么干吗?)。在实际编写中,明智地选择 Key 是得到清晰的分组的必要条件。

合并查询 join

使用 LINQ 的时候,我们不禁在思考:可不可以让数据来自多个数据源呢?这些数据的某一个属性的值完全一样,难道就不能把它们合并到一起吗?

当然是可以的。LINQ 有一个 join 子句,能够帮你达成合并的任务。首先,我们要举两个示例模型。仔细看,确保你理解这两个类的结构和它们的现实含义:

1
2
3
4
5
6
7
8
9
10
11
12
class User // 这个类没有改
{
public int Id { get; set; }
public Status Status { get; set; }
}
class Message // 表示用户发送的一条信息
{
// 发送者 ID
public int SenderId { get; set; }
// 发送内容
public string Text { get; set; } = "";
}

我们假设这是个用户留言板系统,所有的数据都是合法的,那么,一定会有以下的结论:

  • 系统中有一个用户列表,表示所有注册用户
  • 还有一个消息列表,表示留言板上面的所有消息
  • 每条消息都对应一个用户(一个用户可能发送多条消息)

那么,我们可以把 User 列表,合并到 Message 里面!

我们先来构造点示例数据,然后展示 join 语句的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
User[] users = new User[] // 不变
{
new User { Id = 1, Status = Status.Online },
new User { Id = 2, Status = Status.Online },
new User { Id = 3, Status = Status.Offline },
new User { Id = 4, Status = Status.Online },
};
Message[] messages = new Message[] // 来构造一个消息列表
{
new Message {SenderId= 1,Text="I love this."},
new Message {SenderId= 2,Text="No wayyyyy we can leave message" },
new Message{SenderId=3,Text="OMG this is crazy"},
new Message{SenderId=3,Text="Great work!"},
new Message{SenderId=4,Text="Can I delete my message???"}
};

然后我们来查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var result = from message in messages
join user in users
on message.SenderId equals user.Id
select new
{
SenderId = message.SenderId,
Text = message.Text,
UserStatus = user.Status,
};
foreach (var item in result)
{
Console.WriteLine("Message [" + item.Text +
"] from user #" + item.SenderId +
" whose status is " + item.UserStatus);
}

嗯,来仔细瞧一瞧,先画个图感受过程:

合并示意图

  • from 子句从 messages 里面取出所有消息
  • join 子句从 users 列表中取出 user
  • 如何合并?on … equals … 添加了限制条件。遍历 from 子句生成的临时 message 列表时,会根据条件进行比较。这里的条件是,on 和 equals 后面的内容完全一致
  • 根据条件,当 user 的 id 和 message 中的 SenderId 相同时,user 和对应的 message 匹配成功。
  • 合并之后,现在一个元素里面既有 message,又有 user。
  • select 子句创建的新的类型,根据之前已经匹配成功得到的临时列表,从每个合并后的元素中遍历取出 message.SenderId message.Text user.Status,然后创建新的对象

好了,问题来了:我们 select 里面创建的到底是个啥?这个类型我们从来没见过啊?

没错,我们就是创建的新的类型,但是这个类型根本就没有名字,所有的属性都是只读的,这称作匿名类型。我们一直用 new 来创建新的类型,通常 new 后面需要一个类型名称。当我们省略这个类型名称的时候,我们就创建了一个匿名类型

你已经知道,我们用 var 来让 C# 自己决定类型,省心省力。实际上,匿名类型也是 var 的重要用法!这是因为,匿名类型必须使用 var 关键字来创建变量:

1
2
3
4
var testObj = new {
Name = "Sam",
Id = 3
}

回到 LINQ。来看 select:

1
2
3
4
5
6
select new
{
SenderId = message.SenderId,
Text = message.Text,
UserStatus = user.Status
}

这就是一个匿名类型!用 foreach 遍历的时候,我们只能用 var:

1
2
3
4
5
6
foreach (var item in result)
{
Console.WriteLine("Message [" + item.Text +
"] from user #" + item.SenderId +
" whose status is " + item.UserStatus);
}

现在我们来运行一下刚才的整个程序:

1
2
3
4
5
Message [I love this.] from user #1 whose status is Online
Message [No wayyyyy we can leave message] from user #2 whose status is Online
Message [OMG this is crazy] from user #3 whose status is Offline
Message [Great work!] from user #3 whose status is Offline
Message [Can I delete my message???] from user #4 whose status is Online

完美!这下彻底把两组数据合并了。

需要特别注意的一点是,join 后面加进来的列表中的数据是会匹配的。因此,join 列表中的元素可能被复制(一个 user 合并到多条 message),比如上面的 user#3,两条 message 中都有同样的 id 和 status。

此外,多次使用 join 也是可以的!可以把多个列表合并到一起,此处不再赘述。

懒计算

我们刚才一直提到“声明”,其实这和 LINQ 的行为也是一致的。在你获取数据结果时,查询才会真正发生。看看下面的代码:

1
2
3
4
5
6
7
8
9
10
List<int> numbers = new List<int>() { 1, 114, 514, 233, 322, 44432, 23232 };
var result= from num in numbers
where num >=115
orderby num
select num;
numbers[1] = 115;
foreach (var item in result)
{
Console.WriteLine(item); // 115 233 322 514 23232 44432
}

执行一下,你会发现结果中有一个 115!这明明就是在查询后面才添加到原始的 numbers 列表中才对……为什么会这样呢?

这是因为,使用 LINQ 的时候,把它赋值给一个变量并不会触发查询,直到赋值的这个变量被用到的时候,才会真正发生查询。这被称为懒计算。你在写 LINQ 只是声明了一种查询的方法,并非触发了查询

预告:Lambda 表达式

我们之前已经提到,方法和声明式查询都是标准 LINQ 的一种。实际上,它们就是完全一致、可以替换的关系!

如果刚才的声明式查让你感到有些疑惑,来换种角度看看LINQ吧。不过,在此之前,我们必须了解一个东西:Lambda 表达式,这样才能写出那些方法。

但是,这篇文章已经足够长了,因此,我们下次再讨论 lambda 以及 linq,敬请期待!