C#学习笔记(三)—–C#高级特性:实现迭代器的捷径

实现迭代器的捷径

本章摘自《C# in depth》,内容包括:
在C#1中实现迭代器
C#中的迭代器块
迭代器使用示例
使用迭代器作为协同程序


  • 迭代 器 模式 是 行为 模式 的 一种 范例, 行为 模式 是一 种 简化 对象 之间 通信 的 设计 模式。 这是 一种 非常 易于 理解 和 使用 的 模式。 实际上, 它 允许 你 访问 一个 数据项 序列 中的 所有 元素, 而无 须 关心 序列 是什么 类型—— 数组、列表、 链 表 或 任何 其他 类型。 它 能 非常 有效地 构建 出 一个 数据 管道, 经过 一系列 不同 的 转换 或 过滤 后再 从 管道 的 另一 端出 来。 实际上, 这也 是 LINQ 的 核心 模式 之一。

    在. NET 中, 迭代 器 模式 是 通过 IEnumerator 和 IEnumerable 接口 及 它们 的 泛 型 等价物 来 封装 的( 命名 上 有些 不恰当—— 涉及 模式 时 通常 称为 迭代 而非 枚举, 就是 为了 避免 和 枚举 这个 词 的 其他 意思 相 混淆, 本章 将使 用 迭代 器 和 可 迭代 的)。 如果 某个 类型 实现 了 IEnumerable 接口, 就 意味着 它 可以 被 迭代 访问。 调用 GetEnumerator 方法 将 返回 IEnumerator 的 实现, 这就 是 迭代 器 本身。 可以将 迭代 器 想象 成 数据库 的 游标, 即 序列 中的 某个 位置。 迭代 器 只能 在 序列 中 向前 移动, 而且 对于 同一个 序列 可能 同时 存在 多个 迭代 器 操作。

    作为 一门 语言, C# 1 利用 foreach 语句 实现 了 访问 迭代 器 的 内置 支持。 这 让我 们 遍历 集合 时 无比 容易( 比 直接 使用 for 循环 要 方 便得 多) 并且 看起来 非常 直观。 foreach 语句 被 编译 后 会 调用 GetEnnumerator和 MoveNext 方法 以及 Current 属性, 假如 IDisposable 也 实现 了, 程序 最后 还会 自动 销毁 迭代 器 对象。 这是 一个 虽 不起眼 但却 很有 用的 语法 糖。

    然而, 在 C# 1 中, 实现 迭代 器 是 比较 困难 的。 C# 2 所 提供 的 语法 糖 可以 大大 简化 这个 任务, 所以 有时候 更应该 去 实现 迭代 器 模式。 否则 会 导致 更多 的 工作量。

    本章 将 研究 实现 迭代 器 所需 的 代码, 以及 C# 2 所 给予 的 支持。 在 详细 介绍 了 语法 之后, 我们将 研究 一些 现实 世界 中的 示例, 包括 微软 并发 函数 库( concurrency library) 中 对 迭代 器语法 振奋人心 的 使用( 虽然 有点 异乎寻常)。 我会 在 描述 完全 部 细节 之后 再提供 示例, 因为 要 学的 内容 并不 多, 而且 在理 解了 代码 的 功能 之后 再去 看 示例, 要 清晰 得 多。

C# 1: 手写 迭代 器 的 痛苦

为了 能 顺利 过渡到 C# 2 的 特性 上, 首先 实现 一个 相对 简单 的 迭代 器, 但它 仍可 以 提供 真实 有 用的 值。 假设 我们有 一个 基于 循环 缓冲区 的 新的 集合 类型。 我们将 实现 IEnumerable 接口, 以便 新 类 的 用户 能 轻松 地 迭代 集合 中的 所有 值。 在这里, 我们 不关心 这个 集合 中的 内容 是什么, 仅仅 关注 迭代 器 的 实现。 这个 集合 将把 值 存储 在 一个 数组 中( 就是 object[ ], 这里 不 使用 泛 型), 并且 集合 有一个 有趣 的 特性, 就是 能 设置 它的 逻辑“ 起点”。 所以 如果 数组 有 5 个 元素, 并且 你把 起点 设置 为 2, 这样 的 话 我们 就 看到 元素 2、 3、 4、 0 和 1 依次 返回。 这里 不会 展示 完整 的 循环缓冲 代码, 你 可 以在 可 下载 的 代码 中 找到 它们。
为了 方便 演示 这个 类, 我们将 在 构造 函数 中 设置 值 和 起点。 所以, 编写 代码 清单 1, 来 对 集合 进行 迭代。

//【代码清单1】
 static void Main()
        {
            object[] values = { "a", "b", "c", "d", "e" };
            IterationSample collection = new IterationSample(values, 3);
            foreach (object x in collection)
            {
                Console.WriteLine(x);
            }
        }

运行 代码 清单1应该( 最终) 会 产生 输出 结果 d、 e、 a、 b 和 c, 因为 之前 设置 的 起点 为 3。 现在 知道 我们 要 完成 的 功能 了, 下面 来看 一下 在 代码 清单2中 这个 类 的 框架。

//【代码清单2】
 class IterationSample : IEnumerable
    {
        object[] values;
        int startingPoint;

        public IterationSample(object[] values, int startingPoint)
        {
            this.values = values;
            this.startingPoint = startingPoint;
        }

        public IEnumerator GetEnumerator()
        {
           throw new NotImplementedException();
        }
    }

