LINQ
简介
LINQ(Language Integrated Query,语言集成查询)在C#编程语言中集成了查询语法,可以用相同的语法访问不同的数据源。LINQ提供了不同数据源的抽象层,所以可以使用相同的语法。
from r in Formula1.GetChampions()
where r.Country == "Brazil"
orderby r.Wins descending
select r;
子句from、where、orderby、descending和select都是这个查询中预定义的关键字。
查询表达式必须以from子句开头,以select或group子句结束。在这两个子句之间,可以使用where、orderby、join、let和其他from子句。
标准查询操作符
标准查询操作符 | 说明 |
---|---|
Where OfType |
筛选操作符定义了返回元素的条件。在Where查询操作符中可以使用谓词,例如,lambda表达式定义的谓词,来返回布尔值。OfType |
Select SelectMany |
投射操作符用于把对象转换为另一个类型的新对象。Select和SelectMany定义了根据选择器函数选择结果值的投射 |
OrderBy ThenBy OrderByDescending ThenByDescending Reverse |
排序操作符改变所返回的元素的顺序。OrderBy按升序排序,OrderByDescending按降序排序。如果第一次排序的结果很类似,就可以使用ThenBy和ThenByDescending操作符进行第二次排序。Reverse反转集合中元素的顺序 |
Join GroupJoin |
连接操作符用于合并不直接相关的集合。使用Join操作符,可以根据键选择器函数连接两个集合,这类似于SQL中的JOIN。GroupJoin操作符连接两个集合,组合其结果 |
GroupBy ToLookup |
组合操作符把数据放在组中。GroupBy操作符组合有公共键的元素。ToLookup通过创建一个一对多字典,来组合元素 |
Any All Contains |
如果元素序列满足指定的条件,限定符操作符就返回布尔值。Any、All和Contains都是限定符操作符。Any确定集合中是否有满足谓词函数的元素;All确定集合中的所有元素是否都满足谓词函数;Contains检查某个元素是否在集合中 |
Take Skip TakeWhile SkipWhile |
分区操作符返回集合的一个子集。Take、Skip、TakeWhile和SkipWhile都是分区操作符。使用它们可以得到部分结果。使用Take必须指定要从集合中提取的元素个数;Skip跳过指定的元素个数,提取其他元素;TakeWhile提取条件为真的元素,SkipWhile跳过条件为真的元素 |
Distinct Union Intersect Except Zip |
Set操作符返回一个集合。Distinct从集合中删除重复的元素。除了Distinct之外,其他Set操作符都需要两个集合。Union返回出现在其中一个集合中的唯一元素。Intersect返回两个集合中都有的元素。Except返回只出现在一个集合中的元素。Zip把两个集合合并为一个 |
First FirstOrDefault Last LastOrDefault ElementAt ElementAtOrDefault Single SingleOrDefault |
这些元素操作符仅返回一个元素。First返回第一个满足条件的元素。FirstOrDefault类似于First,但如果没有找到满足条件的元素,就返回类型的默认值。Last返回最后一个满足条件的元素。ElementAt指定了要返回的元素的位置。Single只返回一个满足条件的元素。如果有多个元素都满足条件,就抛出一个异常。所有的XXOrDefault方法都类似于以相同前缀开头的方法,但如果没有找到该元素,它们就返回类型的默认值 |
Count Sum Min Max Average Aggregate |
聚合操作符计算集合的一个值。利用这些聚合操作符,可以计算所有值的总和、所有元素的个数、值最大和最小的元素,以及平均值等 |
ToArray AsEnumerable ToList ToDictionary Cast |
这些转换操作符将集合转换为数组:IEnumerable、IList、IDictionary等。Cast方法把集合的每个元素类型转换为泛型参数类型 |
Empty Range Repeat |
这些生成操作符返回一个新集合。使用Empty时集合是空的;Range返回一系列数字;Repeat返回一个始终重复一个值的集合 |
筛选
使用where子句,可以合并多个表达式。例如,找出赢得至少15场比赛的巴西和奥地利赛车手。传递给where子句的表达式的结果类型应是布尔类型:
var racers = from r in Formula1.GetChampions()
where r.Wins > 15 && (r.Country == "Brazil" || r.Country == "Austria")
select r;
foreach (var r in racers)
{
WriteLine($"{r:A}");
}
用索引筛选
不能使用LINQ查询的一个例子是Where()方法的重载。在Where()方法的重载中,可以传递第二个参数——索引。索引是筛选器返回的每个结果的计数器。可以在表达式中使用这个索引,执行基于索引的计算。下面的代码由Where()扩展方法调用,它使用索引返回姓氏以A开头、索引为偶数的赛车手(代码文件EnumerableSample/Program.cs):
var racers = Formula1.GetChampions().
Where((r, index) => r.LastName.StartsWith("A") && index % 2 ! = 0);
foreach (var r in racers)
{
WriteLine($"{r:A}");
}
注:索引及下标,入abc[2].
类型筛选
为了进行基于类型的筛选,可以使用OfType()扩展方法。这里数组数据包含string和int对象。使用OfType()扩展方法,把string类传送给泛型参数,就从集合中仅返回字符串:
object[] data = { "one", 2, 3, "four", "five", 6 };
var query = data.OfType<string>();
foreach (var s in query)
{
WriteLine(s);
}
复合的from子句
如果需要根据对象的一个成员进行筛选,而该成员本身是一个系列,就可以使用复合的from子句。Racer类定义了一个属性Cars,其中Cars是一个字符串数组。要筛选驾驶法拉利的所有冠军,可以使用如下所示的LINQ查询。第一个from子句访问从Formula1.Get Champions()方法返回的Racer对象,第二个from子句访问Racer类的Cars属性,以返回所有string类型的赛车。接着在where子句中使用这些赛车筛选驾驶法拉利的所有冠军(代码文件EnumerableSample/Program.cs)。
var ferrariDrivers = from r in Formula1.GetChampions()
from c in r.Cars
where c == "Ferrari"
orderby r.LastName
select r.FirstName + " " + r.LastName;
排序
要对序列排序,前面使用了orderby子句。下面复习一下前面使用的例子,但这里使用orderby descending子句。其中赛车手按照赢得比赛的次数进行降序排序,赢得比赛的次数用关键字选择器指定:
var racers = from r in Formula1.GetChampions()
where r.Country == "Brazil"
orderby r.Wins descending
select r;
OrderBy()和OrderByDescending()方法返回IOrderEnumerable
使用LINQ查询时,只需要把所有用于排序的不同关键字(用逗号分隔开)添加到orderby子句中。在下例中,所有的赛车手先按照国家排序,再按照姓氏排序,最后按照名字排序。添加到LINQ查询结果中的Take()扩展方法用于返回前10个结果:
var racers = (from r in Formula1.GetChampions()
orderby r.Country, r.LastName, r.FirstName
select r).Take(10);
分组
要根据一个关键字值对查询结果分组,可以使用group子句。现在一级方程式冠军应按照国家分组,并列出一个国家的冠军数。子句group r by r.Country into g根据Country属性组合所有的赛车手,并定义一个新的标识符g,它以后用于访问分组的结果信息。group子句的结果根据应用到分组结果上的扩展方法Count()来排序,如果冠军数相同,就根据关键字来排序,该关键字是国家,因为这是分组所使用的关键字。where子句根据至少有两项的分组来筛选结果,select子句创建一个带Country和Count属性的匿名类型。
var countries = from r in Formula1.GetChampions()
group r by r.Country into g
orderby g.Count() descending, g.Key
where g.Count() >= 2
select new
{
Country = g.Key,
Count = g.Count()
};
foreach (var item in countries)
{
WriteLine($"{item.Country, -10} {item.Count}");
}
例程:
using System;
using System.Collections.Generic;
using System.Linq;
namespace CSharpTest
{
class progress
{
class Person
{
public string Name { set; get; }
public int Age { set; get; }
public string Gender { set; get; }
public override string ToString() => Name;
}
public static int Main()
{
List<Person> personList = new List<Person>
{
new Person
{
Name = "P1", Age = 18, Gender = "Male"
},
new Person
{
Name = "P2", Age = 19, Gender = "Male",
},
new Person
{
Name = "P2", Age = 17,Gender = "Female",
}
};
//var groups = personList.GroupBy(p => p.Gender);
var groups = from p in personList
group p by p.Gender;
foreach (var group in groups)
{
Console.WriteLine(group.Key);
foreach (var person in group)
{
Console.WriteLine($"\t{person.Name},{person.Age}");
}
}
return 0;
}
}
}
Male
P1,18
P2,19
Female
P2,17
LINQ查询中的变量
在为分组编写的LINQ查询中,Count方法调用了多次。使用let子句可以改变这种方式。let允许在LINQ查询中定义变量:
var countries = from r in Formula1.GetChampions()
group r by r.Country into g
let count = g.Count()
orderby count descending, g.Key
where count >= 2
select new
{
Country = g.Key,
Count = count
};
对嵌套的对象分组
如果分组的对象应包含嵌套的序列,就可以改变select子句创建的匿名类型。在下面的例子中,所返回的国家不仅应包含国家名和赛车手数量这两个属性,还应包含赛车手的名序列。这个序列用一个赋予Racers属性的from/in内部子句指定,内部的from子句使用分组标识符g获得该分组中的所有赛车手,用姓氏对它们排序,再根据姓名创建一个新字符串:
var countries = from r in Formula1.GetChampions()
group r by r.Country into g
let count = g.Count()
orderby count descending, g.Key
where count >= 2
select new
{
Country = g.Key,
Count = count,
Racers = from r1 in g
orderby r1.LastName
select r1.FirstName + " " + r1.LastName
};
foreach (var item in countries)
{
WriteLine($"{item.Country, -10} {item.Count}");
foreach (var name in item.Racers)
{
Write($"{name}; ");
}
WriteLine();
}
内连接
使用join子句可以根据特定的条件合并两个数据源,但之前要获得两个要连接的列表。在一级方程式比赛中,有赛车手冠军和车队冠军。赛车手从GetChampions()方法中返回,车队从GetConstructorChampions()方法中返回。现在要获得一个年份列表,列出每年的赛车手冠军和车队冠军。为此,先定义两个查询,用于查询赛车手和车队:
var racers = from r in Formula1.GetChampions()
from y in r.Years
select new
{
Year = y,
Name = r.FirstName + " " + r.LastName
};
var teams = from t in Formula1.GetContructorChampions()
from y in t.Years
select new
{
Year = y,
Name = t.Name
};
有了这两个查询,再通过join子句,根据赛车手获得冠军的年份和车队获得冠军的年份进行连接。select子句定义了一个新的匿名类型,它包含Year、Racer和Team属性。
var racersAndTeams = (from r in racers
join t in teams on r.Year equals t.Year
select new
{
r.Year,
Champion = r.Name,
Constructor = t.Name
}).Take(10);
WriteLine("Year World Champion\t Constructor Title");
foreach (var item in racersAndTeams)
{
WriteLine($"{item.Year}: {item.Champion, -20} {item.Constructor}");
}
左外连接
上一个连接示例的输出从1958年开始,因为从这一年开始,才同时有了赛车手冠军和车队冠军。赛车手冠军出现得更早一些,是在1950年。使用内连接时,只有找到了匹配的记录才返回结果。为了在结果中包含所有的年份,可以使用左外连接。左外连接返回左边序列中的全部元素,即使它们在右边的序列中并没有匹配的元素。
下面修改前面的LINQ查询,使用左外连接。左外连接用join子句和DefaultIfEmpty方法定义。如果查询的左侧(赛车手)没有匹配的车队冠军,那么就使用DefaultIfEmpty方法定义其右侧的默认值:
var racersAndTeams =
(from r in racers
join t in teams on r.Year equals t.Year into rt
from t in rt.DefaultIfEmpty()
orderby r.Year
select new
{
Year = r.Year,
Champion = r.Name,
Constructor = t == null ? "no constructor championship" : t.Name
}).Take(10);
组连接
左外连接使用了组连接和into子句。它有一部分语法与组连接相同,只不过组连接不使用DefaultIfEmpty方法。
使用组连接时,可以连接两个独立的序列,对于其中一个序列中的某个元素,另一个序列中存在对应的一个项列表。
TODO
集合操作
TODO
合并
Zip()方法允许用一个谓词函数把两个相关的序列合并为一个。
首先,创建两个相关的序列,它们使用相同的筛选(国家意大利)和排序方法。对于合并,这很重要,因为第一个集合中的第一项会与第二个集合中的第一项合并,第一个集合中的第二项会与第二个集合中的第二项合并,依此类推。如果两个序列的项数不同,Zip()方法就在到达较小集合的末尾时停止。
第一个集合中的元素有一个Name属性,第二个集合中的元素有LastName和Starts两个属性。
在racerNames集合上使用Zip()方法,需要把第二个集合(racerNamesAndStarts)作为第一个参数。第二个参数的类型是Func<TFirst, TSecond, TResult>。这个参数实现为一个lambda表达式,它通过参数first接收第一个集合的元素,通过参数second接收第二个集合的元素。其实现代码创建并返回一个字符串,该字符串包含第一个集合中元素的Name属性和第二个集合中元素的Starts属性:
var racerNames = from r in Formula1.GetChampions()
where r.Country == "Italy"
orderby r.Wins descending
select new
{
Name = r.FirstName + " " + r.LastName
};
var racerNamesAndStarts = from r in Formula1.GetChampions()
where r.Country == "Italy"
orderby r.Wins descending
select new
{
LastName = r.LastName,
Starts = r.Starts
};
var racers = racerNames.Zip(racerNamesAndStarts,
(first, second) => first.Name + ", starts: " + second.Starts);
foreach (var r in racers)
{
WriteLine(r);
}
分区
扩展方法Take()和Skip()等的分区操作可用于分页,例如,在第一个页面上只显示5个赛车手,在下一个页面上显示接下来的5个赛车手等。
在下面的LINQ查询中,把扩展方法Skip()和Take()添加到查询的最后。Skip()方法先忽略根据页面大小和实际页数计算出的项数,再使用Take()方法根据页面大小提取一定数量的项:
int pageSize = 5;
int numberPages = (int)Math.Ceiling(Formula1.GetChampions().Count() / (double)pageSize);
for (int page = 0; page < numberPages; page++)
{
WriteLine($"Page {page}");
var racers = (from r in Formula1.GetChampions()
orderby r.LastName, r.FirstName
select r.FirstName + " " + r.LastName).
Skip(page * pageSize).Take(pageSize);
foreach (var name in racers)
{
WriteLine(name);
}
WriteLine();
}
下面输出了前3页:
Page 0
Fernando Alonso
Mario Andretti
Alberto Ascari
Jack Brabham
Jenson Button
Page 1
Jim Clark
Juan Manuel Fangio
Nino Farina
Emerson Fittipaldi
Mika Hakkinen
Page 2
Lewis Hamilton
Mike Hawthorn
Damon Hill
Graham Hill
Phil Hill
聚合操作符
聚合操作符(如Count、Sum、Min、Max、Average和Aggregate操作符)不返回一个序列,而返回一个值。
Count()扩展方法返回集合中的项数。下面的Count()方法应用于Racer的Years属性,来筛选赛车手,只返回获得冠军次数超过3次的赛车手。因为同一个查询中需要使用同一个计数超过一次,所以使用let子句定义了一个变量numberYears:
var query = from r in Formula1.GetChampions()
let numberYears = r.Years.Count()
where numberYears >= 3
orderby numberYears descending, r.LastName
select new
{
Name = r.FirstName + " " + r.LastName,
TimesChampion = numberYears
};
foreach (var r in query)
{
WriteLine($"{r.Name} {r.TimesChampion}");
}
Sum()方法汇总序列中的所有数字,返回这些数字的和。下面的Sum()方法用于计算一个国家赢得比赛的总次数。首先根据国家对赛车手分组,再在新创建的匿名类型中,把Wins属性赋予某个国家赢得比赛的总次数:
var countries = (from c in
from r in Formula1.GetChampions()
group r by r.Country into c
select new
{
Country = c.Key,
Wins = (from r1 in c
select r1.Wins).Sum()
}
orderby c.Wins descending, c.Country
select c).Take(5);
foreach (var country in countries)
{
WriteLine("{country.Country} {country.Wins}");
}
方法Min()、Max()、Average()和Aggregate()的使用方式与Count()和Sum()相同。Min()方法返回集合中的最小值,Max()方法返回集合中的最大值,Average()方法计算集合中的平均值。对于Aggregate()方法,可以传递一个lambda表达式,该表达式对所有的值进行聚合。
转换操作符
本章前面提到,查询可以推迟到访问数据项时再执行。在迭代中使用查询时,查询会执行。而使用转换操作符会立即执行查询,把查询结果放在数组、列表或字典中。
在下面的例子中,调用ToList()扩展方法,立即执行查询,得到的结果放在List
List<Racer> racers = (from r in Formula1.GetChampions()
where r.Starts > 150
orderby r.Starts descending
select r).ToList();
foreach (var racer in racers)
{
WriteLine($"{racer} {racer:S}");
}
生成操作符
生成操作符Range()、Empty()和Repeat()不是扩展方法,而是返回序列的正常静态方法。在LINQ to Objects中,这些方法可用于Enumerable类。
有时需要填充一个范围的数字,此时就应使用Range()方法。这个方法把第一个参数作为起始值,把第二个参数作为要填充的项数:
var values = Enumerable.Range(1, 20);
foreach (var item in values)
{
Write($"{item} ", item);
}
WriteLine();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
Empty()方法返回一个不返回值的迭代器,它可以用于需要一个集合的参数,其中可以给参数传递空集合。
Repeat()方法返回一个迭代器,该迭代器把同一个值重复特定的次数。
并行LINQ
System.Linq名称空间中包含的类ParallelEnumerable可以分解查询的工作,使其分布在多个线程上。尽管Enumerable类给IEnumerable
并行查询
为了说明并行LINQ(Parallel LINQ, PLINQ),需要一个大型集合。对于可以放在CPU的缓存中的小集合,并行LINQ看不出效果。在下面的代码中,用随机值填充一个大型的int集合:
static IEnumerable<int> SampleData()
{
const int arraySize = 50000000;
var r = new Random();
return Enumerable.Range(0, arraySize).Select(x => r.Next(140)).ToList();
}
现在可以使用LINQ查询筛选数据,进行一些计算,获取所筛选数据的平均数。该查询用where子句定义了一个筛选器,仅汇总对应值小于20的项,接着调用聚合函数Sum()方法。与前面的LINQ查询的唯一区别是,这次调用了AsParallel()方法。
var res = (from x in data.AsParallel()
where Math.Log(x) < 4
select x).Average();
与前面的LINQ查询一样,编译器会修改语法,以调用AsParallel()、Where()、Select()和Average()方法。AsParallel()方法用ParallelEnumerable类定义,以扩展IEnumerable
var res = data.AsParallel().Where(x => Math.Log(x) < 4).
Select(x => x).Average();
运行这行代码会启动任务管理器,这样就可以看出系统的所有CPU都在忙碌。如果删除AsParallel()方法,就不可能使用多个CPU。当然,如果系统上没有多个CPU,就不会看到并行版本带来的改进。
分区器
AsParallel()方法不仅扩展了IEnumerable
Partitioner类用System.Collection.Concurrent名称空间定义,并且有不同的变体。Create()方法接受实现了IList
手工创建一个分区器,而不是使用默认的分区器:
var result = (from x in Partitioner.Create(data, true).AsParallel()
where Math.Log(x) < 4
select x).Average();
也可以调用WithExecutionMode()和WithDegreeOfParallelism()方法,来影响并行机制。对于WithExecutionMode()方法可以传递ParallelExecutionMode的一个Default值或者ForceParallelism值。默认情况下,并行LINQ避免使用系统开销很高的并行机制。对于WithDegreeOf Parallelism()方法,可以传递一个整数值,以指定应并行运行的最大任务数。查询不应使用全部CPU,这个方法会很有用。
取消
.NET提供了一种标准方式,来取消长时间运行的任务,这也适用于并行LINQ。
要取消长时间运行的查询,可以给查询添加WithCancellation()方法,并传递一个CancellationToken令牌作为参数。CancellationToken令牌从CancellationTokenSource类中创建。该查询在单独的线程中运行,在该线程中,捕获一个OperationCanceledException类型的异常。如果取消了查询,就触发这个异常。在主线程中,调用CancellationTokenSource类的Cancel()方法可以取消任务。
var cts = new CancellationTokenSource();
Task.Run(() =>
{
try
{
var res = (from x in data.AsParallel().WithCancellation(cts.Token)
where Math.Log(x) < 4
select x).Average();
WriteLine($"query finished, sum: {res}");
}
catch (OperationCanceledException ex)
{
WriteLine(ex.Message);
}
});
WriteLine("query started");
Write("cancel? ");
string input = ReadLine();
if (input.ToLower().Equals("y"))
{
// cancel!
cts.Cancel();
}