《深入理解C#》整理7-查询表达式和LINQ to Objects
一、LINQ介绍
1、LINQ中的基础概念
1.1、序列
序列通过IEnumerable和IEnumerable
序列是LINQ的基础。在你看到一个查询表达式的时候,应该要想到它所涉及的序列:一开始总是存在至少一个序列,且通常在中间过程会转换为其他序列,也可能和更多的序列连接在一起。举个例子:结果不是字符串"Holly"和"Jon"——而是IEnumerable
1.2、延迟执行与流处理
查询表达式被创建的时候,不会处理任何数据,也不会访问原始的人员列表。而是在内存中生成了这个查询的表现形式。其判断及转换都是通过委托实例来表示的。只有在访问结果IEnumerable
LINQ的这个特点称为延迟执行。在最终结果的第一个元素被访问的时候,Select转换才会为它的第一个元素调用Where转换。而Where转换会访问列表中的第一个元素,检查这个谓词是否匹配(在上个例子中,是匹配的),并把这个元素返回给Select。最后,依次提取出名称作为结果返回。如下图:当我们使用foreach循环语句打印结果中的各个元素时,示例查询表达式在运转中的前几个阶段。
在之前的章节中,我们已经看到过这样的一个例子:Enumerable. Reverse方法需要提取所有可用的数据,以便把最后一个原始元素作为结果序列的第一个元素返回。这使得Reverse成为一个缓冲操作——它在效率上(甚至可行性上)对整个运算都有巨大的影响。如果你无法承受一次性把所有数据都读到内存中的开销,就不能使用缓冲操作。正如同流处理依赖于你所执行的操作,有些转换一调用就会发生,而不会延迟执行。这称为立即执行。一般来说,返回另外一个序列的操作(通常是IEnumerable
1.3、标准查询操作符
LINQ的标准查询操作符(如Select、Where方法)是一个转换的集合,具有明确的含义。要跨多个数据源提供一致的查询框架,这是极其重要的。
2、定义示例数据模型
下面定义一个数据模型供后续举例使用:NotificationSubscription的作用是,每次相关项目的缺陷被创建或改变后,都发一封电子邮件到特定地址。
二、简单的开始:选择元素
1、以数据源作为开始,以选择作为结束
在C# 3中每个查询表达式都以同样的方式开始——声明一个数据序列的数据源:from element in source
,element只是一个标识符,它前面可以放置一个类型名称。source是一个普通的表达式。在第一个子句出现之后,许多不同的事情会发生,不过迟早都会以一个select子句或group子句来结束。这里我们使用select子句,它的语法也是很简单的:select expression
,select子句被称为投影。结合起来可以得到:from user in SampleData.AllUser select user
2、编译器转译是查询表达式基础的转译
编译器把查询表达式转译为普通的C#代码,这是支持C# 3查询表达式的基础。它是以一种机v械的方式来进行转换的,不会去理解代码、应用类型引用、检查方法调用的有效性或执行编译器要执行的任何正常工作。编译器会将上述的代码转换为:SampleData.AllUsers.Select(user=>user)
C# 3编译器进一步正确地编译查询表达式之前,就把它转译为了这样的代码。它只是对代码进行转译,并让下一个编译阶段来处理查找适当方法的工作——不管这个方法是直接包含的成员,还是扩展方法。参数是一个适当的委托类型或者一个和类型T对应的Expression
实际上几乎所有LINQ提供器都把数据显示为IEnumerable
3、范围变量和重要的投影
之前例子from user in SampleData.AllUser select user
中的user为声明的范围变量,select user为投影表达式。范围变量不像其他种类的变量。在某些方面它根本就不是变量。它们只能用于查询表达式中,实际代表了从一个表达式传递给另外一个表达式的上下文信息。它们表示了特定序列中的一个元素,而且它们被用于编译器转译中,以便把其他表达式轻易地转译为Lambda表达式SampleData.AllUsers.Select(user=>user)
,表达式的左边——提供参数名称的部分来自于范围变量的声明,而右边来自于select子句。
我们将user改为user.Name作为投影,即可看到结果变为字符串序列,而不是User对象了。编译器准许这样做,是由于从Enumerable所选择的Select扩展方法实际上具有如下签名:static IEnumerable<TResult> Select<TSource,TResult>(this IEnumerable<TSource> source,Func<TSource,TResult> selector)
。在将Lambda表达式转换为Func<TSource, TResult>的时候,类型推断发挥了作用。它首先根据SampleData.AllUsers的类型推断出TSource为User,这样就知道了Lambda表达式的参数类型,并因此将user.Name作为返回string类型的属性访问表达式,也就可以推断出TResult为string。这就是Lambda表达式允许使用隐式类型参数的原因,也就是会存在如此复杂的类型推断规则的原因:这些都是LINQ引擎的“齿轮”和“活塞”。
4、Cast、OfType和显式类型的范围变量
大多数时候,范围变量都可以是隐式类型,另外我们可以使用所需类型全部指定了的泛型集合。但如果我们想对一个ArrayList或一个object[]执行查询,就只有依赖两个标准查询操作符来解决这个问题:Cast和OfType,其中Cast是查询表达式语法直接支持的。
Cast通过把每个元素都转换为目标类型(遇到不是正确类型的任何元素的时候,就会出错)来处理,而OfType首先进行一个测试,以跳过任何具有错误类型的元素。而在你引入了具有显式类型的范围变量后,编译器就调用Cast来保证在查询表达式的剩余部分中使用的序列具有合适的类型。相关示例如下:
其中显式指定后转译后的代码会变为list.Cast
三、对序列进行过滤和排序
1、使用where子句进行过滤
Where子句的格式为where 过滤表达式
。编译器把这个子句转译为带有Lambda表达式的Where方法调用,它使用合适的范围变量作为这个Lambda表达式的参数,而以过滤表达式作为主体。过滤表达式当作进入数据流的每个元素的谓词,只有返回true的元素才能出现在结果序列中。使用多个where子句,会导致多个链接在一起的Where调用——只有满足所有谓词的元素才能进入结果序列。
2、退化的查询表达式
到目前为止,我们所有转换后的查询表达式都包含了对Select的调用。如果我们的select子句什么都不做,只是返回同给定的序列相同的序列,编译器会删除所有对Select的调用。当然,前提是在查询表达式中还有其他操作可执行时才这么做。如:select defect in SampleData.AllDefects select defect
,这就是所谓的退化查询表达式。编译器会故意生成一个对Select方法的调用,即使它什么都没有做。
3、使用orderby子句进行排序
orderby子句的一般语法基本上是上下文关键字orderby,后面跟一个或多个排序规则。一个排序规则就是一个表达式(可以使用范围变量),后面可以紧跟ascending或descending关键字,它的意思显而易见(默认规则是升序。)对于主排序规则的转译就是调用OrderBy或OrderByDescending,而其他子排序规则通过调用ThenBy或ThenByDescending来进行转换。
OrderBy和ThenBy的不同之处非常简单:OrderBy假设它对排序规则起决定作用,而ThenBy可理解为对之前的一个或多个排序规则起辅助作用。要特别注意,尽管你能使用多个orderby子句,但每个都会以它自己的OrderBy或OrderByDescending子句作为开始,这意味着最后一个才会真正“获胜”。也许会存在某些原因要包括多个orderby子句,不过这是很少见的。一般都应该使用单个子句来包含多个排序规则。
四、let子句和透明标识符
1、用let来进行中间计算
let子句只不过引入了一个新的范围变量,它的值是基于其他范围变量。语法是极其简单的:let 标识符=表达式
。当我们需要使用两个范围变量,但Lambda表达式只会给Select传递一个参数!这就该透明标识符出场了。它对一个表达式进行求值,并引入一个新的范围变量。示例如下:
2、透明标识符
上个例子的最后,我们在最后的投影中使用了两个范围变量,不过Select方法只对单个序列起作用。如何把范围变量合并在一起?答案是,创建一个匿名类型来包含两个变量,不过需要进行一个巧妙的转换,以便看起来就像在select和orderby子句中实际应用了两个参数。过程如下:
let子句为了实现目标,再一次调用了Select,并为结果序列创建匿名类型,最终创建了一个新的范围变量。对于原始的查询表达式直接引用user或length的地方,如果引用发生在let子句之后,就用z.user或z.length来代替。这里z这个名称是随机选择的——一切都被编译器隐藏起来。
严格地说,如何将不同的范围变量组合到一起,并使透明标识符得以工作,取决于C#编译器的实现。微软的官方实现使用了匿名类型,规范中也展示了这种转换。即使其他的编译器使用了不同的方式,也不应该影响结果。
五、连接
1、使用join子句的内连接
内连接涉及两个序列。一个键选择器表达式应用于第1个序列的每个元素,另外一个键选择器应用于第2个序列的每个元素。连接的结果是一个包含所有配对元素的序列,配对的规则是第1个元素的键与第2个元素的键相同。语法如下:join right-range-variable in right-sequence on left-key-selector equals right-key-selector
。这里使用equals而不是符号作为上下文关键字,这样更容易把左键选择器和右键选择器区分开。编译器使用这个上下文关键字将键选择器划分为不同的Lambda表达式。
在LINQ to Objects的实现中,使用左边序列中第1个元素的所有成对数据先被返回(按右边序列的顺序),接着返回使用左边序列中第2个元素的所有成对数据,依次类推。右边序列被缓冲处理,不过左边序列仍然进行流处理——所以,如果你打算把一个巨大的序列连接到一个极小的序列上,应尽可能把小序列作为右边序列。这种操作仍然是延迟的:在访问第1个数据对时,它才会开始执行,然后再从某个序列中读取数据。这时,它会读取整个右边序列,来建立一个从键到生成这些键的值的映射。之后,它就不需要再次读取右边的序列了,这时你可以迭代左边的序列,生成适当的数据对。在左边的键选择器中,只能访问左边序列的范围变量;在右边的键选择器中,只能访问右边序列的范围变量。如果你颠倒了左右两边的序列,那么你必须也颠倒左右两边的键选择器。幸好,编译器知道这样的常见错误,并建议你按恰当的步骤进行处理。示例如下:
我们通常需要对序列进行过滤,而在连接前进行过滤比在连接后过滤效率要高得多。此外嵌套查询表达式非常有用,不过也会降低可读性:通常应该寻找一种替代方式,或者将右边序列赋给一个变量,来让代码更加清晰。
内联会被编译器转译为对Join方法的调用,leftSequence.Join(rightSequence,leftKeySelector,rightKeySelector,resultSelector)
,用于LINQ to Objects的重载签名如下static IEnumerable<TResult> Join<TOuter,Tinner,Tkey,TResult>{this IEnumerbale<TOuter> outer,IEnumerable<TInner> inner,Func<TOuter,Tkey> outerKeySelector,Func<TInner,TKey> innerKeySelector,Func<TOuter,TInner,TResult> resultSelector}
(通常来说内连接对应右边,外连接对应左边)
2、使用join...into子句进行分组连接
分组连接(group join)结果中的每个元素由左边序列的某个元素和右边序列的所有匹配元素的序列组成。后者用一个新的范围变量表示,该变量由join子句中into后面的标识符指定。示例如下:
内连接和分组连接之间的一个重要差异是,对于分组连接来说,在左边序列和结果序列之间是一对一的对应关系,即使左边序列中的某些元素在右边序列中没有任何匹配的元素也无所谓。在左边元素不匹配任何右边元素的时候,嵌入序列就是空的。与内连接一样,分组连接要对右边序列进行缓冲,而对左边序列进行流处理
编译器将分组连接转译为简单地调用GroupJoin方法,就像Join一样。Enumerable.GroupJoin的签名如下:static IEnumerable<TResult> GroupJoin<YOuter,TInner,TKey,TResult>(this IEnumerable<TOuter> outer,IEnumerable<TInner> inner,Func<TOuter,TKey> outKeySelector,Func<TInner,TKey> innerKeySelector,Func<TOuter,IEnumerable<TInner>,TResult> resultSelector)
这个方法签名和内连接的方法签名非常相似,只不过resultSelector参数必须要用于处理右边元素的序列,不能处理单一的元素。同内连接一致,如果分组连接后面紧跟着select子句,那么投影就用作GroupJoin调用的结果选择器,否则,就引入一个透明标识符。
3、使用多个from子句进行交叉连接和合并序列
交叉连接不在序列之间执行任何匹配操作:结果包含了所有可能的元素对。它们可以简单地使用两个(或多个)from子句来实现。每个额外的from子句都通过透明标识符添加了自己的范围变量。
大多数时候,它就像指定了多表查询的笛卡儿积。然而它有更强大的地方:在任意特定时刻使用的右边序列依赖于左边序列的“当前”值。也就是说,左边序列中的每个元素都用来生成右边的一个序列,然后左边这个元素与右边新生成序列的每个元素都组成一对。这并不是通常意义上的交叉连接,而是将多个序列高效地合并(flat)成一个序列。示例如下:
编译器用来生成这个序列所调用的方法是SelectMany。它使用单个的输入序列(以我们的说法就是左边序列),一个从左边序列任意元素上生成另外一个序列的委托,以及一个生成结果元素(其包含了每个序列中的元素)的委托。下面是这个方法的签名,再次写成Enumerable. SelectMany的实例方法:static IEnumerable<TResult> SelectMany<TSource,TCollection,TResult>(this IEnumerbale<TSource>,Func<TSource,IEnumerable<TCollection>> collectionSelector,Func<TSource,TCollection,TResult> resultSelector)
和其他连接一样,如果查询表达式中连接操作后面紧跟的是select子句,那么投影就作为最后的实参;否则,引入一个透明标识符,从而使左右序列的范围变量在后续查询中都能被访问。SelectMany的一个有意思的特性是,执行完全是流式的——一次只需处理每个序列的一个元素,因为它为左边序列的每个不同元素使用最新生成的右边序列。
SelectMany的合并行为是非常有用的,你可能需要处理大量日志文件,每次处理一行:
var query=from file in Dictionary.GetFiles(logDictionary,"*.log")
from line in ReadLines(file)
let entry=new LogEntry(line)
where entry.Type==EntryType.Error
select entry
六、分组和延续
1、使用group...by子句进行分组
要在查询表达式中对序列进行分组,只需要使用group...by子句,语法如下:group projection by grouping
。该子句与select子句一样,出现在查询表达式的末尾。projection表达式和select子句使用的投影是同样的类型。只不过生成的结果稍有不同。
grouping表达式通过其键来决定序列如何分组。整个结果是一个序列,序列中的每个元素本身就是投影后元素的序列,还具有一个Key属性,即用于分组的键;这样的组合是封装在IGrouping<TKey,TElement>接口中的,它扩展了IEnumerable
编译器总是对分组子句使用GroupBy的方法调用。当分组子句中的投影很简单的时候——换句话说,在原始序列中的每个数据项都直接映射到子序列中的同一个对象的时候,编译器将使用简单的重载版本(只以分组表达式为参数),它知道如何把每个元素映射到键上。示例如下:
2、查询延续
查询延续提供了一种方式,把一个查询表达式的结果用作另外一个查询表达式的初始序列。它可以应用于group...by和select子句上,语法对于两者是一样的——你只需使用上下文关键字into,并为新的范围变量提供一个名称就可以了。范围变量接着能用在查询表达式的下一部分。示例如下:
注意:用于分组连接的join ... into子句不能形成一个延续的结构。主要的区别在于,在分组连接中,你仍然可以使用所有的早期范围变量。而对比本节的查询不难发现,延续会清除之前的范围变量,只有在延续中声明的范围变量才能在供后续使用。
七、在查询表达式和点标记之间作出选择
查询表达式在编译之前,先被转译成普通的C#。用普通的C#调用LINQ查询操作符来代替查询表达式,这种做法并没有官方名称,很多开发者称其为点标记(dot notation)。每个查询表达式都可以写成点标记的形式,反之则不成立:很多LINQ操作符在C#中不存在等价的查询表达式。
1、需要使用点标记的操作
最明显的必须使用点标记的情形是调用Reverse、ToDictionary这类没有相应的查询表达式语法的方法。然而即使查询表达式支持你要使用的查询操作符,也很有可能无法使用你想使用的特定重载。
2、使用点标记可能会更简单的查询表达式
使用点标记可以比查询表达式会更加清晰,但在实际情况下,应当先判断所做的是什么样的查询,并判断哪一种方法更具可读性。
3、选择查询表达式
在执行某些操作时(特别是连接操作),如果查询表达式使用了透明标识符,这时点标记的可读性就没那么高了。一个简单的let子句可能就足以让你选择查询表达式,而不是引入一个新的匿名类型只是为了在查询中扩充上下文。
查询表达式占优势的另一种情况是,需要多个Lambda表达式,或多个方法调用。这同样也包括连接在内,你需要为每个连接方指定键选择器,以及最终的结果选择器。示例如下: