Effective-CSharp-31把针对序列的API设计得更加易于拼接
由于大多数程序的算法都是要在一系列元素而非单一元素上执行操作,因此开发者会使用 foreach、for 循环及 while 等结构。通常把某集合用作输入值,然后检视或修改集合本身或其中元素,最后把另一个集合作为输出值返回给调用方。
这样做的问题在于,若针对整个集合中的每个元素执行操作,程序效率很低。因为执行的操作通常不止一个,且需要多次变换才能把源集合元素转换为目标集合元素。在此过程中,需要创建一些集合保存中间结果,且集合有可能较大,必须等整个集合完成了一次变换操作后,才能继续执行下一次变换操作。要执行几次操作,就得把集合遍历几遍,因此,若执行操作较多,那么算法的执行时间会较长。[1]
另一种办法是,在方法中仅遍历一次,将序列中每个元素都处理一遍,并对其进行各种变换,得到结果。这将提高程序的效率,降低内存使用 (不用每执行一步就创建一个集合)。但这样的的代码很难复用,因为开发者复用的不是整套逻辑,而是其中的一小步。[2]
由于 C# 有迭代器 (iterator),因此,开发者可用它创建出一种方法来操作序列中的元素,这样的方法只会在调用方法真正请求获取元素是才会处理并返回该元素。这些方法以 IEnumerable
如下实例将序列中每种元素输出一次 (重复元素不输出):
1 | public static void Unique(IEnumerable<int> nums) |
此方法虽然简单,但是不能进行复用。
可以考虑改用迭代器方法:
1 | public static IEnumerable<int> UniqueV2(IEnumerable<int> nums) |
有人认为这样改差不多,没什么好处。加上一些追踪语句,能让你更清楚此方法的运作:
1 | public static IEnumerable<int> UniqueV2(IEnumerable<int> nums) |
之所以有这样的效果,关键就在于 yield return 语句。此语句会返回值,并保留信息,记录当前执行的位置及内部迭代逻辑的状态。用此语句写出来的方法,输入输出值都是迭代器,其迭代逻辑可根据早前保留的信息判断当前应读取输入序列的哪一元素,据此生成并返回输出序列中的下一元素。此方法属于可从上次执行位置继续执行的方法 (continuable method),系统每次运行它时,可根据先前记录的状态信息决定继续执行的位置。[4]
将 Unique() 方法改写成连续方法 (continuation method) 有两个好处:
- 推迟了每个元素的求值时机,提高程序效率。
- 此操作可拼接,可灵活复用。[5]
反之,想用包含 foreach 循环的命令式方法进行灵活复用则较为困难。
注意,Unique() 方法还可转换为泛型方法:[6]
1 | public static IEnumerable<T> UniqueV3<T>(IEnumerable<T> sequence) |
迭代器方法可把多个步骤拼接成一套流程。若要输出是每一种数值的平方,接上一个 Square() 即可:
1 | public static IEnumerable<int> Square(IEnumerable<int> nums) |
无论使用多少个迭代器方法,仅需将源序列迭代一次即可。[7]
将序列用作迭代器的输入参数。并令其输出另一序列是一种很好的思路,这使得开发者能设计更多的用法。若迭代器方法的参数不是一个序列而是两个,可用这样的迭代器方法将两个序列合并起来:[8]
1 | public static IEnumerable<string> Zip(IEnumerable<string> first, IEnumerable<string> second) |
Zip() 从两个不同的字符串序列中分别取出一个元素,并连接成为新字符串,输出目标序列。当然,此方法也可设计成泛型方法,只不过稍复杂 (见18条)。
迭代器方法不会修改源序列本身,而是会依次产生目标序列中的元素,这些元素构成一个新序列。若源序列中的元素是引用型,那么迭代器有可能在处理元素时改动该元素内容。[9]
如果能把复杂的算法拆解成多个步骤,并将每个步骤都表示成小型的迭代器方法,那么可将这些方法拼成一条管道,使得程序仅需遍历一次源序列处理,即可对其中元素进行多种小变换。[10]
Bill Wagner, Effective C# (Covers C# 6.0), 3rd Edition 在本条目中讨论了把整集合一步步物化为中间集合的成本;Microsoft Learn 的 Deferred execution and lazy evaluation in LINQ 也说明延迟执行可以避免不必要的中间集合和提前计算。 ↩︎
Effective C# Item 31 用命令式单次遍历与可拼接迭代器方法对比,支撑“单次遍历可能高效但小步骤复用困难”的设计取舍。 ↩︎
Microsoft Learn
IEnumerable<T>Interface、yieldstatement 与 Deferred execution and lazy evaluation in LINQ 支撑IEnumerable<T> -> IEnumerable<T>、按需产生元素和延迟执行的描述;关于多核执行,来源只支持“可组合步骤便于进一步调度或并行化”,不表示yield return会自动把操作放到多个 CPU 内核。 ↩︎Microsoft Learn
yieldstatement 说明yield return会逐个返回元素并保留迭代状态,直到枚举继续推进。 ↩︎Microsoft Learn Deferred execution and lazy evaluation in LINQ 说明查询通常在枚举时执行,延迟执行可按需取值并组合多个查询操作;Effective C# Item 31 将这一点用于序列 API 设计。 ↩︎
Microsoft Learn
IEnumerable<T>Interface 将序列表达为类型安全的泛型枚举接口,支撑把Unique这类逐项操作推广为泛型方法。 ↩︎对于文中
UniqueV2和Square这类逐项yield return方法,延迟枚举可形成流水线并减少中间集合;但 Microsoft Learn CA1851 提醒IEnumerable多次枚举可能重复执行工作,且排序、分组、去重等操作仍可能需要内部缓存。 ↩︎Microsoft Learn
Enumerable.ZipMethod 是“多个输入序列产生一个结果序列”的标准库形态,支撑文中把两个序列合并为目标序列的 API 设计。 ↩︎Microsoft Learn
yieldstatement 支撑迭代器逐个产生目标序列元素;Reference types 说明引用类型变量保存对象引用,因此边界是:迭代器本身不必修改源集合结构,但若元素是引用类型,处理代码仍可能修改被引用对象。 ↩︎本文的总结由 Effective C# Item 31、Microsoft Learn
yieldstatement、Deferred execution and lazy evaluation in LINQ 与 CA1851 共同支撑:序列 API 适合拆成可组合步骤,但必须同时说明延迟执行、多次枚举和物化边界。 ↩︎