奶奶都能看懂的 C# —— 手把手 LINQ
LINQ,学了会,会了忘,忘了学,学了又会,会了又忘……
受不了了!今天这篇文章就来讲解一下 C# 中的 LINQ,先入门,然后带你从代码内涵上,帮助记忆 LINQ 语法。
标准 LINQ 的使用前提
在 C# 的世界里,有许许多多各式各样的对象,也有这些对象的集合,比如我们熟悉的 List<T>。
LINQ 就是为了对这些集合类型的数据进行查询和处理而生的。它的全名叫做 Language-Integrated Query,语言中集成的查询,很好的名字不是吗?
在正式开始查询之前,我们先来说说什么情况下能用。
动动手,打开 VS 创建一个 List 实例,把光标放在这个类型上面,然后按下 F12 导航到定义(反编译源码)。仔细观察这种“集合”类型的对象实现了哪些接口:
1 | |
嗯,有个非常有趣的东西 IEnumerable<T>。
你一定知道 I 表示它是个接口,后面尖括号里面的 T 表示泛型,现在我们来看看它的命名:
Enumerable,可枚举的。字面意思理解,就是这个类里面有一堆东西,你可以一个一个遍历枚举出来(记得 foreach 循环吗?我们经常使用这种枚举来执行 List 操作)。
而标准 LINQ 的使用前提就是类实现了该接口(或能够隐式转换),很符合直觉,如果你这个类都无法枚举,还怎么查询数据呢?
在本文的后半,会介绍怎么自己实现这个接口,不过现在我们先记住这个前提,然后来用一下 LINQ 感受一下吧!
快速开始查询数据
要开始使用 LINQ,只需一行 using 即可,此时你会发现,对于实现了IEnumerable 的集合来说,可用的方法增加了!
1 | |
上面的代码,用了 Take Last 这两个 LINQ 方法,顾名思义,take 取出头部的几个元素,last 取得最后单个元素。
当然 LINQ 的强大之处是使用方法串链,一次完成 2 步查询:
1 | |
先取出前两个,再取出结果中最后一个!
怎么样,是不是已经有点查询的样子了?你可以自己去看一下 IDE 的智能提示,里面还有许多查询方法可用。
声明式查询
基本声明式查询
但是等等!LINQ 不仅支持通过方法使用(其实这叫做扩展方法,但并不在这篇文章的讨论范围内),还支持声明式查询,这是另一种更加清晰可感的形式。
什么意思呢?来看看下面的代码:
1 | |
把上面的代码键入到 Main 方法尝试一下吧!先根据含义猜测一下会输出什么。
1 | |
猜对了吗?这就是一个非常简单的 LINQ 声明式查询示例,这里的查询没有一点调用方法的样子!
现在我们来仔细拆解一下这到底是怎么工作的。我们可以把整个查询拆开成几部分(称为子句):
1 | |
先看这一部分。from 子句(数据从哪里来?)首先从要查询的对象里面“取”出数据,存入变量。in (从什么里面取?)指定查询的对象,然后存入 from 后面的变量。这个 num 是一个范围变量,有一个范围,里面是多个 int,迭代时会处理每一个变量。
1 | |
再看这个 where 子句。它的含义就非常清晰好记:过滤出大于 500 的数据。
1 | |
再看 orderby 子句。这个子句会对列表内的数据从低到高排序。因为 num 是一个 int 类型,所以可以直接排序。
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 | |
然后,让我们构造一些数据来查询:
1 | |
查询的第一行你肯定已经明白了,就是按照 ID 从小到大排序。
现在我们来拆解这个分组。首先,我们必须明白:这个 var,到底是什么类型呢?
把鼠标悬浮在上面,IDE 已经给出了答案:
1 | |
也就是,分组结果是个 IEnumerable,一个内含多个 IGrouping 的可迭代列表。那么问题来了,里面的元素,IGrouping 又是什么?
按住 Ctrl 点一下,转到定义:
1 | |
它又是实现了 IEnumerable 的一组数据,里面有一个 Key。
也就是说,在我们的情境下,IGrouping 是一个分组,里面包含多个 User 元素,这些 User 元素有相同的 Key(也就是相同的 Status,是分组的依据)。明白了这一点,下面的查询就不难理解了:
1 | |
这个子句的含义是:
- 把 user 对应的列表根据 Status 分组,相同 Status 的 User 分到一组
- 每个 IGrouping 表示一个分组,包含多个 User 元素
- Status 作为每个 IGrouping 分组的 Key(by 后面的就是 key)
- 最后把这些 IGrouping 分组塞到一个新的集合中,叫做 userGroup。
看看下面的图片吧,瞬间秒懂。

