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 版本.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章