正如 你 看到 的, 现在 还未 实现 GetEnumerator 方法, 不过 其余 的 代码 已经 可以 运行 了。 那么, 要 如何 实现 GetEnumerator 方法 呢? 首先 要 知道, 我们 需要 在某 个 地方 存储 某个 状态。 迭代 器 模式 的 一个 重要 方面 就是, 不用 一次 返回 所有 数据—— 调用 代码 一次 只需 获取 一个 元素。 这 意味着 我们 需要 确定 访问 到了 数组 中的 哪个 位置。 在 了解 C# 2 编译器 为我 们 所做 的 事情 时, 迭代 器 的 这种 状态 特质 十分重要, 因此 要 密切 关注 本例 中的 状态。 那么, 这个 状态 值 要 保存 在哪里 呢? 假设 我们 尝试 把 它 放在 IterationSample 类 自身 里面, 让 它 既 实现 IEnumerator 接口 又 实现 IEnumerable 接口。 乍一看, 这 似乎是 个 好主意—— 毕竟, 我们 是将 数据 保存 在 正确 的 位置, 其中 也 包括了 起点。 GetEnumerator 方法 可以 仅 返回 this。 然而, 使用 这种 方式 存在 一个 大问题—— 如果 GetEnumerator 方法 被 调用 了 多次, 那么 就会 返回 多个 独立 的 迭代 器。 例如, 我们 能使 用两 个 嵌套 的 foreach 语句, 以便 得到 所有 可能 的 成对 值。 这就 意味着, 两个 迭代 器 需要 彼此 独立, 每次 调用 GetEnumerator 方法 时 都 需要 创建 一个 新 对象。 我们 仍可 以 直接 在 IterationSample 内部 实现 功能, 不过 只用 一个 类 的 话, 分工 就不 明确—— 那样 会 让 代码 非常 混乱。 因此, 可以 创建 另外 一个 类 来 实现 这个 迭代 器。 我们将 使用“ C# 嵌套 类型 可以 访问 它 外层 类型 的 私有 成员” 这一 特点, 就是说, 我们 仅 需要 存储 一个 指向“ 父 级” IterationSample 类型 的 引用 和 关于 所 访 问到 的 位置位置 的 状态, 如 代码 清单 3所示。

//【代码清单3】:嵌套类实现集合迭代器
  class IterationSampleIterator : IEnumerator
        {
            IterationSample parent; //❶ 正在 迭代 的 集合 
            int position; //❷ 指出 遍历 到 的 位置 
            internal IterationSampleIterator(IterationSample parent)
            {
                this.parent = parent; 
                position = -1; //❸ 在 第一个 元素 之前 开始 
            }
            public bool MoveNext()
            {
                if (position != parent.values.Length) //❹ 如果 仍 要 遍历, 那么 增加 position 的 值
                {
                    position++;
                }
                return position < parent.values.Length;
            }
            public object Current
            {
                get
                {
                    if (position == -1 ||position == parent.values.Length) //❺ 防止 访问 第一个 元素 之前 和 最后 一个 元素 之后 

                    {
                        throw new InvalidOperationException();
                    }
                    int index = position + parent.startingPoint; /*❻ 实现 封闭*/
                    index = index % parent.values.Length;
                    return parent.values[index];
                }
            }
            public void Reset()
            {
                position = -1; //❼ 返回 第一个 元素 之前 
            }
        }

这么 简单 的 一个 任务 竟然 使用 了 这么 多 代码! 我们 要 记住 进行 迭代 的 原始 值 的 集合 ❶, 并用 简单 的 从 零 开始 的 数组 跟踪 我们 所在 的 位置 ❷。 为了 返回 元素, 要 根据 开 始点 对 索引 进行 偏移 ❻。 为了 和 接口 一致, 要 让 迭代 器 逻辑上 从 第一个 元素 的 位置 之前 开始 ❸, 所以 在 第一次 使用 Current 属性 之前, 调用 代码 必须 调用 MoveNext 方法。 ❹ 中的 条件 增量 可以 保证 ❺ 中的 条件 判断 简单 准确, 即使 在 程序 第一次 报告 无可 用 数据 后又 调用 MoveNext 也没 有问题。 为了 重置 迭代 器, 我们将 我们 的 逻辑 位置 设置 回“ 第一个 元素 之前” ❼。

这里 涉及 的 大部分 逻辑 都 非常 简单, 当然 还是 有 大量 的 地方 会 出现“ 边界 条件 逻辑 错误”( off- by- one error)。 实际上, 我的 第一个 实现 就是 因为 这个 原因 没有 通过 单元 测试。 不过, 幸好 它 现在 可以 正常 运行 了, 现在 只需 在 IterationSample 中 实现 IEnumerable 接口 来 完成 这个 例子:

//【代码清单4】
public IEnumerator GetEnumerator()
{
  return new IterationSampleIterator( this);
}

要 谨 记 这 只是 一个 相对 简单 的 例子—— 没有 太多 的 状态 需要 跟踪, 也没 有 尝试 检查 集合 是否 在 两次 迭代 之中 被 改变。 实现 一个 简单 的 迭代 器 都 需要 花费 这么 大的 精力, 所以 很少 有人 能在 C# 1 中 实现 这个 模式 也 不足为奇。 开发 人员 通常 喜欢 用 foreach 在 由 框架 提供 的 集合 上 执行 迭代, 或 使用 更 直接( 和 集合 特定) 的 方式 来访 问他 们 自己 构建 的 集合。 因此, 在 C# 1 中用 了 40 行 代码 来 实现 迭代 器。 下面 来看 一下 在 C# 2 中 情况 能否 好转。

C# 2: 利用 yield 语句 简化 迭代 器

  •   迭代 器 块 和 yield return 简介:如果 C# 2 不具备 缩减 实现 迭代 器 所编 写的 代码 量 这个 强大 的 特性, 那么 本章 就 没有 存在 的 必要 了。 在 其他 主题 中, 代码 量 只会 减少 一点点, 或者 仅仅 使 编 写出 的 代码 更 优雅。 然而 在这里, 所需 的 代码 量大 大地 减少 了。 代码 清单5 展示 了 在 C# 2 中 GetEnumerator 方法 的 完整 实现。
