C#之LINQ——方法语法与查询语法(贰)
六、方法语法
这里我会简单讲解下方法语法中的各个API的基本作用。
1、Any
用于检查集合中是否有任意元素匹配给定条件。可以不给条件,用于判断集合是否有元素。
2、All
用于检查集合中是否所有元素满足给定条件。集合为空则返回True。
3、Count
计算集合中匹配特定条件的元素数量。若数量很大,可以使用LongCount方法
4、Contains
检查给定元素是否存在集合中。
其中Contains有两个重载版本,一个是直接传入元素,要注意引用类型比较时,如果元素的类没有继承IEqualityComparer接口或者重写Equals,则使用System.Object的默认实现进行比较,即通过引用进行比较,即使两者数据相同,但是引用地址不同也会判断不相等。另一个版本则是再传入一个comparer自定义比较器。
5、OrderBy
按照给定条件对集合元素进行排序。降序排序可以使用OrderByDescending方法。
OrderBy还有重载版本,可以传入比较器进行比较。
如果需要多个条件按优先级排序,可以在后面再加上任意数量的ThenBy或者ThenByDescending方法。
6、Reverse
反转集合的元素。不接受任何参数。
7、Min 和 Max
返回集合中的最小值或最大值。对于自定义类,可以选择带比较器参数的重载。
-
对于非可空类型(int、float等值类型),若集合为空,则抛出异常。
-
对于可空类型(引用类型或可空值类型),若集合为空,则返回null。
8、Average
用于计算数字集合的平均值。对于自定义类,可以选择带比较器参数的重载。
- 使用该方法必须确保集合不为空,否则抛出异常。
9、Sum
计算集合中值的总和。对于自定义类,可以选择带比较器参数的重载。
- 如果集合为空,总和为0。
10、ElementAt
访问集合中指定索引处的元素。数组或列表可以使用[]索引器进行访问,但并非所有IEnumerable都支持索引器,因此需要ElementAt方法。
- 若访问的索引处没有元素,则会抛出异常。
可以使用ElementAtOrDefault避免异常并获取存储元素的默认值(比如int为0,类对象为null)。
11、First 和 Last
First返回集合中第一个元素,Last返回集合中最后一个元素。如果提供了谓词,则按谓词的条件进行匹配。(这里本文第一次提到谓词,谓词其实就是判断某个条件是否成立的表达式,简单理解为条件表达式)
- 若集合为空,或没有匹配给定谓词的元素,则会抛出异常。
可以使用FirstOrDefault或者LastOrDefault方法避免异常并获取存储元素的默认值。
12、Single
返回集合中满足特定条件的唯一元素。如果不带谓词,则表示访问大小为1的集合中的唯一元素。
- 如果不存在恰好一个这样的元素,则抛出异常。比如有多个满足条件的元素。
可以使用SingleOrDefault方法,当没有匹配元素时,返回存储元素的默认值。但存在多个匹配元素,仍会抛出异常。
13、Where
基于谓词过滤集合。Where不修改原始集合,而是返回新的过滤后的集合。
- 若没有匹配结果,则返回的集合为空,不抛出异常。
另外一个Where重载版本的谓词会多包含一个int类型参数,表示元素集合的索引,这使我们拥有了获取元素索引的能力。
比如我们有这么一个单词集合:
{"1.AAA", "3.BBB", "invalidWord", "4.DDD"}
我们想筛选出所有以数字编号.开头的单词,其中数字编号为下标索引+1,比如索引0的元素数字编号为1。
那么筛选后的结果就是下面这样,其中3.BBB不满足,因为它的数字编号应该是2。invalidWord也不满足,因为它不以数字编号.开头。
{"1.AAA", "4.DDD"}
这里我们既然用到了元素的下标,那么普通的Where肯定是获取不到元素下标的,所以需要使用重载版本,代码如下:
words.Where((word,index) => word.StartsWith($"{index+1}."));
14、Take
从集合开头返回指定数量的连续元素。
- 如果指定数量超过集合大小,则不会抛出异常,而是取出集合所有元素。
还可以使用TakeLast方法,提取集合中最后几个元素。
应用:可以与OrderBy配合,比如获取排行榜前三名。可以与Count配合,比如提取前60%的元素:Take((int)(XXX.Count() * 0.6f))
15、TakeWhile
遍历集合,不断将匹配谓词的元素添加到结果中,直到遇到第一个不匹配的元素。常用于数量较大的有序集合,比如获取小于某个数的所有数字,和Where相比,TakeWhile无需遍历所有集合元素,因为一旦遇到比这个数大的就会停止,可以提高性能。
16、Skip
跳过集合开头指定数量的元素,返回剩余部分的元素集合。
- 如果指定数量超过集合大小,则不会抛出异常,返回集合为空。
还可以使用SkipLast方法,跳过集合中最后几个元素。
应用:可以和Take搭配,比如网页中每页显示固定的商品,可以先Skip前面页面数量的商品,然后Take当前页面数量的商品。
17、SkipWhile
跳过匹配谓词的元素,直到遇到第一个不匹配的元素,返回剩余的元素集合。与TakeWhile一样,常用于有序集合。
18、OfType
根据特定类型过滤集合中的元素。是一个泛型方法,通过泛型参数指定过滤的类型。常用于过滤像ArrayList这样以object为类型的数据集合。
OfType不会抛出异常,而是简单地跳过那些不能被转换为目标类型的元素。
19、Distinct
去除集合中的所有重复值,返回一个只包含唯一元素的集合。注意对于引用类型,比如自定义类,默认通过引用比较,这在Contains中已经提及过。我们可以使用Distinct的重载版本,传入自定义的比较器。
20、Append 和 Prepend
Append用于将元素添加到集合末尾,Prepend用于将元素添加到集合开头。与所有LINQ方法一样,原始集合不会被修改。
21、Concat 和 Union
Concat用于连接相同类型的两个集合,Union用于连接相同类型的两个集合并移除重复项。Concat只是单纯的连接,而Union则是并集操作。
需要注意的是,对于引用类型,Union移除重复的元素默认是引用比较,这在Contains中已经提及过。我们可以使用Union的重载版本,传入自定义的比较器。
22、集合类型转换
LINQ提供了将泛型IEnumerable转换为具体集合类型的工具。这实际上会执行LINQ查询并枚举IEnumerable。
ToArray:将任何IEnumerable转换为数组ToList:将任何IEnumerable转换为列表ToHashSet:将任何IEnumerable转换为哈希集ToDictionary:将任何IEnumerable转换为字典ToLookup:将任何IEnumerable转换为查找表,与字典不同的是它允许一个Key对应多个ValueAsEnumerable:将集合特定类型转换为IEnumerable。- 比如你自己写了一个类继承了
IEnumerable,里面包含了一个Where方法,但与LINQ的Where实现不同。当你想对这个类使用Where查询时,会调用你自己写的那个Where方法。因此需要AsEnumerable转换成通用的IEnumerable从而正确的调用LINQ的那个Where方法。
- 比如你自己写了一个类继承了
Cast:不改变集合的种类(比如List转换后还是List),而是将集合中每个元素转换为指定类型。- 比如将
Enum.GetValue获取的Array转换为指定枚举类型:
- 比如将
IEnumerable<PetType> allPetsTypes = Enum.GetValues(typeof(PetType)).Cast<PetType>();
23、Select
将集合中每个元素投影为新形式。操作后的结果将创建一个新集合。
应用:将集合数字全部乘2;将所有单词转换为大写;将所有数字转换为字符串等等。
Select还有一个带int参数的谓词的重载版本,这个int就是当前处理元素在集合中的索引,我们之前在Where中已经提及过。
24、SelectMany
用于将集合的每个元素投影为一个IEnumerable,并将生成的这些集合展平为一个结果集合。方法内部的主要原理就是一个嵌套的foreach循环,常常用于展平嵌套集合。
可能理解起来比较困难,这里举个例子,假设我们有一个宠物类和主人类,每个主人可以有多个宠物,为了简化篇幅,这里省略了构造方法和ToString方法。
// 宠物类
public class Pet
{
public int Id { get; }
public string Name { get; }
public PetType PetType { get; }
public float Weight { get; }
}
// 主人类
public class PetOwner
{
public int Id { get; }
public string Name { get; }
public IEnumerable<Pet> Pets; // 一个主人有多个宠物
}
接下来是数据准备,分别是宠物数据和主人数据:
IEnumerable<Pet> pets = new[]
{
new Pet(1, "Hannibal", PetType.Fish, 1.1f),
new Pet(2, "Anthony", PetType.Cat, 2f),
new Pet(3, "Ed", PetType.Cat, 0.7f),
new Pet(4, "Taiga", PetType.Dog, 35f),
new Pet(5, "Rex", PetType.Dog, 40f),
new Pet(6, "Lucky", PetType.Dog, 5f),
new Pet(7, "Storm", PetType.Cat, 0.9f),
new Pet(8, "Nyan", PetType.Cat, 2.2f)
};
IEnumerable<PetOwner> people = new[]
{
new PetOwner(1, "John", new [] {
pets.ElementAt(0),
pets.ElementAt(1),
}),
new PetOwner(2, "Jack", new [] {
pets.ElementAt(2)
}),
new PetOwner(3, "Stephanie", new [] {
pets.ElementAt(3),
pets.ElementAt(4),
pets.ElementAt(5)
})
};
我们想要筛选出所有主人的所有宠物,希望它们放到一个集合里面,则可以使用SelectMany。此时嵌套的集合会被展平为一个一维的集合。
IEnumerable<Pet> petsOfOwners = people.SelectMany(owner => owner.Pets);
遍历集合打印出来就会类似这样:
Id: 1, Name: Hannibal, Type: Fish, Weight: 1.1
Id: 2, Name: Anthony, Type: Cat, Weight: 2
Id: 3, Name: Ed, Type: Cat, Weight: 0.7
Id: 4, Name: Taiga, Type: Dog, Weight: 35
Id: 5, Name: Rex, Type: Dog, Weight: 40
Id: 6, Name: Lucky, Type: Dog, Weight: 5
再举个简单的例子,将二维数组展平为一维数组:
var nestedListOfNumbers = new List<List<int>>
{
new List<int> {1, 2, 3},
new List<int> {4, 5, 6},
new List<int> {5, 6},
};
var allNumbersFromNestedList = nestedListOfNumbers.SelectMany(list => list);
如果嵌套很深怎么办?比如三层?只需调用SelectMany两次即可,第1次将三维集合展平为二维集合,第2次将二维集合展平为一维集合。
SelectMany还有一个重载版本,多接收一个参数,如下:Func<TSource, TCollection, TResult> resultSelector,其中TSource是外部集合的元素,TCollection是内部集合的元素。可以简单理解为将展平后的集合中的每个元素进行处理,同时还可以带上父元素信息。
比如我们想要获取所有主人——宠物对的信息集合:
IEnumerable<string> ownerPetPairsInfo = people.SelectMany(
person => person.Pets,
(person, pet) => $"{person.Name} owns {pet.Name}");
将所有主人的宠物放到一个集合后,再对集合中的所有宠物进行处理:转换为包含主人与对应宠物的字符串信息。其中person来自外部集合(父元素)、pet来自内部集合。打印结果如下:
John owns Hannibal
John owns Anthony
Jack owns Ed
Stephanie owns Taiga
Stephanie owns Rex
Stephanie owns Lucky
当然Select不仅仅是可以展平嵌套集合,两个不相干的集合也是可以的,比如求两个数组的笛卡尔积:
var numbers = new[] { 1, 2, 3 };
var letters = new[] { 'A', 'B', 'C' };
var carthesianProduct = numbers.SelectMany(
number => letters,
(number, letter) => $"{number},{letter}");
25、生成新的集合
LINQ提供了用于生成新集合的几个方法。
-
Enumerable.Empty:创建新的空集合。 -
Enumerable.Repeat:创建指定数量的重复值的集合。第一个参数为元素,第二个参数为重复数量。 -
Enumerable.Range:生成从给定值开始递增的整数集合。第一个参数为起始值,第二个参数为集合元素数量。其中起始值也可以传入字符char,调用时将会转为数字。 -
Enumerable.DefaultIfEmpty:如果输入集合不为空,则结果为输入集合的副本;如果输入集合为空,则结果是只有一个元素的集合,其中唯一值为该类型的默认值。可以传入参数指定默认值。
26、GroupBy
根据指定条件对集合中的元素进行分组。
你可能会注意到这和ToLookup有什么不同呢?都是创建了一个键对应多个值的分组集合。
实际上,它们非常相似,我们查看GroupBy方法的源码,会发现它返回值为IEnumerable<IGrouping<TKey, TElement>>,而ILookup刚好继承了这个IEnumerable<IGrouping<TKey, TElement>>,也就是说,LookUp查找表只是GroupBy方法返回类型的更具体的版本。而且GroupBy方法会返回一个GroupedEnumerable对象,它的GetEnumerator返回的是LookUp类型的对象,也就是说,迭代这个GroupBy返回的对象时,实际上迭代的是这个查找表。
那么为什么还要有GroupBy呢?答案与性能有关。假设我们有包含百万宠物数据的数据库,因为SQL没有查找表的等效项,因此使用ToLookUp方法时,会检索数据库中所有宠物数据,并在C#程序中本地构建查找表,这会花费一些时间并且占用大量内存。而GroupBy则可以映射到SQL的GroupBy语句,整个操作可以在数据库端进行。
GroupBy还有一个重载版本,多接收一个参数,如下:Func<TKey, IEnumerable<TSource>, TResult> resultSelector,这和SelectMany的重载版本比较像。其中TKey是分组的键,IEnumerable<TSource>是该分组的值(即该组元素的集合)。
比如还是之前SelectMany的宠物的那个例子,这次我们想对所有宠物 按体重向下取整值 进行分组,并且想获取每个分组的最小和最大体重,所有分组最终按照分组的Key值排序(按向下取整值排序)。
假设某一组是这样的:Group: 0, Min: 0.7, Max: 0.9,这代表体重向下取整为0的所有宠物的分组中,最小体重为0.7,最大体重为0.9。
var weightGroups = pets.GroupBy(
pet => Math.Floor(pet.Weight),
(floorOfWeight, allPetsInThisGroup) => new
{
FloorOfWeight = floorOfWeight,
MinWeightInThisGroup = allPetsInThisGroup.Min(pet => pet.Weight),
MaxWeightInThisGroup = allPetsInThisGroup.Max(pet => pet.Weight)
})
.OrderBy(weightGroup => weightGroup.FloorOfWeight);
使用重载版本就可以获取到分组的键和值,可以用于在分组后,对每个分组进行加工处理,并返回处理后的结果集合。
27、集合的交集与差集
Intersect返回两个集合的交集。Except返回两个集合的差集,即属于第一个集合但不在另一个集合的元素。sequenceEqual判断两个集合是否相等。请注意集合的顺序,若两者顺序不同则不相等。
还需注意判断集合两个元素是否相等,若是引用类型,默认使用引用比较,之前已经讲述过了,需要的话可以传入自定义比较器进行比较。
28、Join
用于两个集合的内连接,即基于匹配键关联两个集合的元素。内连接会返回在两个集合中都匹配的记录。
连接的参数有点多,这里简单讲一下,假设有两个集合outer与inner,Join方法调用如下:
这里outerKeySelector是outer的键选择器,innerKeySelector是inner的键选择器,resultSelector是对两个集合元素连接后结果的处理。其中集合两个元素能否连接则需要看两者的匹配键是否相同,熟悉SQL的应该就不会陌生。
outer.Join(inner, outerKeySelector, innerKeySelector, resultSelector);
来举个稍微复杂点的例子:假设我们有宠物、宠物诊所预约和宠物诊所3个集合:
IEnumerable<Pet> pets = new[]
{
new Pet(1, "Hannibal", PetType.Fish, 1.1f),
new Pet(2, "Anthony", PetType.Cat, 2f),
new Pet(3, "Ed", PetType.Cat, 0.7f),
new Pet(4, "Taiga", PetType.Dog, 35f),
new Pet(5, "Rex", PetType.Dog, 40f),
new Pet(6, "Lucky", PetType.Dog, 5f),
new Pet(7, "Storm", PetType.Cat, 0.9f),
new Pet(8, "Nyan", PetType.Cat, 2.2f)
};
IEnumerable<VeterinaryClinicAppointment> veterinaryClinicAppointments = new[]
{
new VeterinaryClinicAppointment(clinicId:2, petId:1, new DateTime(2021, 10, 1, 12, 0, 0)),
new VeterinaryClinicAppointment(clinicId:3, petId:3, new DateTime(2021, 10, 1, 12, 30, 0)),
new VeterinaryClinicAppointment(clinicId:1, petId:4, new DateTime(2021, 10, 2, 13, 30, 0)),
new VeterinaryClinicAppointment(clinicId:2, petId:1, new DateTime(2021, 11, 1, 12, 0, 0))
};
IEnumerable<VeterinaryClinic> veterinaryClinics = new[]
{
new VeterinaryClinic(id: 1, name: "Happy Paws Clinic"),
new VeterinaryClinic(id: 2, name: "Fish Doctor"),
new VeterinaryClinic(id: 3, name: "Pure Purr Clinic")
};
现在想把它们都连接起来,它们连接的匹配键如下图所示:

