本篇继续LINQ Operators的学习,这里我们讨论的是数据转换的两种方式:Select和SelectMany,看似简单常用的两种运算符,却也大有讲究。我们会在本篇详细介绍他们的使用方式和适用的场景,以及它们对于各种连接(Join)的支持方式。
本篇继续LINQ Operators的学习,这里我们讨论的是数据转换的两种方式:Select和SelectMany,看似简单常用的两种运算符,却也大有讲究。我们会在本篇详细介绍他们的使用方式和适用的场景,以及它们对于各种连接(Join)的支持方式。
数据转换(Projecting)
IEnumerable<TSource> → IEnumerable<TResult>
Enumerable
实现
public static IEnumerable<TResult> Select<TSource, TResult>
(this IEnumerable<TSource> source, Func<TSource, TResult> selector)
{
foreach (TSource element in source)
yield return selector(element);
}
对于Select,你总是得到与源sequence相同数量的elements,并且每个element都经过了lambda表达式的转换。下面的代码选择计算机上安装的所有字体:
// using System.Drawing;
IEnumerable<string> query = from f in FontFamily.Families
select f.Name;
foreach (string name in query) Console.WriteLine(name);
在这个示例中,select子句把一个FontFamily对象转换成字体名称,下面是等价的lambda表达式:
IEnumerable<string> query = FontFamily.Families.Select(f => f.Name);
Select语句经常使用匿名类型来保存转换结果:
var query = from f in FontFamily.Families
select new { f.Name, LineSpacing = f.GetLineSpacing(FontStyle.Bold) };
一个不进行任何转换的select通常用在查询表达式语法中,用以满足查询必需要以select或group结尾的要求。比如下面的查询选择所有支持删除线的字体:
// selects fonts supporting strikeout
IEnumerable<FontFamily> query =
from f in FontFamily.Families
where f.IsStyleAvailable (FontStyle.Strikeout)
select f;
foreach (FontFamily ff in query) Console.WriteLine (ff.Name);
在这种情况下,编译器在把查询表达式语法翻译成方法语法时会忽略该select。
带索引的数据转换
数据转换表达式支持第二个可选参数,用以表示当前element在输入sequence中的索引位置,当然只有本地查询支持这种功能:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> query = names
.Select((s, i) => i + "=" + s); // { "0=Tom", "1=Dick", ... }
Select
子查询和对象层次
我们可以通过在select子句中嵌套一个子查询来创建一个对象层次。
// 下面的示例获取一个集合,用来描述D:\Documents下的每一个子目录
// 每一项都包含一个文件集合
DirectoryInfo[] dirs = new DirectoryInfo(@"d:\Documents").GetDirectories();
var query = from d in dirs
where (d.Attributes & FileAttributes.System) == 0
select new
{
DirectoryName = d.FullName,
Created = d.CreationTime,
Files = from f in d.GetFiles()
where (f.Attributes & FileAttributes.Hidden) == 0
select new { FileName = f.Name, f.Length, }
};
foreach (var dirFiles in query)
{
Console.WriteLine("Directory: " + dirFiles.DirectoryName);
foreach (var file in dirFiles.Files)
Console.WriteLine("" + file.FileName + "Len: " + file.Length);
}
这个查询获取Files的部分可以被称为相关子查询。相关子查询是指它引用了外部查询的对象,本例中,它引用了正在遍历的目录对象d。Select中的子查询让我们可以把一个对象层次映射到另一个对象层次,或者把关系对象模型映射到层次对象模型。
对于本地查询,Select中的子查询会导致双重-延迟执行(double-deferred execution)。在我们上面的示例中,直到内层的foreach语句遍历dirFiles.Files时,获取文件的子查询才会被真正执行。
LINQ to SQL
和EF
中的子查询和连接
LINQ to SQL和EF也支持通过子查询进行数据转换,这是通过SQL中的join实现的。下面的示例获取customer的姓名以及Price大于1000的Purchases:
var query =
from c in dataContext.Customers
select new
{
c.Name,
Purchases = from p in dataContext.Purchases
where p.CustomerID == c.ID && p.Price > 1000
select new { p.Description, p.Price }
};
foreach (var namePurchases in query)
{
Console.WriteLine("Customer: " + namePurchases.Name);
foreach (var purchaseDetail in namePurchases.Purchases)
Console.WriteLine(" - $$$: " + purchaseDetail.Price);
}
这种类型的查询非常适用于解释查询,外部查询和内部的子查询作为一个单元处理,这样就避免了到外部数据源的额外连接。而对于本地查询,它的效率却并不高,因为对于每一个外部查询返回的element,都会通过内部子查询去获得少量的匹配元素。这时,对于本地查询来说更好的选择是Join或GroupJoin,我们会在下一篇中介绍Join和Group Join。
上面的查询使用了两个完全不同的对象集合(Customers和Purchases),可以被看成是一种”Join”行为。它和传统意义上的数据库Join之间的区别在于:我们的查询并没有把结果平展为一个二维的数据集,而是把关系数据映射到了层次对象上。下面的示例和上面的查询实现了相同的功能,但它通过Customer实体的Purchases关联属性简化了查询:
var query =
from c in dataContext.Customers
select new
{
c.Name,
Purchases = from p in c.Purchases // Purchases is EntitySet<Purchase>
where p.Price > 1000
select new { p.Description, p.Price }
};
上面的两种查询都对应了SQL的left outer join(左连接),也就是说,我们在外层查询获取了所有的Customers,而不管他们是否存在Purchases。如果要模拟inner join(内连接),即不包含那些没有Purchases(Price > 1000)的Customers,我们必须在外层查询中对Customers进行过滤:
var query =
from c in dataContext.Customers
where c.Purchases.Any (p => p.Price > 1000)
select new {
c.Name,
Purchases = from p in c.Purchases
where p.Price > 1000
select new { p.Description, p.Price }
};
可以看出,这个查询不够简洁,我们对同一条件 (Price > 1000)书写了两次。这时我们可以通过let子句来避免重复:
var query =
from c in dataContext.Customers
let highValueP = from p in c.Purchases
where p.Price > 1000
select new { p.Description, p.Price }
where highValueP.Any()
select new { c.Name, Purchases = highValueP };
这种样式的查询非常灵活,比如通过把Any改为Count,我们可以只获取那些至少有两个Purchase的Price > 1000的Customers:
where highValueP.Count() >= 2
select new { c.Name, Purchases = highValueP };
数据转换到具体类型
但我们需要获取中间结果时,把数据转换到匿名类型非常有效。但是如果我们希望把结果发送到客户端或其他方法,匿名类型就不适合了,因为匿名类型智能作为一个方法内的局部变量。替换方法是使用具体类型比如DataSets或自定义的业务实体类来保存结果。一个自定义的业务实体只是一个包含了某些属性的简单类,他们用来隐藏低层次的程序结构(比如数据库存储)。假如我们定义了CustomerEntity和PurchaseEntity业务实体,下面的代码可以使用他们来保存查询结果:
IQueryable<CustomerEntity> query =
from c in dataContext.Customers
select new CustomerEntity
{
Name = c.Name,
Purchases = (from p in c.Purchases
where p.Price > 1000
select new PurchaseEntity {
Description = p.Description,
Value = p.Price
}
).ToList()
};
// 要强制执行query,可以把结果转换到普通List
List<CustomerEntity> result = query.ToList();
注意,到目前为止,我们还不需要使用Join或SelectMany语句,这是因为我们在数据转换时维持了层次结构的数据,如下图所示,左边是LINQ to SQL生成的实体类,右边则是我们自定义的业务类。在LINQ中,我们通常避免SQL中把数据表平展成二维结果集的传统方式,因为上面这种层次结构的数据更易于理解和使用。
SelectMany
查询表达式语法
from identifier1 in enumerable-expression1
from identifier2 in enumerable-expression2
...
Enumerable
实现
public static IEnumerable<TResult> SelectMany<TSource, TResult>
(IEnumerable<TSource> source, Func<TSource, IEnumerable<TResult>> selector)
{
foreach (TSource element in source)
foreach (TResult subElement in selector(element))
yield return subElement;
}
SelectMany把子查询连接合并成一个简单的输出序列。让我们回想一下,对于每个输入element,Select返回一个输出element,而对于SelectMany会返回0..n个输出elements。这0..n个elements来自lambda表达式必需生成的一个子sequence。SelectMany可以用来展开这些子sequence、水平展开嵌套的集合,并最终把他们连接到一个输出sequence。
比如,假设我们有一个名字数组如下:
string[] fullNames = { "Anne Williams", "John Fred Smith", "Sue Green" };
现在我们想把它们转换到一个平展的单词集合,即:"Anne", "Williams", "John", "Fred", "Smith", "Sue", Green"
这时,SelectMany就非常适用于这项任务,因为我们需要把每个输入元素映射到多个输出元素。我们需要做的就是 写出一个把单个元素转换到一个子sequence的lambda表达式。string.Split方法正好可以完成此项工作,所以,我们的SelectMany查询如下:
string[] fullNames = { "Anne Williams", "John Fred Smith", "Sue Green" };
IEnumerable<string> query = fullNames.SelectMany(name => name.Split());
foreach (string name in query)
Console.Write(name + "|"); // Anne|Williams|John|Fred|Smith|Sue|Green|
如果我们用Select来替换SelectMany,我们会获得层次结构形式的相同数据,这时结果是一个sequence,但其中的每个element是一个string array,我们需要嵌套的foreach来遍历结果:
IEnumerable<string[]> query = fullNames.Select(name => name.Split());
foreach (string[] stringArray in query)
foreach (string name in stringArray)
Console.Write(name + "|"); // Anne|Williams|John|Fred|Smith|Sue|Green|
使用SelectMany的好处是 它会返回一个简单和平展的sequence。查询表达式语法也支持SelectMany,不过它是通过一个额外的from子句来实现的。查询表达式中的from关键字有两种意义:查询开始处的from引入初始范围变量和输入sequence,任何其他位置的from会被翻译成SelectMany。下面就使用查询表达式语法来重写上面的示例:
IEnumerable<string> query = from fullName in fullNames
from name in fullName.Split() // Translates to SelectMany
select name;
上面查询的第二个from子句引入了一个新的查询变量name,从这里开始我们就可以在查询中使用这个新的范围变量,而老的范围变量fullName也就成了查询的外部范围变量。
外部范围变量
在上面的例子中,fullName在SelectMany之后就成了一个外部范围变量。外部范围变量的作用域会一直保持到查询结束或到达一个into子句为止。这种需要使用外部范围变量的场景,查询表达式语法就优于方法语法了。假如上面的例子,我们需要在结果中加入fullName:
IEnumerable<string> query =
from fullName in fullNames // fullName = outer variable
from name in fullName.Split() // name = range variable
select name + " came from " + fullName;
Anne came from Anne Williams
Williams came from Anne Williams
John came from John Fred Smith
...
那么在后台,编译器是如何来解析外部范围变量的引用的呢?换句话说,它该如何把上面的查询表达式翻译成等价的方法语法呢?因为在方法语法中,SelectMany返回一个平展的sequence,所以它来自哪个外部范围变量fullName已经“丢失”了。其实,编译器使用了一种策略来解决这个问题:在一个临时的匿名类型中,同时保存外部元素和每一个内部子元素。即编译器会把上面的查询表达式转换成如下的方法语法:
IEnumerable<string> query = fullNames
.SelectMany(fName => fName.Split().Select(name => new { name, fName }))
.Select(x => x.name + " came from " + x.fName);
使用SelectMany
连接
我们可以使用SelectMany来join两个sequences,得到一个元素之间的交叉结果集,比如:
string[] players = { "Tom", "Jay", "Mary" };
IEnumerable<string> query = from name1 in players
from name2 in players
select name1 + " vs " + name2;
// RESULT: { "Tom vs Tom", "Tom vs Jay", "Tom vs Mary",
// "Jay vs Tom", "Jay vs Jay", "Jay vs Mary",
// "Mary vs Tom", "Mary vs "Jay", "Mary vs Mary" }
尽管我们通过上面的查询实现一个cross join,但在让其结果有意义之前我们必须要加上一个过滤条件,该过滤语句构成了join的条件:
IEnumerable<string> query = from name1 in players
from name2 in players
where name1.CompareTo (name2) < 0
orderby name1, name2
select name1 + " vs " + name2;
//RESULT: { "Jay vs Mary", "Jay vs Tom", "Mary vs Tom" }
LINQ to SQL
和 EF
中的SelectMany
LINQ to SQL和EF中的SelectMany可以用来做cross joins、inner joins、和left outer joins。 我们可以像Select语句那样,使用已经定义好的关联。一个LINQ-to-db 交叉连接(cross join)就是我们上节示例中的方法:
// 下面的查询匹配每个Customer和每个Purhcase(a cross join):
var query = from c in dataContext.Customers
from p in dataContext.Purchases
select c.Name + " might have bought a " + p.Description;
通常情况下,我们希望Customers只匹配他们自己的Purchases。我们可以通过Where子句来增加一个join条件,其结果是一个标准的SQL样式相等连接(equi-join):
var query = from c in dataContext.Customers
from p in dataContext.Purchases
where c.ID == p.CustomerID
select c.Name + " bought a " + p.Description;
如果我们的实体中有相关的关联属性,我们就可以在查询中直接使用该关联属性来取代交叉连接并获得相同的结果:
var query = from c in dataContext.Customers
from p in c.Purchases
select c.Name + " bought a " + p.Description;
使用关联属性的好处是我们去除了连接条件,不管怎样,两种查询最终生成相同的SQL脚本。
我们可以在这种查询中使用where子句添加额外的过滤条件,比如如果只想找出那些姓名以”t”开始的Customers:
var query = from c in dataContext.Customers
where c.Name.StartsWith ("T")
from p in c.Purchases
select new { c.Name, p.Description };
对于LINQ-to-db查询来讲,我们可以把where子句放到第二个from的下面,其结果完全一样,因为整个查询作为一个Unit来处理并生成相同的SQL脚本。但如果这时一个本地查询,把where放到第二个from后面会导致查询效率的降低,所以对于本地查询,我们应该在 连接之前过滤。
我们还可以继续添加from子句来引入新的子table,如果每个Purchase还有多个PurchaseItems,则我们可以写出下面的查询:
var query = from c in dataContext.Customers
from p in c.Purchases
from pi in p.PurchaseItems
select new { c.Name, p.Description, pi.DetailLine };
如果我们需要包含父表里的数据,我们就不再是添加from子句,而只需简单的导航到该属性即可,假如每个Customer都有一个SalesPerson:
var query = from c in dataContext.Customers
select new { Name = c.Name, SalesPerson = c.SalesPerson.Name };
在这里我们不使用SelectMany是因为这里没有子集合需要平展,父关联属性返回单个元素。
SelectMany
中的Outer joins
我们在前面已经看到一个Select中的子查询返回的结果类似于左连接left outer join:
var query = from c in dataContext.Customers
select new
{
c.Name,
Purchases = from p in c.Purchases
where p.Price > 1000
select new { p.Description, p.Price }
};
在这个示例中,所有的外层元素(customer)都被包含在结果集中,而不管该Customer有没有Purchases。但如果我们使用SelectMany来重写这个查询以获得一个简单的平展集合时:
var query = from c in dataContext.Customers
from p in c.Purchases
where p.Price > 1000
select new { c.Name, p.Description, p.Price };
上面的查询转到了一个内连接inner join,现在只有那些包含了Purchase Price>1000的customers才会被包含在结果集中。如果我们需要在平展的结果集中实现left outer join,我们必须在内层sequence上面应用DefaultIfEmpty查询运算符。如果输入sequence没有任何elements,这个方法返回null。请看下面的示例:
var query = from c in dataContext.Customers
from p in c.Purchases.DefaultIfEmpty()
select new { c.Name, p.Description, Price = (decimal?)p.Price };
上面的查询在LINQ to SQL和EF中工作得很好,返回了所有的Customers,即使他们没有任何Purchases。但是如果我们在一个本地查询中这么做,程序就会崩溃,因为当p为null时,p.Description和p.Price会抛出NullReferenceException。我们可以通过改写该查询让其在两种场景中都能完美地工作:
var query = from c in dataContext.Customers
from p in c.Purchases.DefaultIfEmpty()
select new
{
c.Name,
Descript = p == null ? null : p.Description,
Price = p == null ? (decimal?)null : p.Price
}
如果我们还需要像之前那样引入price过滤的话,我们不能像前面那样直接在from后面添加Where,因为它会在DefaultIfEmpty之后执行:
var query = from c in dataContext.Customers
from p in c.Purchases.DefaultIfEmpty()
where p.Price > 1000 // 错误的做法
...
正确的做法是把Where条件以子查询的形式添加在DefaultIfEmpty前面:
var query = from c in dataContext.Customers
from p in c.Purchases.Where(p => p.Price > 1000).DefaultIfEmpty()
select new
{
c.Name,
Descript = p == null ? null : p.Description,
Price = p == null ? (decimal?)null : p.Price
};
LINQ to SQL和EF把这种查询翻译成left outer join,上面是书写此类查询非常有效的模式。
这里介绍了两种书写外连接的查询方法,尽管使用
SelectMany
来获取平展的结果集更加类似于
SQL
的处理方式,但在很多情况下,在
Select
中使用子查询获得层次结果集的方式更好一些,因为我们我们不需要额外的空值处理。
系列博客导航:
LINQ之路系列博客导航
LINQ之路 1:LINQ介绍
LINQ之路 2:C# 3.0的语言功能(上)
LINQ之路 3:C# 3.0的语言功能(下)
LINQ之路 4:LINQ方法语法
LINQ之路 5:LINQ查询表达式
LINQ之路 6:延迟执行(Deferred Execution)
LINQ之路 7:子查询、创建策略和数据转换
LINQ之路 8:解释查询(Interpreted Queries)
LINQ之路 9:LINQ to SQL 和 Entity Framework(上)
LINQ之路10:LINQ to SQL 和 Entity Framework(下)
LINQ之路11:LINQ Operators之过滤(Filtering)
LINQ之路12:LINQ Operators之数据转换(Projecting)
LINQ之路13:LINQ Operators之连接(Joining)
LINQ之路14:LINQ Operators之排序和分组(Ordering and Grouping)
LINQ之路15:LINQ Operators之元素运算符、集合方法、量词方法
LINQ之路16:LINQ Operators之集合运算符、Zip操作符、转换方法、生成器方法
LINQ之路17:LINQ to XML之X-DOM介绍
LINQ之路18:LINQ to XML之导航和查询
LINQ之路19:LINQ to XML之X-DOM更新、和Value属性交互
LINQ之路20:LINQ to XML之Documents、Declarations和Namespaces
LINQ之路21:LINQ to XML之生成X-DOM(Projecting)
LINQ之路系列博客后记