//【代码清单5】
 public IEnumerator GetEnumerator()
        {
            for (int index = 0; index < values.Length; index++)
            {
                yield return values[(index + startingPoint) % values.Length];
            }
        }

现在,整个IterationSample类是这样的:

//【代码清单6】
 public class IterationSample : IEnumerable
    {
        object[] values;
        int startingPoint;

        public IterationSample(object[] values, int startingPoint)
        {
            this.values = values; this.startingPoint = startingPoint;
        }

        public IEnumerator GetEnumerator()
        {
            for (int index = 0; index < values.Length; index++)
            {
                yield return values[(index + startingPoint) % values.Length];
            }
        }     
    }

4 行 代码 就 搞 定了, 其中 还有 两行 大 括号。 明确 地 讲, 它们 完全 替换 掉了 整个 IterationSampleIterator 类。 至少 在 源 代码 中 是 这样 的…… 稍后, 我们将 看到 编译器 在 后台 都 做了 哪些 工作, 以及 这个 实现 的 奇特 之处。 不过 此时 还是 先看 一下 这里 用到 的 源 代码。

在 你 看到 yield return 之前, 这个 方法 看上去 一直 都 非常 正常。 这句 代码 就是 告诉 C# 编译器, 这个 方法 不是 一个 普通 的 方法, 而是 实现 一个 迭代 器 块 的 方法。 这个 方法 被 声明 为 返回 一个 IEnumerator 接口, 所以 就 只能 使用 迭代 器 块 来 实现 返回 类型 为 IEnumerable、 IEnumerator 或 泛 型 等价物 的 方法 (或者 属性 也可, 我们 后面 会 看到。 但是 不能 在 匿名 方法 中 使用 迭代 器 代码 块。) 如果 方法 声明 的 返回 类型 是非 泛 型 接口, 那么 迭代 器 块 的 生成 类型( yield type) 是 object, 否则 就是 泛 型 接口 的 类型 参数。 例如, 如果 方法 声明 为 返回 IEnumerable< string>, 那么 就会 得到 string 类型 的 生成 类型。

在 迭代 器 块 中 不允许 包含 普通 的 return 语句—— 只能 是 yield return。 在 代码 块 中, 所有 yield return 语句 都 必须 返回 和 代码 块 的 生成 类型 兼容 的 值。 在 之前 的 例子 中, 不能 在 一个 声明 返回 IEnumerable< string> 的 方法 中 编写 yield return 1; 这样 的 代码。

说明 : 对 yield return 的 限制   对 yield 语句 有 一些 额外 的 限制。如果 存在 任何 catch 代码 块, 则 不 能在 try 代码 块 中 使用 yield return, 并且 在 finally 代码 块 中 也不能 使用 yield return 或 yield break( 这个 语句 马上 就要 讲到)。 这 并非 意味着 不能 在 迭代 器 内部 使用 try/ catch 或 try/ finally 代码 块, 只是 说 使用 它们 时有 一些 限制 而已 。如果 想 了解 为什么 会 存在 这样 的 限制, 可以 查看 Eric Lippert 的 一个 博 文 系列, 其中 介绍 了 这种 限制 以及 迭代 器 方面 的 其他 设计 决策, 网址 为 http:// mng. bz/ EJ97。

编写 迭代 器 块 时, 需要 记住 重要的 一点: 尽管 你编 写了 一个 似乎是 顺序 执行 的 方法, 但 实际上 是 请求 编译器 为你 创建 了 一个 状态 机。 编译器 这样做 的 原因, 和 我们 在 C# 1 的 迭代 器 实现 中 塞入 那么 多 代码 的 原因 完全 一样—— 调用 者 每次 只想 获取 一个 元素, 所以 在 返回 上一个 值 时 需要 跟踪 当前 的 工作 状态。 当 编译器 看到 迭代 器 块 时, 会为 状态 机 创建 一个 嵌套 类型, 来 正确 记录 块 中的 位置 以及 局部 变量( 包括 参数) 的 值。 所 创建 的 类 类似于 我们 之前 用 普通 方法 实现 的 类, 用 实例 变量 来 保存 所有 必要 的 状态。 下面 来看 一下, 要 实现 迭代 器, 这个 状态 机要 做 哪些 事情:
①它 必须 具有 某个 初始 状态;
②每次 调用 MoveNext 时, 在 提供 下一个 值 之前( 换句话说, 就是 执行 到 yield return 语句 之前), 它 需要 执行 GetEnumerator 方法 中的 代码;
③ 使用 Current 属性 时, 它 必须 返回 我们 生成 的 上一个 值;
④它 必须 知道 何时 完成 生成 值 的 操作, 以便 MoveNext 返回 false。
要 实现 上述 内容 的 第二 点 需要 一定 的 技巧, 因为 它 总是 要从 之前 达到 的 位置“ 重新 开始” 执行 代码。 跟踪 局部 变量( 当 变量 处于 方法 中 时) 不算 太难—— 它们 在 状态 机中 由 实例 变量 来 表示。 而 重新 启动(指 继续 执行 yield return 之后 的 代码。) 的 动作 更 需 技巧, 不过 好在 你 不用 自己 编写 C# 编译器, 所以 不用 关心 它是 如何 实现 的, 只要 明白 从 黑 盒 出来 的 结果 能 正确 工作 就 行。 在 迭代 器 块 中, 你 可以 编写 普通 代码, 编译器 负责 确保 执行 流程 和在 其他 方法 中 一样 正确。 不同 的 是, yield return 语句 只 表示“ 暂时 地” 退出 方法—— 事实上, 你 可把 它 当作 暂停。
接下来, 我们 用 更加 形象 的 方式 深入研究 执行 流程。

观察 迭代 器 的 工作 流程