则代码如下:第一次Join连接宠物集合与宠物诊所预约集合,并且将结果存储到一个匿名对象上;第二次Join再连接剩下的宠物诊所集合,并最终将匹配的三者转为字符串返回。
var petsAppointmentsFullInfo = pets
.Join(veterinaryClinicAppointments,
pet => pet.Id,
appointmet => appointmet.PetId,
(pet, appointment) => new { Pet = pet, Appointment = appointment })
.Join(veterinaryClinics,
petAppointmentPair => petAppointmentPair.Appointment.ClinicId,
clinic => clinic.Id,
(petAppointmentPair, clinic) => $"Pet name: {petAppointmentPair.Pet.Name}," +
$" appointment date: {petAppointmentPair.Appointment.Date}, " +
$"at clinic {clinic.Name}");
最终打印结果如下:
Pet name: Hannibal, appointment date: 2021/10/1 12:00:00, at clinic Fish Doctor
Pet name: Hannibal, appointment date: 2021/11/1 12:00:00, at clinic Fish Doctor
Pet name: Ed, appointment date: 2021/10/1 12:30:00, at clinic Pure Purr Clinic
Pet name: Taiga, appointment date: 2021/10/2 13:30:00, at clinic Happy Paws Clinic
可以发现Join的方法语法非常笨拙,需要保存中间结果,因此后面我们会来学习查询语法,使用Join将变得很简洁且易读。
29、GroupJoin
基于某些键关联两个集合的元素,并对结果进行分组。
还是Join那个例子,我们有宠物和宠物诊所预约2个集合,现在想要获取所有宠物的预约信息,但是希望所有预约都汇总到一个条目中。比如这里Hannibal有两个预约。

