Effective-CSharp-31把针对序列的API设计得更加易于拼接

由于大多数程序的算法都是要在一系列元素而非单一元素上执行操作,因此开发者会使用 foreach、for 循环及 while 等结构。通常把某集合用作输入值,然后检视或修改集合本身或其中元素,最后把另一个集合作为输出值返回给调用方。

这样做的问题在于,若针对整个集合中的每个元素执行操作,程序效率很低。因为执行的操作通常不止一个,且需要多次变换才能把源集合元素转换为目标集合元素。在此过程中,需要创建一些集合保存中间结果,且集合有可能较大,必须等整个集合完成了一次变换操作后,才能继续执行下一次变换操作。要执行几次操作,就得把集合遍历几遍,因此,若执行操作较多,那么算法的执行时间会较长。

另一种办法是,在方法中仅遍历一次,将序列中每个元素都处理一遍,并对其进行各种变换,得到结果。这将提高程序的效率,降低内存使用 (不用每执行一步就创建一个集合)。但这样的的代码很难复用,因为开发者复用的不是整套逻辑,而是其中的一小步。

由于 C# 有迭代器 (iterator),因此,开发者可用它创建出一种方法来操作序列中的元素,这样的方法只会在调用方法真正请求获取元素是才会处理并返回该元素。这些方法以 IEnumerable 型参数表示源序列,并把要生成的目标序列也设计为 IEnumerable,而且通过 yielld return语句返回序列中的元素,使得开发者无需给整个目标序列中的元素分配空间,而是可以等调用方真正用到序列中的下一个元素时采取向源序列查询相关数据,并以此生成所需元素。将通用的 IEnumerable 或针对某种类型的 IEnumerable 设计成方法的输入及输出参数是一种比较少见的思路,因此,很多开发者都不会这样做,但这种思路能带来许多好处。与传统方法相比,这种延迟执行 (deferred execution,见37条)机制可以降低算法所需内存空间,使算法各部分能够更灵活的拼接复用 (见40条)。还可把不同操作放在不用的 CPU 内核中执行,进一步的提高程序性能。可创建泛型方法,扩大其使用范围。

如下实例将序列中每种元素输出一次 (重复元素不输出):

1
2
3
4
5
6
7
8
9
10
11
public static void Unique(IEnumerable<int> nums)
{
var uniqueSet = new HashSet<int>();

foreach (int num in nums)
{
if (uniqueSet.Contains(num)) continue;
uniqueSet.Add(num);
Console.WriteLine(num);
}
}

此方法虽然简单,但是不能进行复用。

可以考虑改用迭代器方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static IEnumerable<int> UniqueV2(IEnumerable<int> nums)
{
var uniqueSet = new HashSet<int>();

foreach (int num in nums)
{
if (uniqueSet.Contains(num)) continue;
uniqueSet.Add(num);
yield return num;
}
}

public static void Main(string[] args)
{
int[] nums = {0, 3, 4, 5, 7, 3, 2, 7, 8, 0, 3, 1};
foreach (int i in UniqueV2(nums))
Console.WriteLine(i);

Console.WriteLine(UniqueV2(nums).First());
}

有人认为这样改差不多,没什么好处。加上一些追踪语句,能让你更清楚此方法的运作:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public static IEnumerable<int> UniqueV2(IEnumerable<int> nums)
{
var uniqueSet = new HashSet<int>();
Console.WriteLine("\tEntering Unique");
foreach (int num in nums)
{
Console.WriteLine($"\tEvaluating {num.ToString()}");
if (uniqueSet.Contains(num)) continue;
uniqueSet.Add(num);
yield return num;
Console.WriteLine("\tRe-entering after yield return");
}

Console.WriteLine("\tExiting Unique");
}