使用 序列 图 的 方式 有助 我们 充分 了解 迭代 器 是 如何 执行 的。 我们 不用 手工 绘制 这个 图, 而是 用 程序 把 流程 打印 出来( 代码 清单7)。 这个 迭代 器 本身 仅仅 提供 了 一个 数字 序列( 0, 1, 2,- 1)。 有意义 的 部分 不是 这些 数字, 而是 代码 的 流程。

//【代码清单7】
static readonly string Padding = new string(' ', 30);
static IEnumerable<int> CreateEnumerable()
{
Console.WriteLine("{0}Start of CreateEnumerable()", Padding);
for (int i=0; i < 3; i++)
{
Console.WriteLine("{0}About to yield {1}", Padding, i);
yield return i;
Console.WriteLine("{0}After yield", Padding);
}
Console.WriteLine("{0}Yielding final value", Padding);
yield return -1;
Console.WriteLine("{0}End of CreateEnumerable()", Padding);
}
...
IEnumerable<int> iterable = CreateEnumerable();
IEnumerator<int> iterator = iterable.GetEnumerator();
Console.WriteLine("Starting to iterate");
while (true)
{
Console.WriteLine("Calling MoveNext()...");
bool result = iterator.MoveNext();
Console.WriteLine("... MoveNext result={0}", result);
if (!result)
{
break;
}
Console.WriteLine("Fetching Current...");
Console.WriteLine("... Current result={0}", iterator.Current);
}

代码 清单 7的 代码 确实 不够 优雅, 尤其 进行 迭代 的 代码 更是如此。 在 通常 的 写法 中, 我们 一般 使用 foreach 循环 语句, 不过 为了 完全 地 展现 运行 过程中 何时 发生 何事, 须 把 迭代 器 分割 为 一些 很小 的 片段。 这段 代码 的 作用 同 foreach 大致 相同, 然而 foreach 还会 在最 后 调用 Dispose 方法, 随后 我们 会 看到, 这对 于 迭代 器 块 是 很重 要的。 可以 看到, 虽然 这次 我们 返回 的 是 IEnumerable< int> 而非 IEnumerator< int>, 但 迭代 器 方法 里 的 语法 没有 任何 区别。 通常 为了 实现 IEnumerable< T>, 我们 只会 返回 IEnumerator< T>。 如果 你 只想在 方法 中 生成 一个 序列, 可以 返回 IEnumerable< T>。
这里写图片描述

这个 结果 中有 几个 重要的 事情 需要 牢记:
①在 第一次 调用 MoveNext 之前, CreateEnumerable 中的 代码 不会 被 调用;
② 所有 工作 在 调用 MoveNext 时 就 完成 了, 获取 Current 的 值 不会 执行 任何 代码; ③在 yield return 的 位置, 代码 就 停止 执行, 在下 一次 调用 MoveNext 时 又 继续 执行;
④在 一个 方法 中的 不同 地方 可以 编写 多个 yield return 语句;
⑤ 代码 不会 在最 后的 yield return 处 结束, 而是 通过 返回 false 的 MoveNext 调用 来 结束 方法 的 执行。
第一 点 尤为 重要, 因为 它 意味着 如果 在 方法 调用 时 需要 立即 执行 代码, 就不能 使用 迭代 器 块, 如 参数 验证。 如果 你将 普通 检查 放入 用 迭代 器 块 实现 的 方法 中, 将不 能 很好 地 工作。 你 肯定 会在 某些 时候 违反 这些 约束—— 这是 十分 常见 的 错误, 而且 如果 你 不知道 迭代 器 块 的 原理, 也 很难 理解 为什么 会 这样。下面的内容中 将 解决 这个 问题。 有两 件事 情 我们 之前 尚未 接触 到—— 终止 迭代 过程 的 其他 方式, 以及 finally 代码 块 如何 在这 种 有点 古怪 的 执行 形式 中 工作。 现在 来看 一下。

进一步 了解 迭代 器 执行 流程

在 常规 的 方法 中, return 语句 具有 两个 作用: 第一, 给 调用 者 提供 返回 值; 第二, 终止 方法 的 执行, 在 退出 时 执行 合适 的 finally 代码 块。 我们 看到 yield return 语句 临时 退出 了 方法, 直到 再次 调用 MoveNext 后又 继续 执行, 我们 根本 没有 检查 finally 代码 块 的 行为。 如何 才能 真正 地 停止 方法? 所有这些 finally 代码 块 发生了 什么? 我们 从 一个 非常 简单 的 构造( yield break 语句) 开始。
①使用 yield break 结束 迭代 器 的 执行 人们 总 希望 找到 某种 方式 来 让 方法 具有 单一 的 出口 点, 很多人 也 很 努力 地 实现 这个 目标(作者:我 个人 认为, 为 实现 这个 目标 你 所 使用 的 方法 可能 会使 代码 很难 阅读, 不如 设置 多个 出口 点, 尤其 在 需要 估计 任何 可能发生 的 异常 并使 用 try/ finally 进行 资源 清理 时。 不过, 关键 是 做到 这一点 不难)。 同样 的 技术 也 适用于 迭代 器 块。 不过, 如果 你 希望“ 提早 退出”, 那么 yield break 语句 正是 你 所需 要的。 它 实际上 终止 了 迭代 器 的 运行, 让 当前 对 MoveNext 的 调用 返回 false。 代码 清单8演示 了 在 计数 到 100 次 的 过程中, 假如 运行 超过 时限 就 提前 停止 的 情况。 它 也 演示 了 在 迭代 器 块 中 使用方法 参数 (记住, 迭代 器 块 不能 实现 具有 ref 或 out 参数 的 方法。) 的 方式, 并 证实 这与 方法 的 名称 是 无关 的。