让我们关注这些信息:Hannibal有2个预约,Ed有1个预约,Storm没有预约。
对于Hannibal的两个预约,我们希望集合中不是有两个条目,而是都放在一个条目中。
Hannibal: appointments on 01/01/2021 12:00, 01/11/2021 12:00
Ed: appointments on 01/10/2021 12:30
Storm: no appointments
使用GroupJoin可以完成上述需求,可以发现最后连接后元素的处理中,pet是单一元素,appointments是一个集合,也就是连接后,一个pet可以对应多个appointmet。
var petsGroupedAppointments = pets
.GroupJoin(clinicAppointments,
pet => pet.Id,
appointmet => appointmet.PetId,
(pet, appointments) => new { Pet = pet, Appointments = appointments });
如果打印信息,你会发现即使Storm没有预约,也仍然在返回的集合中。也就是说,GroupJoin的调用者全部都出现在结果集合中,且每只宠物可以有多个预约。和Join不同,Join只有两个元素的匹配键匹配才会出现在结果集合中,且每只宠物可能多次出现在集合中。
利用GroupJoin的这个特性,我们可以实现左连接,即返回左表的所有条目,以及右表中匹配的条目(若存在)。与内连接的区别就是,左连接会把左表中没有匹配元素的项也列出来。
如果不理解,我举个例子,上图宠物和预约的左连接如下,注意这里Hannibal是出现了两次的,也就是一个匹配项一个条目,只不过没有匹配项的右侧为空。

