Effective-CSharp-37尽量采用惰性求值的方式来查询,而不要及早求值
定义查询操作,程序并不会立刻把数据获取并填充至序列,因为定义的实际上只是一套执行步骤,当真正需要遍历结果时,才会执行。每迭代一遍产生一套新结果,这叫做***惰性求值 (lazy evaluation),反之,若像普通代码一样直接查询某套变量的取值并立即记录,那么就称为及早求值 (eager evaluation)***。
若定义查询操作需多次执行,请考虑采用哪种求值方式。是给数据做一份快照,还是先把逻辑记录下来,以便随时根据该逻辑查询,并将结果置入序列?
惰性求值与一般编写代码时的思路有很大区别,LINQ 查询操作把代码当数据看,用作参数的 lamdba 表达式要等到调用时才执行。此外,如果 provider 使用表达式树 (expression tree) 而不是委托,那么稍后可能还会有新的表达式融入树中。
通过以下示例演示惰性求值与及早求值的区别。生成一个序列,暂停,迭代,暂停,再迭代:
1 | private static IEnumerable<TResult> Generate<TResult>(int number, Func<TResult> generator) |
注意观察结果中的时间戳 (time stamp)。由此可知,前后两次迭代所生成的是不同的两个序列,因为 sequence 变量保存的不是创建好的元素,而是创建元素所用的表达式树。(Ryuu:在 Rider 中编写该代码,将出现 Possible multiple enumeration 警告,同样告知,此操作可能导致遍历序列前后不一致。)
由于查询表达式能够惰性求值,因此可以在现有的查询操作后继续拼接其他的操作。
如下示例将 sequence1 序列得到的时间转换成协调世界时:
1 | var sequence1 = Generate(10, () => DateTime.Now); |
sequence1 和 sequence2 两个序列是在功能层面上组合,而不是在数据层面上。系统并不会先把 sequence1 的所有值拿出来,然后全部修改一遍,构成 sequence2。而是执行相关的代码,把 sequence1 的元素生成出来,紧接着执行另一端代码处理该元素,将结果放入sequence2。程序并不会把时间都准备好,并将其转换为协调世界时,而是等待调用时再去生成该序列中被调用的时间。
由于查询表达式可惰性求值,因此,理论上来说,它可以操作无穷序列 (infinite sequence)。如果代码写的较为合理,那么程序仅需检查开头部分即可,因为在完成查询后程序会停止。反之,有些写法令表达式必须把整个序列处理一遍才能得到完整结果:
1 | /// <summary> |
此示例不必生成整个序列,而是仅生成十个数 (虽然 AllNumbers() 可以生成至 int.MaxValue)。Take() 方法只需要其中的前 N 个对象。
反之,如果把查询语句改成这样,程序将一直运行,直到 int.MaxValue才停下:
1 | var answers = from number in AllNumbers() where number < 10 select number; |
查询语句需要逐个判断序列中的每个元素,并根据其是否小于 10 决定要不要生成该元素,该逻辑导致其必须遍历整个元素。
某些查询操作必须把整个序列处理一遍,然后才能得到结果。比如上例的 where,以及 OrderBy、Max、Min。对于可能无限延伸下去的序列来说,尽量不要执行此操作。即使是有限长度,还是应尽量利用过滤机制来缩减待处理的数据,以提高程序效率。
1 | // Order before filter. |
第一种方法会将所有产品排序,然后剔除小于等于 100 的产品,而第二种,则是先剔除小于等于 100 的产品,然后再排序,这样的话待排序的数据量可能减小。在编写算法时,请安排合适的执行顺序,这样的算法可能执行很快,反之,则可能极为耗时。
笔者列举了以上理由,建议查询时优先考虑惰性求值,但在个别情况下,可能想要给结果做一份快照,这是可以考虑 ToList() 及 ToArray(),他们都能立刻根据查询结果来生成序列,并保存至容器中。该方法用在以下两个场合:
- 需要立即执行查询操作
- 将来还要使用同一套查询结果
与及早求值的方法比,惰性求值能减少程序工作量,且使用起来更灵活。若有需要,可使用 ToList() 及 ToArray() 来保存结果。