就是这么坑:Linq的延迟加载特性

C#

浏览数:233

2019-4-3

AD:资源代下载服务

Linq拥有简洁的形式、强大的表达,是C#初学者必须了解的基础,是.Net开发者组织代码的利器。

学习Linq的时候,几乎所有的书都会介绍,绝大部分的Linq语法具有延迟加载的特性。例如:

IEnumerable<int> a = new int[] { 1, 2, 3 }.Where(p => p > 1);

当使用Linq的Where方法操作数组集合时,方法并没有返回最终结果,即int[]{2,3},而是返回一个接口类型IEnumerable<int>。只有当foreach的时候,才会逐个返回结果中的元素。为什么呢?因为类似Where的Linq操作,不是在内存里重新开辟内存存放结果,而是只保存了查询的命令,这样我们可以在后面继续增加新的命令形成一系列的组合操作,不至于每次查询都浪费资源生成新的中间结果。

然而,也有少数的Linq命令是即时加载的,比如Count、First、Last等方法。因为这些方法本身就涉及到了遍历各个元素,既然内部需要foreach遍历,自然就不是延迟加载了。

好了,简要地介绍了一下Linq的延迟加载特性,让我们来假设一个简单的应用情形:

某地举办了一场演唱会,每张门票都有id,和购票的粉丝绑定。演唱会嘛,就是去听粉丝一起唱唱歌,顺便也看看偶像的现场表演。这不,演唱会过程中有一个粉丝和声活动,由最后一个幸运粉丝嗷个结尾。

粉丝定义如下:

public class Fan
{
        public Fan(int id)
    {
        Id = id;
    }

    public int Id { get; set; }
}

粉丝和声的过程代码描述如下:

int[] ticketIds = new int[] { 1, 2, 3 };//姑且就以3位粉丝作为代表吧!
IEnumerable<Fan> fans = ticketIds.Select(i => new Fan(i));
Sing(fans);//唱歌咯!
Console.WriteLine("Oh oh oh oh...");//联欢结束

好了,现在让我们看一下Sing(IEnumerable<Fan>)方法怎么实现。很多人可能都会这么写:

        private static void Sing(IEnumerable<Fan> fans)
        {
            var theLastFan = fans.LastOrDefault();
            foreach (var fan in fans)
            {
                Console.WriteLine(theLastFan != fan ? "Don't break my heart" : "喜悦总是出现在我梦中");
            }
        }

可以看到方法内使用了Linq的Last方法来获取最后一个粉丝;然后遍历粉丝,开始回声迭起的联欢;到了最后一位粉丝时,由这位幸运粉丝嗷最后一句。

让我们运行一下看看:

一个失败的结尾

其他人:哎?最后这位粉丝你怎么回事?让你唱结尾,不是让你唱开头哇!

最后一位粉丝:???(很无辜)

出问题了,问题在哪儿呢?

直接原因是“theLastFan == fan”的判断失效了,而Fan是引用类型。

哦,此fan非彼fan呀!

在LastOrDefault()方法执行时,因为传递的是IEnumerable对象,方法会在内部逐个遍历找到最后一位粉丝,这里触发了Select内部的操作,产生了一个Fan的对象。

而下面接着开始foreach操作,其实又是逐个执行了Select命令!好了,最后一位粉丝与之前的并不是同一个对象,尽管他们的Id都是3——真假美猴王啊……(说到美猴王……直接开花)

所以——真相只有一个!3号票的粉丝上厕所去了,有吃瓜群众冒领了Ta的位置,但是又没有了解规则,所以跟着大家唱了开头的一句!保安!保安!(额,我想一定是我最近推理小说看多了……)

那么怎么改呢?

一种方式是让粉丝集合先加载再传入好了:

IEnumerable<Fan> fans = ticketIds.Select(i => new Fan(i)).ToArray();

这样为什么可以呢?这里又涉及到性能细节了。虽然Linq方法的传入参数是接口类型IEnumerable<T>,但是如果确认了传入参数的具体类型,有助于提升性能。比如Count()方法,如果我们知道它其实是ICollection类型,那么直接访问Count属性就可以了,对于绝大部分继承该接口的集合来说,这是一个时间复杂度为O(1)的操作(自己实现的其实也应该保证);如果不能确定呢?那只能一个个地加了,复杂度就是O(n)。

同样地,贴一下LastOrDefault()方法的源代码,加深一下读者的理解:

        public static TSource LastOrDefault<TSource>(this IEnumerable<TSource> source) {
            if (source == null) throw Error.ArgumentNull("source");
            IList<TSource> list = source as IList<TSource>;
            if (list != null) {
                int count = list.Count;
                if (count > 0) return list[count - 1];
            }
            else {
                using (IEnumerator<TSource> e = source.GetEnumerator()) {
                    if (e.MoveNext()) {
                        TSource result;
                        do {
                            result = e.Current;
                        } while (e.MoveNext());
                        return result;
                    }
                }
            }
            return default(TSource);
        }

所以,传入前加载好,传入的对象就是一个数组(当然也可以用ToList方法生成列表),这样Last方法获取的就是数组内已经确定的对象。

考虑到实际项目往往都是多人开发,你总不能要求所有开发成员在使用Sing方法时,都要牢记传入Linq结果前保证加载吧?在这方面,机器比人靠谱得多,我更推荐增强代码的健壮性来防呆。

所以另一种方式是将Sing方法实现改为:

          private static void Sing(IEnumerable<Fan> fans)
        {
            if(!(fans is IList<Fan> fanList))
            {
                fanList = fans.ToArray();
            }
            for(int i = 0; i < fanList.Count - 1; i++)
            {
                Console.WriteLine("Don't break my heart");
            }
            Console.WriteLine("喜悦总是出现在我梦中");
        }

我们在方法内部主动判断一次传入参数是否已经加载,如果没有,则先加载生成的结果,再执行遍历结果。

让我们看看现在的执行结果:

完美的尾声

好了,演唱会互动环节顺利地结束了,粉丝们发出了兴奋的欢呼声。

参考网址:

LINQ 查询简介 (C#)​ docs.microsoft.com

后记

转眼已经2019年了,距离我上一次更新专栏已经过去了8个多月。这期间经历了很多,好几次想写写文章,但都被忙和懒打败了。直到最近同事踩了个坑,感觉挺有意思,介绍起来也不甚麻烦,就抽出一点时间写了这篇文章。

另外,在国内对.Net生态普遍悲观的现状下,能保持学习C#热情的人,是很可贵的(当然,在另一些人的眼中,也可能是愚蠢,but who cares?)。而在未更新的8个多月里,专栏居然还逐渐涨到了接近200人的关注,虽然不多,但我很感谢关注的你们。我完全有动力将专栏一直更新下去,尽管频率极不稳定。

.Net Core近年来的发展势头喜人,但是历史告诉我们:靠微软的施舍是不行的。要舍身投入开源社区、积极贡献开源力量,才是正道!