Effective-CSharp-37尽量采用惰性求值的方式来查询,而不要及早求值

定义查询操作,程序并不会立刻把数据获取并填充至序列,因为定义的实际上只是一套执行步骤,当真正需要遍历结果时,才会执行。每迭代一遍产生一套新结果,这叫做惰性求值 (lazy evaluation),反之,若像普通代码一样直接查询某套变量的取值并立即记录,那么就称为及早求值 (eager evaluation)

若定义查询操作需多次执行,请考虑采用哪种求值方式。是给数据做一份快照,还是先把逻辑记录下来,以便随时根据该逻辑查询,并将结果置入序列?

惰性求值与一般编写代码时的思路有很大区别,LINQ 查询操作把代码当数据看,用作参数的 lamdba 表达式要等到调用时才执行。此外,如果 provider 使用表达式树 (expression tree) 而不是委托,那么稍后可能还会有新的表达式融入树中。

通过以下示例演示惰性求值与及早求值的区别。生成一个序列,暂停,迭代,暂停,再迭代:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private static IEnumerable<TResult> Generate<TResult>(int number, Func<TResult> generator)
{
for (var i = 0; i < number; i++)
yield return generator();
}

/// <summary>
/// Start time for Test One: 8:37:28 PM
/// Waiting... Press Return
///
/// Iterating...
/// 8:37:30 PM
/// ...
/// 8:37:30 PM
/// Waiting... Press Return
///
/// Iterating...
/// 8:37:39 PM
/// ...
/// 8:37:39 PM
/// </summary>
private static void LazyEvaluation()
{
Console.WriteLine($"Start time for Test One: {DateTime.Now:T}");
var sequence = Generate(10, () => DateTime.Now);

Console.WriteLine("Waiting... \tPress Return");
Console.ReadLine();

Console.WriteLine("Iterating...");
foreach (DateTime dateTime in sequence) // warning: Possible multiple enumeration
Console.WriteLine($"{dateTime:T}");

Console.WriteLine("Waiting... \tPress Return");
Console.ReadLine();

Console.WriteLine("Iterating...");
foreach (DateTime dateTime in sequence) // warning: Possible multiple enumeration
Console.WriteLine($"{dateTime:T}");
}

注意观察结果中的时间戳 (time stamp)。由此可知,前后两次迭代所生成的是不同的两个序列,因为 sequence 变量保存的不是创建好的元素,而是创建元素所用的表达式树。(Ryuu:在 Rider 中编写该代码,将出现 Possible multiple enumeration 警告,同样告知,此操作可能导致遍历序列前后不一致。)

由于查询表达式能够惰性求值,因此可以在现有的查询操作后继续拼接其他的操作。

如下示例将 sequence1 序列得到的时间转换成协调世界时:

1
2
3
4
5
6
7
var sequence1 = Generate(10, () => DateTime.Now);
var sequence2 =
from dateTime in sequence1
select dateTime.ToUniversalTime();

foreach (DateTime dateTime in sequence2)
Console.WriteLine($"{dateTime:T}");

sequence1 和 sequence2 两个序列是在功能层面上组合,而不是在数据层面上。系统并不会先把 sequence1 的所有值拿出来,然后全部修改一遍,构成 sequence2。而是执行相关的代码,把 sequence1 的元素生成出来,紧接着执行另一端代码处理该元素,将结果放入sequence2。程序并不会把时间都准备好,并将其转换为协调世界时,而是等待调用时再去生成该序列中被调用的时间。

由于查询表达式可惰性求值,因此,理论上来说,它可以操作无穷序列 (infinite sequence)。如果代码写的较为合理,那么程序仅需检查开头部分即可,因为在完成查询后程序会停止。反之,有些写法令表达式必须把整个序列处理一遍才能得到完整结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// 0123456789
/// </summary>
private static void LazyEvaluation3()
{
var answers = from number in AllNumbers() select number;
var smallNumbers = answers.Take(10);

foreach (int num in smallNumbers)
Console.Write(num);
}

private static IEnumerable<int> AllNumbers()
{
var number = 0;

while (number < int.MaxValue)
yield return number++;
}

此示例不必生成整个序列,而是仅生成十个数 (虽然 AllNumbers() 可以生成至 int.MaxValue)。Take() 方法只需要其中的前 N 个对象。

反之,如果把查询语句改成这样,程序将一直运行,直到 int.MaxValue才停下:

1
2
3
4
var answers = from number in AllNumbers() where number < 10 select number;

foreach (int num in answers)
Console.Write(num);

查询语句需要逐个判断序列中的每个元素,并根据其是否小于 10 决定要不要生成该元素,该逻辑导致其必须遍历整个元素。

某些查询操作必须把整个序列处理一遍,然后才能得到结果。比如上例的 where,以及 OrderBy、Max、Min。对于可能无限延伸下去的序列来说,尽量不要执行此操作。即使是有限长度,还是应尽量利用过滤机制来缩减待处理的数据,以提高程序效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Order before filter.
var sortedProductsSlow =
from p in products
orderby p.UnitsInStock descending
where p.UnitsInStock > 100
select p;

// Filter before order.
var sortedProductsFast =
from p in products
where p.UnitsInStock > 100
orderby p.UnitsInStock descending
select p;

第一种方法会将所有产品排序,然后剔除小于等于 100 的产品,而第二种,则是先剔除小于等于 100 的产品,然后再排序,这样的话待排序的数据量可能减小。在编写算法时,请安排合适的执行顺序,这样的算法可能执行很快,反之,则可能极为耗时。

笔者列举了以上理由,建议查询时优先考虑惰性求值,但在个别情况下,可能想要给结果做一份快照,这是可以考虑 ToList() 及 ToArray(),他们都能立刻根据查询结果来生成序列,并保存至容器中。该方法用在以下两个场合:

  1. 需要立即执行查询操作
  2. 将来还要使用同一套查询结果

与及早求值的方法比,惰性求值能减少程序工作量,且使用起来更灵活。若有需要,可使用 ToList() 及 ToArray() 来保存结果。