public static void Main(string[] args)
{
int[] nums = {0, 3, 4, 5, 7, 3, 2, 7, 8, 0, 3, 1};

// Entering Unique
// Evaluating 0
// 0
// Re-entering after yield return
// Evaluating 3
// 3
// Re-entering after yield return
// Evaluating 4
// 4
// Re-entering after yield return
// Evaluating 5
// 5
// Re-entering after yield return
// Evaluating 7
// 7
// Re-entering after yield return
// Evaluating 3
// Evaluating 2
// 2
// Re-entering after yield return
// Evaluating 7
// Evaluating 8
// 8
// Re-entering after yield return
// Evaluating 0
// Evaluating 3
// Evaluating 1
// 1
// Re-entering after yield return
// Exiting Unique
foreach (int i in UniqueV2(nums))
Console.WriteLine(i);

// Entering Unique
// Evaluating 0
// 0
Console.WriteLine(UniqueV2(nums).First()); // Ryuu: 添加一个示例

// foreach (int num in Square(UniqueV2(nums)))
// Console.WriteLine($"Number returned from unique square: {num.ToString()}");
}

之所以有这样的效果,关键就在于 yield return 语句。此语句会返回值,并保留信息,记录当前执行的位置及内部迭代逻辑的状态。用此语句写出来的方法,输入输出值都是迭代器,其迭代逻辑可根据早前保留的信息判断当前应读取输入序列的哪一元素,据此生成并返回输出序列中的下一元素。此方法属于可从上次执行位置继续执行的方法 (continuable method),系统每次运行它时,可根据先前记录的状态信息决定继续执行的位置。

将 Unique() 方法改写成连续方法 (continuation method) 有两个好处:

  1. 推迟了每个元素的求值时机,提高程序效率。
  2. 此操作可拼接,可灵活复用。

反之,想用包含 foreach 循环的命令式方法进行灵活复用则较为困难。

注意,Unique() 方法还可转换为泛型方法:

1
2
3
4
5
6
7
8
9
10
public static IEnumerable<T> UniqueV3<T>(IEnumerable<T> sequence)
{
var uniqueSet = new HashSet<T>();
foreach (T item in sequence)
{
if (uniqueSet.Contains(item)) continue;
uniqueSet.Add(item);
yield return item;
}
}

迭代器方法可把多个步骤拼接成一套流程。若要输出是每一种数值的平方,接上一个 Square() 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static IEnumerable<int> Square(IEnumerable<int> nums)
{
foreach (int num in nums)
yield return num * num;
}

public static void Main(string[] args)
{
int[] nums = {0, 3, 4, 5, 7, 3, 2, 7, 8, 0, 3, 1};

foreach (int num in Square(UniqueV2(nums)))
Console.WriteLine($"Number returned from unique square: {num.ToString()}");

}

无论使用多少个迭代器方法,仅需将源序列迭代一次即可。

将序列用作迭代器的输入参数。并令其输出另一序列是一种很好的思路,这使得开发者能设计更多的用法。若迭代器方法的参数不是一个序列而是两个,可用这样的迭代器方法将两个序列合并起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static IEnumerable<string> Zip(IEnumerable<string> first, IEnumerable<string> second)
{
using (var firstSequence = first.GetEnumerator())
{
using (var secondSequence = second.GetEnumerator())
{
while (firstSequence.MoveNext() && secondSequence.MoveNext())
{
yield return $"{firstSequence.Current}{secondSequence.Current}";
}
}
}
}

Zip() 从两个不同的字符串序列中分别取出一个元素,并连接成为新字符串,输出目标序列。当然,此方法也可设计成泛型方法,只不过稍复杂 (见18条)。

迭代器方法不会修改源序列本身,而是会依次产生目标序列中的元素,这些元素构成一个新序列。若源序列中的元素是引用型,那么迭代器有可能在处理元素时改动该元素内容。

如果能把复杂的算法拆解成多个步骤,并将每个步骤都表示成小型的迭代器方法,那么可将这些方法拼成一条管道,使得程序仅需遍历一次源序列处理,即可对其中元素进行多种小变换。