Effective-CSharp-37尽量采用惰性求值的方式来查询,而不要及早求值
定义查询操作,程序并不会立刻把数据获取并填充至序列,因为定义的实际上只是一套执行步骤,当真正需要遍历结果时,才会执行。每迭代一遍产生一套新结果,这叫做惰性求值 (lazy evaluation),反之,若像普通代码一样直接查询某套变量的取值并立即记录,那么就称为及早求值 (eager evaluation)。[1]
若定义查询操作需多次执行,请考虑采用哪种求值方式。是给数据做一份快照,还是先把逻辑记录下来,以便随时根据该逻辑查询,并将结果置入序列?[2]
惰性求值与一般编写代码时的思路有很大区别,LINQ 查询操作把代码当数据看,用作参数的 lamdba 表达式要等到调用时才执行。此外,如果 provider 使用表达式树 (expression tree) 而不是委托,那么稍后可能还会有新的表达式融入树中。[3]
通过以下示例演示惰性求值与及早求值的区别。生成一个序列,暂停,迭代,暂停,再迭代:
1 | private static IEnumerable<TResult> Generate<TResult>(int number, Func<TResult> generator) |
注意观察结果中的时间戳 (time stamp)。由此可知,前后两次迭代所生成的是不同的两个序列,因为 sequence 变量保存的不是创建好的元素,而是创建元素所用的表达式树。(Ryuu:在 Rider 中编写该代码,将出现 Possible multiple enumeration 警告,同样告知,此操作可能导致遍历序列前后不一致。)[4]
由于查询表达式能够惰性求值,因此可以在现有的查询操作后继续拼接其他的操作。[5]
如下示例将 sequence1 序列得到的时间转换成协调世界时:
1 | var sequence1 = Generate(10, () => DateTime.Now); |
sequence1 和 sequence2 两个序列是在功能层面上组合,而不是在数据层面上。系统并不会先把 sequence1 的所有值拿出来,然后全部修改一遍,构成 sequence2。而是执行相关的代码,把 sequence1 的元素生成出来,紧接着执行另一端代码处理该元素,将结果放入sequence2。程序并不会把时间都准备好,并将其转换为协调世界时,而是等待调用时再去生成该序列中被调用的时间。[6]
由于查询表达式可惰性求值,因此,理论上来说,它可以操作无穷序列 (infinite sequence)。如果代码写的较为合理,那么程序仅需检查开头部分即可,因为在完成查询后程序会停止。反之,有些写法令表达式必须把整个序列处理一遍才能得到完整结果:[7]
1 | /// <summary> |
此示例不必生成整个序列,而是仅生成十个数 (虽然 AllNumbers() 可以生成至 int.MaxValue)。Take() 方法只需要其中的前 N 个对象。[8]
反之,如果把查询语句改成这样,程序将一直运行,直到 int.MaxValue才停下:
1 | var answers = from number in AllNumbers() where number < 10 select number; |
查询语句需要逐个判断序列中的每个元素,并根据其是否小于 10 决定要不要生成该元素,该逻辑导致其必须遍历整个元素。[9]
某些查询操作必须把整个序列处理一遍,然后才能得到结果。比如上例的 where,以及 OrderBy、Max、Min。对于可能无限延伸下去的序列来说,尽量不要执行此操作。即使是有限长度,还是应尽量利用过滤机制来缩减待处理的数据,以提高程序效率。[10]
1 | // Order before filter. |
第一种方法会将所有产品排序,然后剔除小于等于 100 的产品,而第二种,则是先剔除小于等于 100 的产品,然后再排序,这样的话待排序的数据量可能减小。在编写算法时,请安排合适的执行顺序,这样的算法可能执行很快,反之,则可能极为耗时。[11]
笔者列举了以上理由,建议查询时优先考虑惰性求值,但在个别情况下,可能想要给结果做一份快照,这是可以考虑 ToList() 及 ToArray(),他们都能立刻根据查询结果来生成序列,并保存至容器中。该方法用在以下两个场合:[12]
- 需要立即执行查询操作
- 将来还要使用同一套查询结果
与及早求值的方法比,惰性求值能减少程序工作量,且使用起来更灵活。若有需要,可使用 ToList() 及 ToArray() 来保存结果。[13]
Bill Wagner, Effective C# (Covers C# 6.0), 3rd Edition 在本条目中讨论惰性与及早求值的实践取舍;Microsoft Learn Deferred execution and lazy evaluation 说明延迟执行会把表达式求值推迟到真正需要结果时。 ↩︎
Microsoft Learn
IEnumerable<T>Interface、CA1851 与Enumerable.ToList分别支撑“序列不等于快照”“多次枚举可能重复执行”和“需要快照时显式物化”的边界。 ↩︎Microsoft Learn
IQueryable<T>Interface 说明IQueryable<T>关联表达式树和查询提供程序,枚举会触发 provider 执行;Deferred execution and lazy evaluation 支撑 lambda 或查询逻辑在枚举时才执行的惰性行为。 ↩︎Microsoft Learn CA1851 说明多次枚举
IEnumerable可能重复执行昂贵操作或产生副作用;这也解释了示例中两次枚举同一查询会得到不同时间戳。 ↩︎Microsoft Learn Deferred execution and lazy evaluation 和 LINQ 查询运算符执行分类 支撑查询操作可以先组合,等枚举或终端操作时再执行。 ↩︎
Microsoft Learn Deferred execution and lazy evaluation 描述查询变量保存查询命令而非结果数据;EF Core Buffering and streaming 也区分流式处理和把结果缓冲到内存的执行边界。 ↩︎
Microsoft Learn LINQ 查询运算符执行分类 区分 streaming 与 nonstreaming 延迟操作;流式操作可按需推进,非流式操作可能必须先读取完整输入。 ↩︎
Microsoft Learn 的 LINQ 执行分类将部分运算符归为 deferred streaming;结合 Deferred execution and lazy evaluation,
Take这类按需操作可以在取得所需元素后停止推进上游序列。 ↩︎Microsoft Learn 的执行分类中
Where属于 deferred streaming;本文示例的边界是:where number < 10可以逐个产出匹配元素,但没有Take等终止条件时,foreach为了确认序列结束仍会继续推进源序列直到耗尽。 ↩︎Microsoft Learn LINQ 查询运算符执行分类 将运算符分为 immediate、deferred streaming、deferred nonstreaming;
OrderBy这类非流式延迟操作通常要先读取完整输入,Max/Min这类聚合也需要检查完整输入才能给出最终结果。 ↩︎Microsoft Learn 的执行分类说明排序属于非流式延迟操作;因此先过滤再排序通常能减少排序阶段需要处理的元素数量,EF Core Efficient Querying 也强调查询执行方式会影响缓冲和流式成本。 ↩︎
Microsoft Learn
Enumerable.ToList与Enumerable.ToArray支撑ToList()/ToArray()会从源序列创建物化集合;EF Core Buffering and streaming 将这类操作称为缓冲全部结果。 ↩︎Effective C# 中区分 early execution 与 deferred execution 的条目、Microsoft Learn Deferred execution and lazy evaluation、CA1851 与
ToList/ToArray文档共同支撑本文总结:优先保持查询可组合和按需执行,但在快照、多次读取或资源边界处显式物化。 ↩︎