迭代器,迭代器塊和資料管道

姚琪琳發表於2012-04-06

本文是《深入理解C#(第2版)》網站上作者的文章

原文地址:http://www.csharpindepth.com/Articles/Chapter11/StreamingAndIterators.aspx

簡介

LINQ to Objects是基於迭代器的。後者一直是.NET的一部分,並且是foreach語句的基礎,但對於LINQ to Objects來說,它們要比以往更加重要。本文將探討什麼是迭代器、如何在C# 2(或3)中實現迭代器,以及LINQ to Objects的基礎內容。本文不是LINQ to Objects的完整教程,只提供了核心構建塊的背景知識(當然還包括LINQ本身的一小部分內容)。

什麼是迭代器?

迭代器模式是很常用的設計模式。在.NET中,它被封裝在IEnumerableIEnumerator介面以及它們的泛型版本中。(大多數情況下,我將使用非泛型形式來進行闡述,用泛型形式來演示程式碼。顯然,泛型形式是強型別的,而非泛型形式不是,但除此之外,它們之間唯一的區別是,泛型形式實現了IDisposable。)

.NET迭代器模式的基本理念是,作為資料消費者,你呼叫IEnumerableGetEnumerator()來得到IEnumerator,然後迭代IEnumerator的內容(通過MoveNext()方法和Current屬性),直到不再需要任何資料,或迭代器已經沒有能返回的資訊。例如,對IEnumerable<string>進行的簡單foreach迴圈與下面的程式碼類似:

IEnumerator<string> iterator = dataSource.GetEnumerator();
while (iterator.MoveNext())
{
    string data = iterator.Current;
    // Use data within the body of the loop 
}

簡便起見,上面的程式碼沒有呼叫迭代器的Dispose方法。為foreach迴圈生成的程式碼使用了一個try/finally構造,來確保迭代器能夠在用完之後進行處置(dispose)。這並不是模式本身所要求的,但當通過神奇的yield return語句來實現迭代器時(稍後會看到),卻是十分重要的。

為什麼是兩個介面?

你可能會問,為什麼IEnumerable自己沒有MoveNext()Current成員呢?為什麼要用另一個介面?好吧,想象一下有兩個不同的迴圈,它們迭代相同的列表。你不希望兩個迴圈互相干擾,因此需要兩個獨立的概念來表達“現在我訪問了幾個列表元素”。IEnumerator正是為此而生。它維護了獨立的資訊(如列表的當前索引)和集合本身的引用。儘管迭代器通常用於記憶體中的集合,但後面我們會看到,它並不侷限於此,還能用於“請求資料、處理資料、請求資料”這樣的迴圈。

