认识LINQ的第一步---从查询表达式开始

时间:2022-02-01 08:17:50

学习和使用C#已经有2个月了,在这两个月的学习中,深刻体会到,C#这门语言还真不适合编程初学者学习,因为它是吸取了很多其他语言,不仅是面向对象,还包括函数式语言的很多特性,导致它变成特性大爆炸的语言。它的许多方面单独拿出来讲,就得是一本书的规模,而且还不一定让人一下子明白。

LINQ,Language INtegrated Query,语言集成查询,是其中一个非常重要的部分,有关它的功能增强,贯穿了整个C#的发展。

先从基本的查询表达式下手。

在讲查询表达式前,我们必须明白:查询表达式不仅仅是针对数据库,它针对的是所有数据源,因为LINQ的意图就是为所有数据源提供统一的访问方式。因为最近的项目使用的是LINQ to SQL,所以这里只讲LINQ to SQL。

查询表达式非常像SQL语句,但书写方式却是反过来:

var articles = from article in db.Articles
where a.Name == "JIM"
select article;

我们必须明白,查询表达式返回的结果是一个序列,哪怕这个序列只有一个元素。

正因为查询表达式返回的是一个序列,才使得它的行为非常有趣。序列的基本特点就是每次只取一个元素,这使得每个转换都处理一个数据,而且只在结果序列的第一个元素被访问的时候才会开始执行查询表达式。像是上面的数据流是这样的:select转换会在它返回的序列中的第一元素被访问时,为该元素调用where转换,where转换会返回数据列表中第一个元素,检查这个谓词(a.Name == "JIM")是否匹配,再把该元素返回给select。

这就是查询表达式的延迟执行,在它被创建的时候没有处理任何数据,只是在内存中生成了这个查询的委托。这样子是非常高效和灵活的,但并不是所有情况都适合,像是reverse这类的操作,就要访问整个数据源。所以,我们一般在返回另一个序列的操作中使用延迟执行,如果是返回单一值就使用即时执行。

使用查询表达式,首先就是声明数据序列的数据源:

from article in db.Articles

where子句用来进行过滤。它会进入数据流中每个元素的谓词,只有返回true的元素才能出现在结果序列中。我们可以使用多个where子句,只有满足所有谓词的元素才能进入结果序列。
      编译器会将where子句转换为带有Lambda表达式的where方法调用,像是这样:

where(article => article.Name == "JIM")

所以我们也可以直接使用Lambda表达式,它同样是返回一个序列:

var articles = db.Articles.Where(article => article)

使用Lambda表达式能使我们的代码的简洁度大幅上升,这也是它为什么会被引进C#中的原因之一。

select子句就是投影,相信学过数据库的同学一定非常熟悉。它同样也会有对应的方法调用,应该说,几乎所有的LINQ to SQL操作都有对应的方法调用(因为编译器就是讲它们转换为方法调用),所以接下来就不再讲方法调用形式了。

前面讲过,编译器会将查询表达式转换为普通C#代码的方法调用,以select为例,它并不会像我们预期的那样,转换为Enumerable.Select或者List<T>.Select,它就只是对代码进行转换,然后再寻找适当方法。该方法的参数会是一个委托类型或者一个Expression<T>。为什么转换后的方法中的参数是一个Lambda表达式呢?因为Lambda表达式可以被转换为委托实例或者表达式树,所以使用Lambda表达式就可以对应所有情况。这种转换并不依赖与特定类型,而只依赖与方法名称和参数,也就是所谓的动态类型(Duck Typing)的编译时形式。

因为这样,我们可以实现自己的LINQ提供器,但除非真的有特殊需要,一般我们都不需要做到这点。

我们来看看查询表达式中两个最重要的组成:范围变量和投影表达式。

from article in db.Articles

其中,article就是范围变量,而:

select article

投影表达式就使用了该范围变量。编译器在转换的时候,Lambda表达式的左边,参数名称就是范围变量,右边来自于投影表达式,所以,我们决不能这样写:

from article in db.Articles
select person

从上面来看,范围变量应该是隐式类型,编译器会自己推断它的具体类型。当然,我们也可以使用显式类型的范围变量,像是下面这样:

from Article article in db.Articles
select article

其中,db.Articles是一个List<Article>。

这样的情况是因为我们想要在强类型的容器中进行查询。这样的机制能够运行的保障源于一个方法:Cast()。Cast()会强制将类型转换为目标类型,如果无法转换则会出错。

上面的表达式会转换为以下的代码:

list.Cast<Article>().Select(article => article);

为什么需要我们对范围变量进行显式声明呢?因为Select方法只是IEnumerable<T>的扩展方法,而不是IEnumerable,所以我们必须要显式的声明范围变量的类型才能调用Select方法。
      让我们更加深入的研究一下投影表达式。

前面的Select其实是不做事的,但它也并不会返回源数据,查询表达式的结果和源数据必须不一样,否则我们对结果进行的操作可能就会影响到源数据了。这样的Select有一个专门的称呼:退化查询表达式。如果没有其他子句,像是where子句的调用,编译器还是会为select表达式生成Select方法调用,但像是前面,结果生成的表达式并没有Select方法调用。

所有的查询表达式都需要select或者group...by结尾,所以退化查询表达式才会诞生。

数据库的操作经常需要对元素进行排序,所以我们接下来就讲orderby子句。

orderby子句可以有多个排序规则,默认是升序,也可以选择降序descending。我们来看一个例子:

from article in db.Articles
orderby article.Age descending, article.Height
select article;

编译器生成的表达式如:

db.Articles.OrderbyDescending(article => article.Age).ThenBy(article => article.Height);