使用GroupJoin + SelectMany + DefaultIfEmpty就可以实现左连接,在上面例子的代码基础上添加SelectMany方法,将宠物——预约集合对展平为宠物——预约对字符串的一维集合。
那这个DefaultInEmpty是干嘛的呢?我们知道,如果没有匹配项的宠物,它的Appointments是空的,而在SelectMany中,如果集合没有元素,那么自然提取不了。这个方法在生成新的集合那章中讲过,如果集合为空,会生成一个默认值到集合中,这里是null。所以最终结果就是,没有匹配项的宠物,它的Appointments会有一个null值,SelectMany就会提取到这个null值,并按我们自定义的方式与宠物名一起拼接字符串。
var leftJoin = pets
.GroupJoin(clinicAppointments,
pet => pet.Id,
appointmet => appointmet.PetId,
(pet, appointments) => new { Pet = pet, Appointments = appointments })
.SelectMany(
petAppointmentsPair => petAppointmentsPair.Appointments.DefaultIfEmpty(),
(petAppointmentsPair, appointment) =>
$"Pet name: {petAppointmentsPair.Pet.Name}, appointment date: {appointment?.Date}");
打印结果如下:
Pet name: Hannibal, appointment date: 2021/10/1 12:00:00
Pet name: Hannibal, appointment date: 2021/11/1 12:00:00
Pet name: Anthony, appointment date:
Pet name: Ed, appointment date: 2021/10/1 12:30:00
Pet name: Taiga, appointment date: 2021/10/2 13:30:00
Pet name: Rex, appointment date:
Pet name: Lucky, appointment date:
Pet name: Storm, appointment date:
Pet name: Nyan, appointment date:
30、Aggregate
用于对集合应用累加器函数。即我们可以拥有一个函数,它对集合中每个元素执行,每次执行都会持续修改某个累加结果。Aggregate可能是LINQ中最强大的方法,可以仅用此方法实现LINQ的大部分功能。
举个最简单的例子,累加数组中数字的总和,即实现Sum方法:
var numbers = new[] { 10, 1, 4, 17, 122 };
var sum = numbers.Aggregate((sum, nextNumber) => sum + nextNumber);
Console.WriteLine($"sum is {sum}"); // sum is 154
这里,Aggregate的累加器函数接收两个参数,sum代表累加结果,初始时它为集合的第一个元素;nextNumber代表当前正在处理的集合元素。每次处理当前集合元素时,都将函数产生的结果分配给累加结果,即这里的sum,并在处理下一个元素时使用。遍历完集合后,最终会返回累加结果sum。
再来2个例子:
- 找出字符串中最长的单词
var sentence = "The quick brown fox jumps over the lazy dog";
var longestWord = sentence.Split(" ")
.Aggregate((longestSoFar, nextWord) =>
nextWord.Length > longestSoFar.Length ? nextWord : longestSoFar);
Console.WriteLine($"longest word is '{longestWord}'"); // longest word is 'quick'
- 实现
Stirng.Join方法,连接字符串数组中每个字符串,并用,分隔
var chars = new[] { "a", "b", "c", "d" };
var withSeparator = chars.Aggregate((a, b) => a + ',' + b);
Console.WriteLine($"withSeparator: {withSeparator}"); // withSeparator: a,b,c,d
当然,上面的Aggregate方法的两个参数都是集合的元素类型,如果想要将第一个累加结果参数设置为任意类型,可以用它的重载版本。它多一个参数seed,不仅可以定义累加结果的初始值,也间接指定了累加结果参数的类型。
- 比如可以统计句子的单词数量
var sentence = "The quick brown fox jumps over the lazy dog";
var countOfWords = sentence.Split(" ")
.Aggregate(
0,
(totalCount, nextWord) => totalCount + 1);
Console.WriteLine($"countOfWords: {countOfWords}"); // countOfWords: 9
不带 seed 的方法取第一个元素作为累加初始值,从第二个元素开始遍历计算;而带 seed 的方法
用 seed 作为初始值,从第一个元素就开始遍历,若遍历的集合为空,则直接返回 Seed 值。
31、Zip
用于将指定函数应用于两个集合的对应元素,返回结果集合。可以理解为衣服上的拉链,将左右两个集合拉在一起。两个集合元素数量需要相等,否则多余的元素将会忽略。函数中的两个参数分别代表来自两个集合的对应元素。
比如将两个集合对应元素组合成一个字符串:
var numbers = new[] { 1, 2, 3, 4, 5 };
var words = new[] { "The", "quick", "brown", "fox", "jumps" };
var zipped = numbers.Zip(words,
(number, word) => $"{number}. {word}");
打印结果:
1. The
2. quick
3. brown
4. fox
5. jumps
来看一个应用,比如我们有一系列连续的点坐标,我们需要获取相邻两个点间的所有路径长度。则可以自己和自己进行Zip,只需要第二个集合偏移一个元素位置即可。
var points = new[]
{
new Point(10, 10),
new Point(10, 11),
new Point(11, 12),
new Point(11, 14),
new Point(12, 16)
};
var distances = points.Zip(points.Skip(1),
(point1, point2) => GetDistance(point1, point2));
若不指定函数,则返回的集合中,每个元素是Zip前两个集合元素所组成的元组。
七、查询语法
1、语法
先来看一个例子,对数字数组进行排序:
var numbers = new[] { 9, 3, 7, 1, 2 };
var orderedNumbers = from number in numbers
orderby number
select number;
其中这个from ... in ...很像foreach的格式,表示对集合的每个元素进行操作,之后进行排序并select返回。其中这个select必须存在,否则会报错(除非使用后面讲到的group子句结束查询)。
那么LINQ查询语法的格式如下:
先以from in开头,之后中间应用所需的任何操作,如orderby、where、group by、join等等,最后以select或者group by结尾。
如果以select结尾那么返回值是IEnumerale<T>,如果是group by结尾,则返回一个分组集合,即IEnumerable<IGrouping<Key, Values>>。

我们还可以在中间操作部分使用let关键字来声明临时变量,提高可读性并且避免重复计算。举个例子,比如对数组的数字按照平方根的向下取整排序,并返回平方根的向下取整结果:
var numbers = new[] { 9, 3, 7, 1, 2 };
var orderedNumbers = from number in numbers
let floorOfSquare = MathF.Floor(MathF.Sqrt(number))
orderby floorOfSquare
select floorOfSquare;
若不使用let关键字,必须要重复写两次这个平方根向下取整的式子。
需要注意的是,如果查询语法没有对应方法的等效项,比如Reverse,就需要结合方法语法的API实现。
2、orderby
用于对集合进行排序。
上一讲已经提到了如何对数组升序排序:
orderby field
降序排序只需添加descending关键字:
orderby field descending
如果需要按多个条件按优先级进行排序,只需,分隔多个字段即可,其中每个字段后面都可以选择添加descending。将按从左往右的顺序进行优先级排序,比如field1相同的元素按照field2排,以此类推。
orderby field1 [descending], field2 [descending], ...
3、where
用于按条件过滤集合。
使用方式和方法语法的Where极其相似,不过遗憾的是,对于方法语法中Where带元素索引的重载版本,查询语法却没有,因此这种情况下推荐使用方法语法。
4、select
将集合中每个元素投影为新形式。
这与方法语法的Select的使用基本相同,这里不再赘述。
5、select many
之前我们提到,对于嵌套集合的处理,方法语法可以使用SelectMany。而查询语法对嵌套集合的处理则相对简单,只需要使用多次from子句即可。
比如将二维数组展平为一维数组,只需使用两次from子句:
var nestedListOfNumbers = new List<List<int>>
{
new List<int> { 1, 2, 3},
new List<int> { 4,5,6},
new List<int> { 5,6},
};
var allNumbersFromNestedList = from list in nestedListOfNumbers
from number in list
select number;
多维数组的展平也是一样的,多使用几次from子句即可,这里每个from相当于一层foreach。
我们再拿之前学习SelectMany的例子吧,为了避免来回翻动,我把两种方式都列出来:
- 筛选出所有名字以
J开头的主人的所有宠物组成的列表。方法语法和查询语法分别如下:
// 方法语法
var allPetsOfJPeople = people
.Where(person => person.Name.StartsWith("J"))
.SelectMany(person => person.Pets);
// 查询语法
var allPetsOfJPeople = from person in people
where person.Name.StartsWith("J")
from pet in person.Pets
select pet;
- 获取两个数组的笛卡尔积,方法语法和查询语法分别如下:
var numbers = new[] { 1, 2, 3 };
var letters = new[] { 'A', 'B', 'C' };
// 方法语法
var carthesianProduct = numbers.SelectMany(
_ => letters,
(number, letter) => $"{number},{letter}");
// 查询语法
var carthesianProduct = from number in numbers
from letter in letters
select $"{number},{letter}";
6、group by
按指定条件对集合进行分组。
这和之前的GroupBy非常类似。使用group by时,可以省略最后的select子句。
来看个简单例子,将宠物按类型分组:
var petsGroupedByType = from pet in pets
group pet by pet.PetType;
group by还有一个into关键字,对应方法语法GroupBy中的重载版本,可以将分组结果的每一组传入into后面的对象中,以获取分组的Key和该分组的值(即该组元素的集合)。
我们再次使用方法语法GroupBy中所用到的例子:对所有宠物 按体重向下取整值 进行分组,并且想获取每个分组的最小和最大体重,所有分组最终按照分组的Key值排序(按向下取整值排序)。
为了避免来回查看,这里先贴出方法语法的代码:
// 方法语法
var weightGroups = pets.GroupBy(
pet => Math.Floor(pet.Weight),
(floorOfWeight, allPetsInThisGroup) => new
{
FloorOfWeight = floorOfWeight,
MinWeightInThisGroup = allPetsInThisGroup.Min(pet => pet.Weight),
MaxWeightInThisGroup = allPetsInThisGroup.Max(pet => pet.Weight)
})
.OrderBy(weightGroup => weightGroup.FloorOfWeight);
查询语法代码如下:(当然肯定有多种等价的方式,这里尝试用了let关键字)
// 查询语法
var weightGroups = from pet in pets
group pet by Math.Floor(pet.Weight) into weightGroup
orderby weightGroup.Key
let weightsInThisGroup = from pet in weightGroup
orderby pet.Weight
select pet.Weight
select new
{
FloorOfWeight = weightGroup.Key,
MinWeightInThisGroup = weightsInThisGroup.First(),
MaxWeightInThisGroup = weightsInThisGroup.Last()
};
7、join
用于执行内连接和左连接。
之前在方法语法中的Join和GroupJoin中已经讲解过了内连接和左连接,所以这里直接讲一下如何实现。
- 内连接,还是之前宠物、宠物诊所预约和宠物诊所的例子,对三者进行内连接,查询语法如下。需要注意这里必须使用
equals关键字比较匹配键。
var petsAppointmentsFullInfo = from pet in pets
join appointment in veterinaryClinicAppointments
on pet.Id equals appointment.PetId
join clinic in veterinaryClinics
on appointment.ClinicId equals clinic.Id
select $"Pet name: {pet.Name}," +
$" appointment date: {appointment.Date}," +
$" at clinic {clinic.Name}";
进行左连接之前,先来看看之前方法语法提到过的GroupJoin的例子:将宠物集合与预约集合进行连接,并且相同宠物的预约会分到同一组。
var petsGroupedAppointments = pets
.GroupJoin(clinicAppointments,
pet => pet.Id,
appointmet => appointmet.PetId,
(pet, appointments) => new { Pet = pet, Appointments = appointments });
而在查询语法中,我们可以使用在join后面使用into将分组结果存储到后面的对象中,这个对象就代表每一个分组结果,和group by的into后面鹅对象是一样的。
使用这个into,我们就可以实现和上面这个GroupJoin一样的效果(注意这里into之后,join和in中间的appointment变量就用不了了,因为被into后面的appointments替代了):
var petsGroupedAppointments =
from pet in pets
join appointment in clinicAppointments
on pet.Id equals appointment.PetId into appointments
select new { Pet = pet, Appointments = appointments };
既然实现了GroupJoin,那么就可以按照之前的GroupJoin + SelectMany + DefaultIfEmpty的思路完成左连接,只不过select many在查询语法中只需要通过from完成。
- 左连接,还是之前
GroupJoin进行左连接的那个例子:即使宠物没有预约也要打印出来。
var leftJoin = from pet in pets
join appointment in clinicAppointments
on pet.Id equals appointment.PetId into appointments
from appointment in appointments.DefaultIfEmpty()
select $"Pet name: {pet.Name}, appointment date: {appointment?.Date}";
可以发现,不管是内连接还是左连接,相比方法语法的Join,查询语法更加简洁、清晰、易懂。