迭代器塊(C# 2)

在C# 1中實現迭代器塊是很痛苦的。你需要建立一個實現了IEnumerable的集合型別,然後還要建立一個單獨的型別(即實現了IEnumerator的型別)來包含對該集合例項的引用,通常還要新增一些狀態資訊,來指明遍歷到了什麼地方。這太容易出錯了,一般開發者不到萬不得已是不會自己實現的。

而在C# 2中,當你使用迭代器塊來實現IEnumerableIEnumerator(或它們的泛型版本)時,編譯器將為我們處理所有複雜的工作。它為我們構建了一個狀態機,你可以很容易地獲取迭代器,執行迭代器塊中的程式碼,併產生(yield)值。演示易於解釋,我們來看一段完整的程式:

using System;
using System.Collections.Generic;

class IteratorBlockDemo
{
    static IEnumerable<string> GetDemoEnumerable()
    {
        yield return "start";

        for (int i=0; i < 5; i++)
        {
            yield return i.ToString();
        }

        yield return "end";
    }

    static void Main()
    {
        foreach (string x in GetDemoEnumerable())
        {
            Console.WriteLine(x);
        }
    }
}

程式的輸出結果非常簡單:

start
0
1
2
3
4
end

我不會在此深入介紹迭代器塊工作的細節(更多資訊請參考《深入理解C#(第二版)》或語言規範)。不過下面的內容還是十分重要的:

  1. Main呼叫了GetDemoEnumerable()
  2. GetDemoEnumerable()新建了編譯器生成的額外型別的一個例項。目前為止,還沒有執行我們編寫的任何程式碼。
  3. Main呼叫了MoveNext()
  4. 執行迭代器塊中的程式碼,直到遇到一條yield語句。在本例中,會立即發生。迭代器記住當前項為“start”,然後返回true,表示有資料。
  5. Main使用Current屬性來獲取資料,然後列印。
  6. Main再次呼叫MoveNext()
  7. 迭代器從上一次的位置繼續執行(即從第一個yield return後面開始)。和之前一樣,執行程式碼(初始化i變數),知道遇到下一個yield語句。
  8. ……重複這種模式,直到某次呼叫MoveNext()時到達了方法的末尾,這時該呼叫將返回false,表明已經沒有資料了。

有三點是非常重要的:首先,在第一次呼叫MoveNext()之前,不會執行任何原始程式碼。其次,後續呼叫MoveNext()時,程式碼會跳轉到上次離開時的位置。產生(yield)值就像是“暫停”了方法。最後,編譯器會保留方法的狀態(即區域性變數),儘管i是迭代器的區域性變數,它的值仍然可以儲存在迭代器中,到下次呼叫MoveNext()的時候,仍然可以使用它。

這些特性使迭代器塊完全不同於其他方法。你有可能一直搞不明白這是怎麼回事,也有可能突然靈光乍現。你可以對迭代器塊進行除錯,來檢視其工作流程。現在,我們瞭解了迭代器以及它們如何在C# 2中得以輕鬆地實現,下面看看如何將它們串聯(chain)起來。

資料管道和可組合性

LINQ to Objects構建於資料管道(data pipeline)這個概念之上。我們從一個實現了IEnumerable的資料來源開始,串聯起一系列操作,每個操作都基於前一個的結果,並返回另一個IEnumerable(可能為不同的結果型別)。這種將一些小操作串聯成一個大操作的行為稱為組合(composition)。組合的重點在於真正簡化每一個小操作。LINQ to Objects使用委託對輸入元素進行操作,帶來了很大的靈活性。例如過濾器中的謂詞(predicate),指明某個特殊的值是否能通過過濾器的篩選;又如投影(projection),可以將一個值對映為另一個值。

假設我們有一個隨機排列的數字序列,希望得到一個字串序列。我們過濾掉所有的負數,對剩下的進行排序,然後呼叫ToString將數字轉換為十六進位制。在C# 3和.NET 3.5中,可以使用如下的查詢表示式:

from number in numbers
where number >= 0
orderby number
select number.ToString("x")

在編譯器開始處理之前,解析器將其翻譯為非查詢表示式的形式:

numbers.Where (number => number >= 0)
    .OrderBy (number => number)
    .Select (number => number.ToString("x"))

=>語法用於Lambda表示式——一種建立委託(和表示式樹)的簡單方式。WhereOrderBySelect方法都是LINQ to Objects提供的擴充套件方法,分別用來過濾、排序和投影。正如你所知,這其中蘊含了很多特性,但本文不會深入這些細節——我們只將注意力放在迭代器的行為上。

延遲執行vs立即執行

當我們使用迭代器塊建立自己的迭代器時,上面的表示式並沒有執行什麼有意義的程式碼——它建立了管道,但沒有真正地請求資料。只有在第一次請求Select返回值中的資料時,才會真正地進行過濾、排序、投影等操作。這成為延遲執行(deferred execution)。很多LINQ to Objects查詢操作符都使用了延遲執行,特別是那些用於構建管道的操作符。而其他查詢操作符如Count()Max()ToList()等則使用了立即執行(immediate execution),這意味著它們將立即進行工作,並返回適當的資料。

弄清楚這些是十分重要的,我們能夠知道操作具體在什麼時候發生。比如,你指定了一個可能會丟擲異常的投影,你需要知道只有在請求資料的時候才會發生異常,呼叫Select不丟擲異常,不代表一切正常。

流vs緩衝

還有一個概念與與延遲/立即執行大同小異,即操作符對資料進行流式傳輸(stream)和緩衝傳輸(buffer)。使用流式傳輸的操作符每次只(從管道的前一部分)獲取它所需要的資料來給出下一條結果。Select()就是最簡單的流式傳輸操作符,它獲取下一項,執行指定的委託,投影到結果上,然後產生(yield)結果。Where()要稍微複雜一些,它會一直請求資料,直到沒有資料或找到了一個可以通過過濾器的項。每當這時,它就會產生該值,而不再請求任何資料。

緩衝傳輸操作符則不同。它們請求資料時,會緩衝管道較早部分的所有資料,然後計算出所有結果。Reverse()是最簡單的緩衝傳輸操作符。為了返回反序的資料,在產生(yield)任何資料之前,你必須先到達最後一個元素。LINQ to Objects中的排序和分組也使用了緩衝傳輸。

生成器

LINQ to Objects還包含了一些生成器操作符(generator operator)。它們會建立一個資料序列,但不基於任何其他序列。DefaultIfEmpty也被認為是生成器,但實際上不是。它基於一個已知的序列,儘管該序列可能為空(empty)。本文不會對此進行討論。

標準的生成器有三個:EmptyRepeatRange。我們先來看看它們可能的實現,然後使用組合對實現進行少許修改。當然,實際上這些已經都為我們實現好了,但對於理解迭代器塊和組合來說,重新實現一次將是非常不錯的實踐。

Empty

正如我們所預料的,Empty返回一個空的序列,也就是說第一次呼叫MoveNext()時會返回false。說來奇怪,它並沒有我們想象得那麼簡單。既然不需要返回任何值,那麼不在方法體內新增任何程式碼不就行了嗎?其實不然,我們需要這樣的程式碼:

public static IEnumerable<TResult> Empty<TResult>()
{
    yield break;
}

yield break語句在迭代器塊中意味著“提前退休”——它就像是方法的結尾,讓MoveNext()返回false。我們為什麼需要它呢?因為在一個空方法體內我們無法第一時間告訴編譯器我們要建立一個迭代器塊。編譯器只有在方法體內遇到yield returnyield break時,才會將該方法視為是迭代器塊的實現。這是我能想到的唯一一處,yield break直接出現在方法末尾之前,並且產生實際效果的地方。

Repeat

Repeat包含兩個引數:要返回的元素和返回的次數。它將按你指定的次數產生(yield)相同的元素,然後停止。加上驗證數量不能為負的程式碼,該方法的總程式碼量也很少:

public static IEnumerable<TResult> Repeat<TResult>(TResult element, int count)
{
    if (count < 0)
    {
        throw new ArgumentOutOfRangeException("count");
    }
    for (int i = 0; i < count; i++)
    {
        yield return element;
    }
}

Range

Range也十分簡單。與前兩個方法不同的是,它不是泛型的:它總是返回IEnumerable<int>。你需要指定兩個引數:起始點和返回值的數量。返回的序列從給定的值開始,然後計數。因此Range(10, 3)將產生10、11、12。以下是忽略了錯誤驗證的實現:

public static IEnumerable<int> Range(int start, int count)
{
    for (int i = start; i < start + count; i++)
    {
        yield return i;
    }
}

通用生成器

以上三種生成器都有點特殊。它們不需要委託,這與LINQ to Objects中的大多數API都不同。現在我們引入一個新的生成器。它是如此通用,配得上它的名字——Generate。它將生成一個無窮序列,它以一個起始點作為引數,產生(yield)該起始點,再對當前值執行一個委託,然後產生這個當前值,如此迴圈往復。以下是程式碼(不含錯誤檢查——通常應該判斷step是否為null):

public static IEnumerable<TSource> Generate<TSource>(TSource start, Func<TSource, TSource> step)
{
    TSource current = start;
    while (true)
    {
        yield return current;
        current = step(current);
    }
}

程式碼跟我們希望的一樣簡單。但實際上我們不會直接迭代Generate的結果,因為它是個死迴圈。確實,如果不使用組合,Generate幾乎沒有任何用處。但是,每次只需要用一個操作符,我們就能輕鬆地實現以上三種生成器。在實際使用Generate之前,我們先來看看另外一個操作符。

Take

Take是一個“管道”操作符。與SelectWhere一樣,它接受一個序列,並返回一個序列。除了“source”序列外,你還需要指定要返回source中的多少個元素。Take簡單地產生(yield)source中的元素,直到沒有資料,或已經返回了所要求的元素數量。因此,其實現(不含錯誤檢查)可能為:

public static IEnumerable<TSource> Take<TSource>(this IEnumerable<TSource> source, int count)
{
    foreach (TSource element in source)
    {
        if (count == 0)
        {
            yield break;
        }
        count--;
        yield return element;
    }
}

注意,我們使用了yield break來指明當計數器為0時完成迭代。(這裡我們也可以簡單地使用break,因為迴圈之後已經沒有其他程式碼了。但我認為還是有必要演示一個比Empty實現中的yield break更加普通的場景。)source引數之前的this表明Take是一個擴充套件方法。

現在我們有了TakeGenerator,可以使用組合來重新實現EmptyRepeatRange了:

public static IEnumerable<TResult> Repeat<TResult>(TResult element, int count)
{
    return Generate(element, x => x).Take(count);
}

public static IEnumerable<TResult> Empty<TResult>()
{
    return Repeat(default(TResult), 0);
}

public static IEnumerable<int> Range(int start, int count)
{
    return Generate(start, x => x + 1).Take(count);
}

實現Repeat時,我們先是用指定的元素建立一個無窮序列,在指定步長(step)時返回該元素本身。然後用Take進行組合,在到達指定長度時終止這個無窮序列。Empty序列只需要使用TResult的預設值,重複0次。最後,Range建立了另一個無窮序列,從指定的值開始,每次加1,然後用Take來返回所需的元素數量。

實際上,以這種方式來實現這三個迭代器並不是特別有用,但這卻能讓你從一個有趣的視角來了解組合。Generate總是建立一個無窮序列,這會讓你理解資料總是在請求的時候才會生成。否則,如果Generate先建立所有值,然後再一次性返回,很快就會耗盡記憶體。我有一篇博文介紹了一個稍微複雜點的示例,幷包含完整的程式碼。那是一個用來生成曼德博羅特集圖形的查詢表示式:

var query = from row in Enumerable.Range(0, ImageHeight)
            from col in Enumerable.Range(0, ImageWidth)
            // Work out the initial complex value from the row and column
            let c = new Complex((col * SampleWidth) / ImageWidth + OffsetX,
                                (row * SampleHeight) / ImageHeight + OffsetY)
            // Work out the number of iterations
            select Generate(c, x => x * x + c).TakeWhile(x => x.SquareLength < 4)
                                                .Take(MaxIterations)
                                                .Count() into count
            // Map that to an appropriate byte value
            select (byte)(count == MaxIterations ? 0 : (count % 255) + 1);

這裡的Generate位於巢狀查詢中,用於查詢某個特定點的顏色。博文中可以下載完整的程式碼,我希望你能從中明白組合這個技術是多麼強大。

結論

LINQ to Objects中到處都是序列,以及充滿無限可能的簡單操作符。在《深入理解C#》中,我建議你在面對一個複雜的查詢表示式時,先看看它是由哪些序列組成的。理解了迭代器如何工作,以及流式/緩衝傳輸和延遲/立即執行,可以為有效地使用LINQ打下一個良好的基礎。組合查詢是非常有趣的,而且C# 2中出現的迭代器塊也使得迭代器更加易用。本文舉了一些例子,你肯定也能寫出自己的示例。前進吧,少年,去建立自己的操作符。重要的就是,實踐!

對於我們中的許多人來說,LINQ不僅是一項新技術,更是導致程式碼出錯的原因。要真正掌握新鮮的理念,總是會花很長的時間,所以我由衷地建議你多玩玩LINQ。儘管別的LINQ provider可能比LINQ to Objects更“性感”,但當你需要在記憶體中定義集合並操作核心內容時,它卻更純粹、更簡單、更直接。

同樣,對於很多開發者來說,迭代器塊也十分神祕。除非你曾建立過序列,否則可能從未編寫過迭代器。本文向你展示了它是多麼簡單,並且在你希望產生不同行為的地方給出了一些警告。迭代器塊在C# 2中已然十分有用,與LINQ相結合時則更加絢麗,而所有這一切都體現在短小精悍的程式碼中。同樣,實踐吧。路漫漫,其修遠,我們在乎的不是目的地,而是沿途的風景以及看風景的心情。讓心靈去旅行,這才是旅行的意義。

相關文章