OrderBy对排序规则起决定作用,而ThenBy只是在OrderBy的结果中进行排序,而且它本身就被定义为IOrderedEnumerable<T>的扩展方法,这是OrderBy返回的类型,这注定我们无法单独使用ThenBy(当然,ThenBy本身也返回该类型,这是为了进一步的连锁)。
      最好只使用一个orderby子句,虽然理论上我们是可以使用多个。

前面的查询表达都是最基本的操作,对于一般的要求就已经足够了,接下来讲解比较复杂的查询表达式。

剩下的查询表达式都会涉及到透明标识符。

使用透明标识符最简单的应用就是let子句:

from article in db.Articles
let length = article.Name.Length
orderby length
select new {Name = article.Name, Length = length};

上面的查询表达式远比之前要复杂得多了!
      let子句引入了新的范围变量,它的值是基于其他范围变量。但问题来了,Lambda表达式只会给select传递一个参数!而我们这里有两个范围变量!!所以,前面我们才会创建一个匿名类型来包含这两个变量,于是实际的查询表达式是被转换为这样:

db.Articles.Select(article => new {article, length = article.Name.Length})
.OrderBy(z => z.length).Select(z => new {Name = article.Name, Length = z.length});

z这个名称是编译器随机生成的,我们注意到,在let子句后的查询表达式,凡是涉及到length的地方,都会用z.length来代替。这就是透明标识符。
      数据库操作都会涉及到联接,LINQ的联接有3种类型:

1.内联接

内联接涉及到两个序列:键选择器(应用于第二个序列的每个元素)和键选择器表达式(应用于第一个序列的每个元素)。联接的结果是一个所有配对元素的序列,配对的规则是第一个元素的键与第二个元素的键相同。

使用内联接的方式如下:

from article in db.Articles
join comment in db.Comments
on article.Name equals comment.ArticleName
select new { article.Name, comment.Content};

对于这两个序列,右边序列db.Comments会在左边序列db.Articles进行流处理的时候进行缓冲,所以当我们要把一个巨大的序列联接到一个小序列上的时候,应把小序列作为右边序列。

如果我们想要过滤序列,最好是在内联接之前对左边序列进行过滤,这样查询表达式就会更加简单,对右边序列进行过滤,可能就要使用嵌套查询表达式,这样可读性很差。

2.分组联接

分组联接结果的每个元素由左边序列的范围变量的1个元素组成,由右边序列的所有匹配元素组成的序列显示为一个新的范围变量,该变量由into后面出现的标识符指定:

from article in db.Articles
join comment in db.Comments
on article.Name equals comment.ArticleName
into comments
select new { Article = article, Comments = comments};

Comments是一个嵌入序列,包含了匹配article的所有comment。
      内联接和分组联接最大的差异就是分组联接在左边序列和结果序列间存在一对一的对应关系,即使左边序列中的某些元素在右边序列中没有任何匹配的元素,会用空表示。分组联接同样要对右边序列进行缓冲,对左边序列进行流处理。

3.交叉联接

前面的联接都是相等联接(equijion),左边序列中的元素和右边序列进行匹配。但交叉联接并不在序列间进行任何匹配操作,结果包含了每个可能的元素对:

from article in db.Articles
from comment in db.Comments
select new { Article = article, Comment = comment};

交叉联接的结果就是一个笛卡尔积,左边序列被联接到右边序列的每个元素中。
     当然,我们必须对数据库有足够的了解才能明白上面的联接是怎么回事,但正如我们在开头强调的,C#是一门综合性非常强大的语言,所以在学习它的特性前我们必须对相关知识有足够的了解。

讨论完联接后,我们接下来就是分组。

分组的实现非常简单:

from article in db.Articles
where article.Name == "JIM"
group article by article.Age;

当然,我们可以对article.Author进行分组,但这时编译器生成的表达式就非常有趣了:

db.Articles.Where(article => article.Name == "JIM").GroupBy(article => article.Age,
article => article.Author);

GroupBy有更加复杂的版本,它们提供的功能可能比使用查询表达式还要更加强大。
     如果打算对查询结果进行更急处理,可以使用查询延续。查询延续是把一个查询表达式的结果用作另外一个查询表达式的初始序列。像是这样:

from article in db.Articles
where article.Name == "JIM"
group article by article.Age into grouped
select new { Age = grouped.Key,
count = grouped.Count()};

可怕的是,我们还能在查询延续后面继续延续!

from article in db.Articles
where article.Name == "JIM"
group article by article.Age into grouped
select new { Age = grouped.Key,
count = grouped.Count()} into result
orderby result.Count
select result;

当然,使用查询表达式必须注意查询语句的复杂性,像是上面的例子就已经足够复杂了。

在代码中使用查询表达式必须注意,如果是复杂的情况,查询表达式会非常复杂,这时使用查询表达式就不是一个好主意,所以,C#提供了很多方法调用来简化这些操作。我们之间其中几个比较常见的操作。

First()返回的是满足条件的第一个元素,而Single()返回的是满足条件的唯一一个元素,但如果有多个元素符合就会跑出错误。它们在取出元素的时候非常有用,但因为可能存在错误,所以最好使用FirstOrDefault()和SingleOrDefault()来代替。Include()这个方法主要用于提取表中的子表中的元素,像是Articles这个表,可以有一个List<Comment> Comments用于存放相关的comment,可以这样:

var articles = db.Articles.Include("Comments").FirstOrDefault(c => c.Name == article.Name);

这样我们就能提取出Comments中的元素了。我们要时刻记住,无论是查询表达式还是方法的调用,最后返回的结果都会是一个IQuerable<T>,是一个序列,而不是单个结果,哪怕这个序列只有一个结果,所以,使用ToList()将其转换为List<T>,就可以方便的使用List的方法对该序列进行操作了。