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根据类型筛选元素,只返回TResult类型的元素
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。这个接口派生自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查询中的变量

在为分组编写的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接口定义了扩展方法,但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();
}

文章作者: 陈德强
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 陈德强 !
¥
  目录