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子句,可以合并多个表达式。例如,找出赢得至少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子句。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 。这个接口派生自IEnumerable 接口,但包含一个额外的方法CreateOrderedEnumerable ()。这个方法用于进一步给序列排序。如果根据关键字选择器来排序,其中有两项相同,就可以使用ThenBy()和ThenByDescending ()方法继续排序。这两个方法需要IOrderEnumerable 接口才能工作,但也返回这个接口。所以,可以添加任意多个ThenBy()和ThenByDescending()方法,对集合排序。
使用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查询中,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 20Empty()方法返回一个不返回值的迭代器,它可以用于需要一个集合的参数,其中可以给参数传递空集合。
Repeat()方法返回一个迭代器,该迭代器把同一个值重复特定的次数。
System.Linq名称空间中包含的类ParallelEnumerable可以分解查询的工作,使其分布在多个线程上。尽管Enumerable类给IEnumerable 接口定义了扩展方法,但ParallelEnumerable类的大多数扩展方法是ParallelQuery 类的扩展。一个重要的异常是AsParallel()方法,它扩展了IEnumerable 接口,返回ParallelQuery 类,所以正常的集合类可以以并行方式查询。
为了说明并行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 接口,所以可以对简单的数组调用它。AsParallel()方法返回ParallelQuery 。因为返回的类型,所以编译器选择的Where()方法是ParallelEnumerable.Where(),而不是Enumerable.Where()。在下面的代码中,Select()和Average()方法也来自ParallelEnumerable类。与Enumerable类的实现代码相反,对于ParallelEnumerable类,查询是分区的,以便多个线程可以同时处理该查询。集合可以分为多个部分,其中每个部分由不同的线程处理,以筛选其余项。完成分区的工作后,就需要合并,获得所有部分的总和。
var res = data.AsParallel().Where(x => Math.Log(x) < 4). Select(x => x).Average();运行这行代码会启动任务管理器,这样就可以看出系统的所有CPU都在忙碌。如果删除AsParallel()方法,就不可能使用多个CPU。当然,如果系统上没有多个CPU,就不会看到并行版本带来的改进。
AsParallel()方法不仅扩展了IEnumerable 接口,还扩展了Partitioner类。通过它,可以影响要创建的分区。
Partitioner类用System.Collection.Concurrent名称空间定义,并且有不同的变体。Create()方法接受实现了IList 类的数组或对象。根据这一点,以及Boolean类型的参数loadBalance和该方法的一些重载版本,会返回一个不同的Partitioner类型。对于数组,使用派生自抽象基类OrderablePartitioner 的DynamicPartitionerForArray 类和StaticPartitionerFor-Array 类。
手工创建一个分区器,而不是使用默认的分区器:
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(); }