LINQ(Language Integrated Query,语言集成查询)是一个方法库,用于对序列进行过滤、转换、排序、分组和汇总。加上 using System.Linq; 之后,每个数组、List<T>、Dictionary 以及其他 IEnumerable<T> 都会获得 Where、Select 和 OrderBy 这样的方法,每个方法都接受一个 Lambda,说明要对单个元素做什么。
第一个查询:方法语法与查询语法
LINQ 有两种写法。方法语法把调用串联起来;查询语法读起来像 SQL。编译器会把查询语法转换成同样的方法调用,所以两者的结果完全相同。
输出:
95, 91, 88
95, 91, 88
在现代代码中,方法语法更常见,而且有些运算符(Take、Distinct、Count、First、ToList)只有方法形式。查询语法在连接操作,以及需要用 let 引入中间变量的查询中更有优势。本页其余部分使用方法语法,只在查询语法能带来额外好处时才展示它。
Where 与 Select:过滤与投影
Where 保留满足条件的元素。Select 把每个元素转换成别的东西。两者结合,可以覆盖日常大部分查询。
输出:
Mug, Kettle
MUG $8.50 | LAMP $32.00 | KETTLE $27.00 | RUG $120.00
Lamp: 38.40
Rug: 144.00
1. Mug 2. Lamp 3. Kettle 4. Rug
Where 从不改变元素,Select 从不改变元素的个数。new { p.Name, WithTax = ... } 会创建一个匿名类型,也就是编译器替你写好的一个类,你可以用普通的点语法读取它的属性。它没有你能写出来的名字,所以持有它的变量必须用 var 声明,它适合只在一个方法内部使用的结果。
OrderBy、ThenBy 与按多个键排序
输出:
Ana 60
Ana 15
Ben 90
Ben 40
Chloe 25
Ben, Ana, Ben, Chloe, Ana
OrderBy 是稳定排序:键相等的元素保持原来的顺序。第二个键请用 ThenBy 或 ThenByDescending。第二个 OrderBy 会按它自己的键重新排序所有元素,让第一个键沦为只用来打破平局:优先级正好反了。在查询语法中,多个键用逗号分隔:orderby o.Customer, o.Total descending。
与 List<T>.Sort 不同,OrderBy 不会改动数据源,而是返回一个新序列。
First、Single、Last 及其 OrDefault 版本
这些方法返回单个元素而不是序列。普通版本在没有可返回的元素时会抛出异常;OrDefault 版本则返回该类型的默认值。
输出:
Ana
Ben
Bea
True
First: InvalidOperationException
Single: InvalidOperationException
0
对空序列调用 First 抛出的异常是 InvalidOperationException,消息为“Sequence contains no elements”(当谓词没有匹配到任何元素时是“Sequence contains no matching element”),这是被搜索最多的 C# 错误之一。当“没有”是一种正常结果时,请使用 FirstOrDefault 并检查 null。
最后一行展示了值类型的陷阱:对于 int 序列,没有匹配时 FirstOrDefault 返回 0,这与真正的 0 无法区分。可以先用 Where 过滤并检查 Any(),或者在 .NET 6 及以后的版本中传入你自己的默认值:scores.FirstOrDefault(s => s > 100, -1)。
Single 用于“恰好一个”:没有或者多于一个都会抛出异常,所以它既是查询,也是一种断言。
Any、All、Count 与 Contains
输出:
True
True
True
3
True
True
Any() 在找到第一个匹配时就会停止,所以 items.Any() 才是询问“有没有元素”的正确方式。当数据源是列表或数组这样的集合时,items.Count() > 0 会读取 Count 属性,但对于查询或其他惰性序列,它会先遍历所有元素。对空序列调用 All 会返回 true,因为不存在不满足条件的元素。
Sum、Average、Min 与 Max
输出:
Total: 28.25
Items: 7
Priciest: 7.25
Average price: 4.92
0
Average: InvalidOperationException
空序列的 Sum 是 0,但对空的数字序列调用 Average、Min 和 Max 会抛出 InvalidOperationException:因为不存在合理的答案。可以先用 Any() 做保护,或者先调用 DefaultIfEmpty():empty.DefaultIfEmpty(0).Average()。
Distinct、Take 与 Skip
输出:
home, shop, cart
Page 1: 1 2 3 4 5 6 7 8 9 10
Page 2: 11 12 13 14 15 16 17 18 19 20
Page 3: 21 22 23
12, 15
Distinct 按顺序保留每个值第一次出现的位置。Skip(n).Take(m) 是标准的分页写法,而 TakeWhile/SkipWhile 会在第一个不满足条件的元素处停止或开始。Enumerable.Range(start, count) 无需数组就能生成一个整数序列。
GroupBy
GroupBy 按键把元素分到不同的桶里。每个分组都有一个 Key,本身也是一个序列,所以你可以对它做聚合。
输出:
North: 3 sales, total 600
South: 2 sales, total 400
East: 1 sales, total 350
Top rep: Ana 400
North -> Ana/Chloe/Ana; South -> Ben/Eli; East -> Dev
分组按照各个键在数据源中第一次出现的顺序输出(North、South、East),并不会排序;如果需要排序,请加上 OrderBy(g => g.Key)。在查询语法中,group X by K 决定每个分组里放什么(这里只放销售代表的名字),而 into g 给分组命名,让查询可以继续往下写。
ToList、ToArray 与 ToDictionary
To... 系列方法会运行查询并保存结果。
输出:
ben@x.com
2
ToDictionary: ArgumentException
当两个元素产生相同的键时,ToDictionary 会抛出 ArgumentException,和 Dictionary.Add 一样。如果预计会有重复,请先分组(就像 wordCounts 那样),或者使用 ToLookup,它会把每个键映射到一个值序列。
延迟执行
Where、Select、OrderBy、GroupBy 以及其他大多数运算符都会返回一个查询对象,在有人枚举它之前什么也不做。这带来了两个值得一看的后果:
输出:
25, 40, 99
converting 10
converting 25
converting 40
converting 99
Count: 4
converting 10
converting 25
converting 40
converting 99
Max: 9900
converting 10
converting 25
converting 40
converting 99
Cached: 4, 9900
第一个查询看到了 99,尽管它是在查询写好之后才加进去的:过滤是在 string.Join 枚举查询时才运行的。第二个查询把转换过程打印了两遍,因为 Count() 和 Max() 各自从头枚举了一次。如果 Lambda 开销很小,这只是白做了一些工作;但如果 Lambda 里有数据库查询、文件读取或随机数,两次遍历可能会返回不同的数据。当你要多次使用一个结果时,请用 ToList() 或 ToArray() 把它固化下来。
返回单个值的运算符(Count、Sum、First、Any、Max)以及 To... 系列方法会立即运行。这种惰性是如何实现的,请参见 IEnumerable 与 yield。
速查表
| 运算符 | 作用 | 返回 |
|---|---|---|
Where(x => cond) | 过滤 | 序列(延迟) |
Select(x => expr) | 转换每个元素 | 序列(延迟) |
SelectMany(x => seq) | 展平嵌套序列 | 序列(延迟) |
OrderBy、ThenBy(+Descending) | 稳定排序 | 序列(延迟) |
GroupBy(x => key) | 按键分桶 | 分组序列(延迟) |
Distinct、Take、Skip | 去重、分页 | 序列(延迟) |
First、Single、Last | 单个元素,没有时抛出异常 | 元素 |
FirstOrDefault 等 | 单个元素或默认值 | 元素 |
Any、All、Contains | 判断 | bool |
Count、Sum、Min、Max、Average | 聚合 | 数字 |
ToList、ToArray、ToDictionary | 运行并保存 | 集合 |
常见错误
- 对可能为空的序列调用
First()。 会抛出“Sequence contains no elements”;请使用FirstOrDefault并检查结果。 - 调用两次
OrderBy。 第二个键会变成主键;请使用ThenBy。 - 多次枚举同一个查询。 工作会重复执行;调用一次
ToList()即可。 - 以为查询会给数据源拍快照。 它在被枚举时才读取数据源,而不是在书写时。
- 对空序列调用
Average、Min、Max。 它们会抛出异常;请用Any()或DefaultIfEmpty做保护。 - 用
Count() > 0判断是否有元素。Any()在找到第一个元素时就会停止。
常见问题
C# 中的 LINQ 是什么?
LINQ(Language Integrated Query,语言集成查询)是 System.Linq 中的一组扩展方法,例如 Where、Select、OrderBy 和 GroupBy,它们可以用于任何 IEnumerable<T>:数组、列表、字典,以及其他查询的结果。C# 还有一种查询语法(from x in items where ... select ...),编译器会把它翻译成同样的方法调用。
LINQ 中的 Select 和 Where 有什么区别?
Where 负责过滤:保留 Lambda 返回 true 的元素,并且不改变它们。Select 负责投影:把每个元素转换成别的东西,比如某个属性或一个新对象,元素个数保持不变。它们经常串联使用:people.Where(p => p.Age >= 18).Select(p => p.Name)。
First 和 FirstOrDefault 有什么区别?
First() 返回第一个元素(或者第一个满足谓词的元素),如果没有,就抛出 InvalidOperationException(“Sequence contains no elements”或“no matching element”)。FirstOrDefault() 则返回该类型的默认值:类返回 null,int 返回 0,所以使用结果之前要先检查。
如何用 LINQ 按两个字段排序?
在 OrderBy 后面串联 ThenBy:orders.OrderBy(o => o.Customer).ThenByDescending(o => o.Total)。调用两次 OrderBy 是不行的:第二次调用会对整个序列重新排序,于是它的键成了主键,第一个键只用来打破平局,与本意正好相反。
LINQ 中的 GroupBy 是怎么工作的?
GroupBy(x => key) 为每个不同的键返回一个分组。每个分组都是一个 IGrouping<TKey, TElement>:它有一个 Key 属性,本身也是由拥有这个键的元素组成的序列,所以你可以对它调用 Count()、Sum() 或 Select。分组按照各个键第一次出现的顺序输出。
LINQ 中的延迟执行是什么?
大多数 LINQ 运算符在你调用时并不会运行;它们返回一个查询,这个查询每次被枚举时(通过 foreach、ToList()、Count() 等)才运行。因此,查询能看到它定义之后对数据源所做的修改,枚举两次就会把工作做两遍。调用 ToList() 或 ToArray() 可以只运行一次并保存结果。