//【代码清单8】
static IEnumerable<int> CountWithTimeLimit(DateTime limit)
{
for (int i = 1; i <= 100; i++)
{
if (DateTime.Now >= limit)
{
yield break;//如果时间到了就停止
}
yield return i;
}
}
...
DateTime stop = DateTime.Now.AddSeconds(2);
foreach (int i in CountWithTimeLimit(stop))
{
Console.WriteLine("Received {0}", i);
Thread.Sleep(300);
}

正常 情况下, 运行 代码 清单 6- 6 时 将 看到 大约 7 行的 输出 结果。 如 我们 所 预计 的 那样, foreach 循环 能够 正常 地 结束, 迭代 器 遍历 完了 需要 遍历 的 元素。 yield break 语句 的 行为 非常 类似于 普通 方法 中的 return 语句。 到 目前 为止, 一切 都 还 相对 简单。 执行 流程 的 最后 一个 要 研究 的 地方 是: finally 代码 块 如何 执行 及 何时 执行。
②finally 代码 块 的 执行 在要 离开 相关 作用域 时, 我们 习惯 执行 finally 代码 块。 迭代 器 块 行为 方式 和 普通 方法 不太 一样, 尽管 我们 也 看到, yield return 语句 暂时 停止 了 方法, 但 并没有 退出 该 方法。 按照 这样 的 逻辑, 在这里 我们 不要 期望 任何 finally 代码 块 能够 正确 执行—— 实际 也 确实 如此。 不过, 在 遇到 yield break 语句 时, 适当 的 finally 代码 块 还是 能够 执行 的, 正 如在 从 普通 方法 中 返回 时 你 所 期望 的 那样 (在 未 执行 yield return 或 yield break 语句 之前 而 离开 相关 作用域 时, 它们 也会 执行。 在这里, 我 只 关注 这 两种 yield 语句 的 行为, 因为 它们 的 执行 流程 是不同 的。)finally 在 迭代 器 块 中常 用于 释放 资源, 通 常与 using 语句 配合 使用。后面还会 介绍 一个 真实 的 例子, 但 现在 我们 先来 看看 finally 块 何时 以及 如何 执行。 代码 清单 9展示 了 一个 示例—— 它在 代码 清单8的 基础上 添加 了 finally 代码 块。 改变 的 地方 使用 粗体 显示。

//【代码清单9】
static IEnumerable<int> CountWithTimeLimit(DateTime limit)
{
try
{
for (int i = 1; i <= 100; i++)
{
if (DateTime.Now >= limit)
{
yield break;
}
yield return i;
}
}
finally
{
Console.WriteLine("Stopping!");//不管循环是否结束都执行
}
}
...
DateTime stop = DateTime.Now.AddSeconds(2);
foreach (int i in CountWithTimeLimit(stop))
{
Console.WriteLine("Received {0}", i);
Thread.Sleep(300);
}

代码 清单 9 中, 如果 迭代 器 块 计数 到了 100 或者 由于 时限 而 停止, finally 代码 块 都会 执行。( 如果 代码 抛出 一个 异常, 它 也会 执行。) 不过, 在 其他 情况下 我们 可能 想 避免 调用 finally 块 中的 代码, 我们 来 走 个 捷径。 我 看到 当 调用 MoveNext 时 只有 迭代 器 块 中的 这段 代码 被 执行。 那么 如果 从不 调用 MoveNext 会 发生 什么 呢? 或者 如果 我们 只 调用 几次, 而后 就 停止 呢? 看看 把 代码 清单9 的“ 调用” 部分 改为 下面 这样 会 怎么样:

//【代码清单10】
DateTime stop = DateTime.Now.AddSeconds(2);
foreach (int i in CountWithTimeLimit(stop))
{
Console.WriteLine ("Received {0}", i);
if (i > 3)
{
Console.WriteLine("Returning");
return;
}
Thread.Sleep(300);
}

在这里, 我们 不是 提前 停止 执行 迭代 器 代码, 而是 提前 停止 使用 迭代 器。 输出 结果 也许 令人 感到 意外:
Received 1
Received 2
Received 3
Received 4
Returning
Stopping!
此处, 在 foreach 循环 中的 return 语句 执行 后, 迭代 器 的 finally 代码 也 被 执行 了。 这 通常 是 不应 发生 的, 除非 finally 代码 块 被 调用 了—— 在 这种 情况下, 被 调用 了 两次! 虽然 我们 知道 在 迭代 器 方法 中 存在 着 finally 代码 块, 但 问题是 什么 原因 引起 它 执 行的 呢。 我 之前 也 提 到过—— foreach 会在 它自己 的 finally 代码 块 中 调用 IEnumerator 所 提供 的 Dispose 方法( 就 像 using 语句)。 当 迭代 器 完成 迭代 之前, 你 如果 调用 由 迭代 器 代码 块 创建 的 迭代 器 上 的 Dispose, 那么 状态 机 就会 执行 在 代码 当前“ 暂停” 位置 范围内 的 任何 finally 代码 块。 这个 解释 复杂 且有 点 详细, 但 结果 却 很容易 描述: 只要 调用 者 使用 了 foreach 循环, 迭代 器 块 中的 finally 将 按照 你 期望 的 方式 工作。 只需 通过 手动 使用 迭代 器, 我们 就能 非常 容易 地 证明 对 Dispose 的 调用 会 触发 finally 代码 块 的 执行:

DateTime stop = DateTime.Now.AddSeconds(2);
IEnumerable<int> iterable = CountWithTimeLimit(stop);
IEnumerator<int> iterator = iterable.GetEnumerator();
iterator.MoveNext();
Console.WriteLine("Received {0}", iterator.Current);
iterator.MoveNext();
Console.WriteLine("Received {0}", iterator.Current);

