C#和VB.NET中的LINQ提供了一种与SQL查询类似的“对象查询”语言,对于熟悉SQL语言的人来说除了可以提供类似关联、分组查询的功能外,还能获取编译时检查和Intellisense的支持,使用Entity Framework更是能够自动为对象实体的查询生成SQL语句,所以很受大中型信息系统设计者的青睐。
IEnumerable这个接口可以说是为了这个特性“量身定制”,再加上微软提供的扩展(Extension)方法和Lambda表达式,给开发者带来了无穷的便利。本人在最近的开发工作中使用了大量的这种特性,同时在调试过程中还遇到了一个小问题,那么正好趁此机会好好研究一下相关原理和实现。
先从一个现实的例子开始吧。假如我们要做一个商品检索功能(这只是一个例子,我当然不可能把公司的产品也业务在这里贴出来),其中有一个检索条件是可以指定厂家的名称并进行模糊匹配。厂家的包括两个名称:注册名称和一般性名称,我们只按一般性名称进行检索。当然你可以说直接用SQL查询就行了,但是我们的系统是以实体对象为核心进行设计的,厂家的数量也不会太多,大概1000条。为了不增加系统的复杂性,只考虑使用现有的数据访问层接口进行实现(按过滤条件获取商品,以及获取所有厂商),这时LINQ的便捷性就体现出来了。
借助IEnumerable接口和其辅助类,我们可以写出以下代码:
public GoodsListResponse GetGoodsList(GoodsListRequest request)
{
//从数据库中按商品类别获取商品列表
IEnumerable<Goods> goods = GoodsInformation.GetGoodsByCategory(request.CategoryId);
//用户指定了商品名检索字段,进行模糊匹配
//如果没有指定,则不对商品名进行过滤
if (!String.IsNullOrWhiteSpace(request.GoodsName))
{
request.GoodsName = request.GoodsName.Trim().ToUpper();
//按商品名对 goods 中的对象进行过滤
//生成一个新的 IEnumerable<Goods> 类型的迭代器
goods = goods.Where(g => g.GoodsName.ToUpper().Contains(request.GoodsName));
}
//如果用户指定的厂商的检索字段,进行模糊匹配
if (!String.IsNullOrWhiteSpace(request.ManufactureName))
{
request.ManufactureName = request.ManufactureName.Trim().ToUpper();
//只提供了获取所有厂商的列表方法
//取出所有厂商,筛选包含关键字的厂商
IEnumerable<Manufacture> manufactures = ManufactureInformation.GetAll();
manufactures = manufactures.Where(m => m.Name.GeneralName.ToUpper()
.Contains(request.ManufactureName));
//取出任何符合所匹配厂商的商品
goods = goods.Where(g => manufactures.Any(m => m.Id == g.ManufactureId));
}
GoodsListResponse response = new GoodsListResponse();
//将 goods 放到一个 List<Goods> 对象中,并返回给客户端
response.GoodsList = goods.ToList();
return response;
}
</div>
假如不使用IEnumerable这个接口,所实现的代码远比上面复杂且难看。我们需要写大量的foreach语句,并手工生成很多中间的 List 来不断地筛选对象(你可以尝试把第二个if块改写成不用IEnumerable接口的形式)。
看上去一切都很和谐,但是上面的代码有一个隐含的bug,这个bug也是今天上午困扰了我许久的一个问题。
运行程序,当我不输入厂商检索条件的时候,程序运行是正确的。但当我输入一个厂商的名字时,系统抛出了一个空引用的异常。咦?为什么会有空引用呢?我输入的厂商是数据库中不存在的厂商,因此我觉得问题可以出在goods = goods.Where(g => manufactures.Any(m => m.Id == g.ManufactureId)) 这句话上。既然manufactures是空的,那么是不是意味着我不能调用其 Any 方法呢(lambda表达式中的部分)。于是我改写成以下形式:
if (manufactures != null)
//取出任何符合所匹配厂商的商品
goods = goods.Where(g => manufactures.Any(m => m.Id == g.ManufactureId));
</div>
还是不行,那么我对manufactures判断其是否有元素,就调用其无参数的Any方法,这时问题依旧:
聪明的你肯定已经看出问题出在哪了,因为Visual Studio已经提示得很清楚了。但我当时还局限在“列表为空”这个框框中,因此迟迟不能发现原因。出错是发生在 manufactures.Any() 这句话上,而我已经判断了它不为空啊,为什么还会抛错呢?
后来叫了一个同事帮我看,他说的四个字一下子就提醒了我“延迟计算”。哦,对!我怎么把这个特性给忘了。在最初的代码中(就是没有对 manufactures 为空进行判断),出错是发生在 goods.ToList() 这句话时,而图上的那个代码段出错是发生在调用Any()方法时(图中的灰色部分),而我单步跟踪到 Any() 这句话上时,出错的语句跳到 Where 子句(黄色部分),说明知道访问 Any 方法时lambda表达式才被调用。
那么很显然是 Where 语句中这个 predicate 有问题:Manufacture的Name字段可能为空(数据库中存在这样的数据,所以导致在 translate 的时候Name字段为空),那么改写成以下形式就能解决问题,当然我们不用对 manufactures 列表进行为空的判断:
manufactures = manufactures.Where(m => m.Name != null &&
m.Name.GeneralName.ToUpper().Contains(request.ManufactureName));
</div>
在此要感谢那位同事看出了问题所在,否则我不知道还得郁闷多久。
我之前在使用 LINQ 语句的时候知道它的延迟计算特性,但是没有想到从根本上自 IEnumerable 的扩展方法就有这个特性。那么很显然,C#的编译器只是把 LINQ 语句改写成类似于调用 Where、Select之类的扩展方法,延迟计算这种特性是 IEnumerable 的扩展方法就支持的!我之前一直以为我每调用一次 Where 或者 Select(其实我SelectMany用得更多),就会对结果进行过滤,现在看来并不是这样。
即使是使用 Where 等扩展方法, 执行这些 predicate 的时间是在 foreach 和 ToList 的时候才发生。
为什么会这样呢?看样子这完全不应该呀?Where子句的返回值就是一个IEnumerable的迭代器,按道理应该已经筛选了对象啊?为了彻底搞清楚这个问题,那么方法很明显——看 .NET 的源代码。
Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate) 是它的方法头,在看源代码之前,相信你已经知道微软大概是怎么实现的了:既然Where接受一个Func类型的委托,并且都是在ToList 或者 foreach 的时候计算的,那么显而易见实现应该是……
好了,来看下代码吧。IEnumerable的扩展方法都在 Enumerable 这个静态类中,Where方法的实现代码如下:
public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate) {
if (source == null) throw Error.Argu