这下就简单了!我们可以用两层 foreach 循环,来验证一下:
1 | |
结果是:
1 | |
是不是和我们的预想完全一样呢?当然,我们这里是拿 Enum 作为 key,这只是一个比较符合现实、又合理的例子。
LINQ 会把 Key 完全相同的元素分到一组中。所以,你当然可以用其它类型——比如相同的 int 类型,把相同年龄的 User 分到一组(除非有非常好的理由,真的有人会这么干吗?)。在实际编写中,明智地选择 Key 是得到清晰的分组的必要条件。
合并查询 join
使用 LINQ 的时候,我们不禁在思考:可不可以让数据来自多个数据源呢?这些数据的某一个属性的值完全一样,难道就不能把它们合并到一起吗?
当然是可以的。LINQ 有一个 join 子句,能够帮你达成合并的任务。首先,我们要举两个示例模型。仔细看,确保你理解这两个类的结构和它们的现实含义:
1 | |
我们假设这是个用户留言板系统,所有的数据都是合法的,那么,一定会有以下的结论:
- 系统中有一个用户列表,表示所有注册用户
- 还有一个消息列表,表示留言板上面的所有消息
- 每条消息都对应一个用户(一个用户可能发送多条消息)
那么,我们可以把 User 列表,合并到 Message 里面!
我们先来构造点示例数据,然后展示 join 语句的用法:
1 | |
然后我们来查询:
1 | |
嗯,来仔细瞧一瞧,先画个图感受过程:

- 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 | |
回到 LINQ。来看 select:
1 | |
这就是一个匿名类型!用 foreach 遍历的时候,我们只能用 var:
1 | |
现在我们来运行一下刚才的整个程序:
1 | |
完美!这下彻底把两组数据合并了。
需要特别注意的一点是,join 后面加进来的列表中的数据是会匹配的。因此,join 列表中的元素可能被复制(一个 user 合并到多条 message),比如上面的 user#3,两条 message 中都有同样的 id 和 status。
此外,多次使用 join 也是可以的!可以把多个列表合并到一起,此处不再赘述。
懒计算
我们刚才一直提到“声明”,其实这和 LINQ 的行为也是一致的。在你获取数据结果时,查询才会真正发生。看看下面的代码:
1 | |
执行一下,你会发现结果中有一个 115!这明明就是在查询后面才添加到原始的 numbers 列表中才对……为什么会这样呢?
这是因为,使用 LINQ 的时候,把它赋值给一个变量并不会触发查询,直到赋值的这个变量被用到的时候,才会真正发生查询。这被称为懒计算。你在写 LINQ 只是声明了一种查询的方法,并非触发了查询。
预告:Lambda 表达式
我们之前已经提到,方法和声明式查询都是标准 LINQ 的一种。实际上,它们就是完全一致、可以替换的关系!
如果刚才的声明式查让你感到有些疑惑,来换种角度看看LINQ吧。不过,在此之前,我们必须了解一个东西:Lambda 表达式,这样才能写出那些方法。
但是,这篇文章已经足够长了,因此,我们下次再讨论 lambda 以及 linq,敬请期待!