这次,“ stopping” 行不 会 打印 出来。 而 如果 增加 一个 对 Dispose 的 显 式 调用, 就会 在 输出 中看 到这 一行。 在 迭代 器 完成 之前 终止 它的 执行 是 相当 少见 的, 并且 不用 foreach 语句 而 手动 使用 迭代 器 也是 不多见 的, 不过 如果 你 这样做, 记得 把 迭代 器 包含 在 using 语句 中 使用。 我们 现在 已经 研究 了 迭代 器 块 的 大部 分行 为了, 不过 在 结束 本 小节 之前, 还是 值得 研究 一下 在 目前 微软 实现 中的 一些 奇特 之处。

具体 实现 中的 奇特 之处

如果 你 使用 微软 C# 2 编译器 编译 迭代 器 块, 并使 用 ildasm 或 Reflector 来 查看 生成 的 IL, 你将 看到 编译器 在 幕后 为我 们 生成 的 嵌套 类型。 对于 我的 例子, 当 编译 代码 清单 6- 4 的 时候, 它 调用 了 IterationSample.< GetEnumerator> d__ 0( 顺便 说 下, 在这里 的 尖 括号 不是 代表 泛 型 类型 参数。) 我们 无法 在这里 详细 地 讲解 所 生成 的 代码, 但 应该 在 Reflector 中看 看 具体 发生 的 情况, 建议 参考 语言 规范, 在 语言 规范 的 10. 14 节 中 定义 了 类型 可 具有 的 不同 状态, 并且 其中 的 描述 也有 助于 理解 生成 的 代码。 MoveNext 通常 包含 一个 很大 的 switch 语句, 将 执行 大部分 工作。 幸好, 作为 开发 人员 我们 不需要 太 关心 编译器 是 如何 解决 这些 问题 的。不过, 关于 实现 中的 以下 一些 奇特 之处 还是 值得 了解 的: 在 第一次 调用 MoveNext 之前, Current 属性 总是 返回 迭代 器 产生 类型 的 默认值; 在 MoveNext 返回 false 之后, Current 属性 总是 返回 最后 的 生成 值; Reset 总是 抛出 异常, 而 不像 我们 手动 实现 的 重置 过程 那样, 为了 遵循 语言 规范, 这是 必要 的 行为; 嵌套 类 总是 实现 IEnumerator 的 泛 型 形式 和 非 泛 型 形式( 提 供给 泛 型 和 非 泛 型 的 IEnumerable 所用)。 不 实现 Reset 是 完全 合理 的—— 编译器 无法 合理 地 解决 在 重置 迭代 器 时 需要 完成 的 一些 事情, 甚至 不能 判断 解决 方法 是否 可行。 可以 认为, Reset 在 IEnumerator 接 口中 一 开始 就不 存在, 而且 我也 完全 想不起 我 最后 一次 调用 它是 什么时候 了。 很多 集合 都不 支持 Reset, 通常, 调用 者 不能 依赖 它。 实现 额外 的 接口 也不 会对 其 产生 影响。 有意思 的 是, 如果 你的 方法 返回 IEnumerable, 那么 你最 终 得到 的 是一 个 实现 了 5 个 接口( 包括 IDisposable) 的 类。 语言 规范 解释 的 很 清楚, 不过 作为 开发 人员 你 不需要 关心 这些。 事实上 很少 有 某个 类 会同 时 实现 IEnumerable 和 IEnumerator—— 编译器 做了 大量 的 工作, 来 确保 不论 你 如何 处理 都 正确, 通常 还会 在 迭代 某个 集合 时, 在 创建 集合 的 同一 线程 上 创建 一个 内嵌 类型 的 实例。 Current 的 行为 有点 奇怪—— 尤其 在 完成 迭代 后 依然 保持 着 最 后的 值, 会 阻止 其 被 垃圾 回收。 这个 问题 也许 在 未来 的 C# 编译器 中会 被 修正, 不过 这不 太 可能, 因为 它 会破 坏 已有 的 代码( 和. NET 3. 5 及. NET 4 一起 发布 的 微软 C# 编译器 还是 如此)。 严格 来说, 从 C# 2 语言 规范 的 角度 来看, 这样做 也是 对的—— Current 属性 的 行为 未 明确 定义。 如果 它 按照 框架 文档 的 建议 来 实现 这个 属性, 在 适当 的 时候 抛出 异常 可能 更好 些。因此, 使用 自动 生成 的 代码 还是 存在 一些 缺点, 不过 对于 明智 的 调用 者, 不会有 太大 问题, 让我 们 正确对待 它, 毕竟 我们 节省 了 大量 需要 手动 实现 的 代码。 换句话说, 迭代 器 应该 比 在 C# 1 中 使用 得 更为 广泛。 下一 节 提供 了 一些 示例 代码, 以便 检查 你对 迭代 器 块 的 理解, 并 了解 它们 在 实际 的 开发 中 多么 有用( 而 不是 仅仅 停留在 理论上)。

真实 的 迭代 器 示例

你是 否 曾经 写 过 一些 其 本身 非常 简单 却能 让你 的 项目 更加 整齐 而有 条理 的 代码? 这种 事 对于 我来 说是 经常 发生 的, 这 让我 常常 得意忘形, 以至于 同事 们 都用 很 奇怪 的 眼神 看 我。 这种 稍 显 幼稚 的 快乐, 在使 用 一些 新语 言 特性 的 时候 更加 强烈, 这不 仅仅 说明 我 获得 了“ 玩 新 玩具” 的 快感, 而是 这些 新 特性 显然 很好。 即使 现在 我 已经 使用 了 多年 的 迭代 器, 仍然 会 遇到 用 迭代 器 块 呈现 解决 方案 的 情况, 并且 结果 代码 简短、 整洁 和 易于 理解。 我将 与你 分享 三个 这样 的 例子。

①迭代 时刻表 中的 日期

for (DateTime day = timetable.StartDate;
day <= timetable.EndDate;
day = day.AddDays(1))

我处 理过 太多 这样 的 代码 了, 一直 讨厌 这样 的 循环, 不 过当 我 以 伪 代码 的 方式 向 其他 开发 人员 大声 念 出 这些 代码 的 时候, 我才 意识到 我 缺乏 一定 的 交流 技巧。 我 会说“ 对于 时刻表 中的 每一 天”。 回想 起来 其实 很 明显, 我 实际上 是 需要 一个 foreach 循环。( 这些 可能 从 一 开始 对 你来 说 就是 显而易见 的, 如果 你 恰好 属于 这种 情况, 那么 请原谅 我说 了 这么 多。 幸好, 我看 不到 你 此时 的 表情。) 当 重写 为 下述 代码 的 时候, 这个 循环就 显得 好多 了:

foreach (DateTime day in timetable. DateRange)

在 C# 1 中, 我也 许 只 会把 它 当做 一个 美梦, 而 不会 自寻烦恼 去 实现 它: 我们 之前 看到 要 手动 实现 一个 迭代 器 有多 麻烦, 而 最后 的 结果 只是 使 几个 for 循环 变得 整洁 了 一些。 然而, 在 C# 2 中, 它 就 非常 简单 了。 在 一个 表示 时刻表 的 类 中, 我 仅仅 添加 了 一个 属性:

public IEnumerable<DateTime> DateRange
{
get
{
for (DateTime day = StartDate;
day <= EndDate;
day = day.AddDays(1))
{
yield return day;
}
}
}

原始 的 循环 代码 被 移动 到了 时刻表 类 中, 这样 很好—— 这些 代码 被 封装 到 一个 对“ 天” 进行 循环 的 属性 中, 并 一次 生成 1 个, 这样做 比 在业 务 代码 中 处理 这些“ 天” 要好 很多。 如果 我想 做得 更 复杂 些( 例如, 跳过 周末 和 公 休假), 还可以 做到 在 一个 地方 封装 代码, 在任 何地 方 使用 它。 这个 小小 的 更改, 在 很大 程度 上 改善 了 代码 库 的 可读性。 因此, 这时 我 停止 了 对 商业 代码 的 重 构。 我 确实 也 考虑过 引入 一个 Range< T> 类型, 来 表示 一个 通用 的 范围, 但 由于 只在 这 一种 情况下 需要 它, 所以 为此 而 付出 更多 的 努力 似乎 没有 什么 意义。 事实证明, 这是 一个 明智 之举。 在 本书 第 1 版 中, 我 创建 了 这样 一个 类型, 但它 有 一些 很难 在 书面 上 描述
的 缺点。 我 在 我的 工具 库 中 重新 设计 了 这个 类型, 但 仍然 还有 一些 忧虑。 这种 类型 通常 听 上去 要比 实际情况 简单, 然而 不久 你就 需要 频繁 地处 理 一些 个别 情况。 我所 遇到 的 这些 困难 的 细节 超出 了 本书 的 范围, 它们 大多 是对 于 通用 的 设计 而言 的, 不是 C# 本身, 但 它们 却 十分 有趣, 因此 我 在 本书 网 站上 撰写 了 一篇 文章 来 描述 这些 情况( 参见 http:// mng. bz/ GAmS)。 下面 这个 例子 也是 我 喜欢 的, 它 能 充分 说明 我 衷情 迭代 器 块 的 原因。

②迭代文件中的行
想 一想 你 曾 多少 次 逐行 阅读 文本 文件 吧, 这 实在 是 一个 再 平常 不过 的 任务 了。 而在. NET 4 中, 框架 最终 提供 了 一种 方法, 使得 这项 任务 通过 reader. ReadLines 来 实现 要 简单 得 多。 但 如果 你 曾使 用过 该 框架 的 早期 版本, 你也 可以 轻松 创建 自己的 代码, 如同 我将 在 接下 来的 内容 中 介绍 的 那样。 我都 不知道 自己 曾经 写 过 多少 次 这样 的 代码:

using (TextReader reader = File.OpenText(filename))
{
string line;
while ((line = reader.ReadLine()) != null)
{
// Do something with line
}
}

这里 共有 四个 不同 的 概念:
如何 获取 TextReader;
管理 TextReader 的 生命 周期;
迭代 TextReader. ReadLine 返回 的 行;
对这 些 行进 行 处理。
只有 第 一条 和 最后 一条 是 因 势 而 变的—— 生命 周期 管理 和 迭代 机制 都是 样板 代码。( 至少 C# 的 生命 周期 管理 非常 简单, 感谢 using 语句!) 我们有 两种 方法 可以 进行 改进。 我们 可以 使用 委托—— 编写 一个 工具 方法, 将 阅读 器 和 委托 作为 参数, 为 文件 中的 每一 行 调用 该 委托, 最后 关闭 阅读 器。 这 经常 作为 闭 包 和 委托 的 示例, 不过 我还 发现 了 一个 更加 优雅 的 适合于 LINQ 的 方法。 我们 不 将 逻辑 作为 委托 传入 方法, 而是 使用 迭代 器 一次 返回 文件 中的 一行, 因此 可以 使用 普通 的 foreach 循环。 你 可以 编写 一个 实现 了 IEnumerable< string> 的 完整 类型 来 达到 这一点( 我的 MiscUtil 库 中的 LineReader 类 就是 这种 用途), 不过 其他 类 中的 一个 单独 的 方法 也是 可以 的。 它 非常 简单, 如 代码 清单 11所示。

static IEnumerable<string> ReadLines(string filename)
{
using (TextReader reader = File.OpenText(filename))
{
string line;
while ((line = reader.ReadLine()) != null)
{
yield return line;
}
}
}
...
foreach (string line in ReadLines("test.txt"))
{
Console.WriteLine(line);
}

在 对 集合 进行 迭代 时, 我们将 行 产生 给 调用 者, 除此之外, 方法 体 与之 前 几乎 完全 相同。 与之 前 一样, 我们 打开 文件, 一次 读取 一行, 然后 在 结束 时 关闭 阅读 器。 尽管 这里“ 结束 时” 这个 概念 要比 在 普通 方法 中 使用 using 语句 有趣 得 多, 后者 的 流 控制 更为 明显。 因此 在 foreach 循环 中 释放 迭代 器 非常 重要, 它可 以 确保 阅读 器 被 清理 干净。 迭代 器 方法 中的 using 语句 扮演 了 try/ finally 块 的 角色。 在到 达 文件 末尾 或在 中途 调用 IEnumerater< string> 的 Dispose 方法 时, 将 进入 finally 块。 调用 代码 很可能 滥用 ReadLines (…). GetEnumerator() 返回 的 IEnumerator< string>, 导致 资源 泄露, 但这 通常 是 IDisposable 的 情况—— 如果 你 没有 调用 Dispose, 则 可能 导致 泄露。 不过 这 很少 发生, 因为 foreach 进行 了 正确 的 处理。 要 注意 这种 潜在 的 滥用, 如果 你 依赖 迭代 器 中的 try/ finally 块 来 授予 某些 权限, 然后 又 将其 移 除, 那么 这就 是一 个 安全 漏洞。 这个 方法 封 装了 我 之前 列出 的 四个 概念 中的 前 三个, 但却 有 一些 限制。 将 生命 周期 管理 和 迭代 部分 结合 起来 是 可以 的, 但如 果 我们 想从 网络 流 中 读取 文本 或使 用 UTF- 8 以外 的 编码 格式 呢? 我们 需要 将 第一 部分 交还 给 调 用者 来 控制。 最 显而易见 的 方式 是 修改 方法 签名, 使其 接受 一个 TextReader, 如下 所示:

static IEnumerable< string> ReadLines( TextReader reader)

但这 是一 个 糟糕 的 方案。 我们 希望 获取 阅读 器 的 所有权, 这样 可以 方便 地 为 调用 者 进行 清理。 但 这样一来 就 意味着, 只要 用户 使 用了 该 方法, 我们 就不 得不 进行 清理。 问题是, 如果 在 第一次 调用 MoveNext() 之前 发生了 异常, 我们 就 没有 机会 进行 清理 了, 所有 的 代码 都不 会 运行。 IEnumerable< string> 本身 不是 可 释放 的, 但它 已经 将 这一 部分 的 状态保存 为“ 需要 释放”。 如果 GetEnumerator() 被 调用 两次, 还会 产生 另一个 问题: 本应 生成 两个 独立 的 迭代 器, 但 它们 却 使用 相同 的 阅读 器。 我们 通过 将 返回 类型 改为 IEnumerator< string> 可以 在 一定程度 上 缓解 这个 问题, 但 这样 其 结果 就 无法 用于 foreach 循环 了, 并且 如果 我们 没有 到达 第一次 调用 MoveNext() 就 出现 了 错误, 则 仍然 无法 运行 任何 清理 代码。 幸运 的 是, 有一个 办法 可以 解决 这个 问题。 因为 我们 的 代码 不是 立即 执行 的, 因此 并不 立即 就 需要 阅读 器。 我们 需要 的 是在 需要 阅读 器 的 时候 获取 它的 方式。 我们 可以 使用 接口 来 表示“ 可以 在 需要 的 时候 提供 一个 TextReader”, 不过 含有 单一 方法 的 接口 总是 会 让你 想到 委托。 我们 这里 要 小小 地做 一下 弊, 使用. NET 3. 5 中的 一个 委托。 它 含有 不同 参数 类型 数量 的 重载, 但我 们 只需 要 一个:

public delegate TResult Func< TResult>()

如你 所见, 该 委托 没有 参数, 返回 类型 与 类型 参数 的 类型 相同。 这是 典型的 提供 器 和 工厂 方法 的 签名。 在 本例 中, 我们 想要 获取 一个 TextReader, 因此 使用 Func< TextReader>。 对 方法 的 更改 非常 简单:

static IEnumerable<string> ReadLines(Func<TextReader> provider)
{
using (TextReader reader = provider())
{
string line;
while ((line = reader.ReadLine()) != null)
{
yield return line;
}
}
}

现在, 我们 只有 在 需要 的 时候 才 去 获取 资源, 并且 那时 我们 处于 IDisposable 的 上下 文中, 可以 在 适当 的 时候 释放 资源。 此外, 如果 对其 返回 值 多次 调用 GetEnumerator(), 每次 都将 创建 独立 的 TextReader。 我们 可以 简单 地 使用 匿名 方法 来 添加 打开 文件 的 重载, 也可以 指定 文件 的 编码:

static IEnumerable<string> ReadLines(string filename)
{
return ReadLines(filename, Encoding.UTF8);
}
static IEnumerable<string> ReadLines(string filename, Encoding encoding)
{
return ReadLines(delegate {
return File.OpenText(filename, encoding);
});
}

这个 简单 的 示例 使 用了 泛 型、 匿名 方法( 捕获 了 所在 方法 的 参数) 和 迭代 器 块。“ 三 缺一” 的 是 可 空 类型, 否则 就 汇集 了 C# 2 主要 特性 的“ 大四 喜”。 我曾 多次 使用 过 这些 代码, 它 比我 们 开始时 介绍 的 笨重 代码 要 整洁 得 多。 如同 前文 提到 的 那样, 如果 使 用的 是. NET 的 较 新版本, 你就 可以 通过 File. ReadLines 来做 到。 但这 仍然 可作 为 一个 例子, 说明 迭代 器 块 可以 多么 有用。

[英]Jon Skeet. 深入理解C#(第3版) (图灵程序设计丛书) (Kindle 位置 4848-4852). 人民邮电出版社. Kindle 版本.

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值