迭代器

您撰寫的幾乎所有程式或多或少都需要逐一查看集合。 您將會撰寫程式碼,以查看集合中的每個項目。

您也會建立迭代器方法,也就是為該類別的元素產生「迭代器」的方法。 「迭代器」是周遊容器的物件,特別是清單。 迭代器可以用於:

  • 在集合中的每個項目上執行動作。
  • 列舉自訂集合。
  • 擴充 LINQ 或其他程式庫。
  • 建立資料管線,其中的資料會透過迭代器方法有效率地流動。

C# 語言提供可產生和取用序列的功能。 這些序列可以同步或非同步進行產生和取用。 本文將概述這些功能。

使用 foreach 逐一查看

列舉集合很簡單︰foreach 關鍵字會列舉集合,並對集合中的每個項目執行一次內嵌陳述式:

foreach (var item in collection)
{
    Console.WriteLine(item?.ToString());
}

就這樣。 若要逐一查看集合的所有內容,只需要 foreach 陳述式即可。 不過,foreach 陳述式不是魔法。 它需要在 .NET Core 程式庫中定義兩個泛型介面,才能產生逐一查看集合所需的程式碼︰IEnumerable<T>IEnumerator<T>。 以下會更詳細說明這個機制。

這兩種介面也有非泛型對應項目︰IEnumerableIEnumerator。 新式程式碼適合使用泛型版本。

非同步產生序列時,您可以使用 await foreach 陳述式以非同步取用序列:

await foreach (var item in asyncSequence)
{
Console.WriteLine(item?.ToString());
}

序列為 System.Collections.Generic.IEnumerable<T> 時,您可以使用 foreach。 序列為 System.Collections.Generic.IAsyncEnumerable<T> 時,您可以使用 await foreach。 在後者的情況下,會非同步產生序列。

具有迭代器方法的列舉來源

C# 語言另一個很棒的功能是可讓您建立方法,以建立列舉的來源。 這些方法稱為「迭代器方法」。 迭代器方法定義如何在要求時於序列中產生物件。 您可以使用 yield return 內容關鍵字來定義迭代器方法。

您可以撰寫這個方法,以產生從 0 到 9 的整數序列:

public IEnumerable<int> GetSingleDigitNumbers()
{
    yield return 0;
    yield return 1;
    yield return 2;
    yield return 3;
    yield return 4;
    yield return 5;
    yield return 6;
    yield return 7;
    yield return 8;
    yield return 9;
}

上述程式碼顯示不同的 yield return 陳述式,強調您可以在迭代器方法中使用多個不連續的 yield return 陳述式。 您可以 (經常) 使用其他語言建構,來簡化迭代器方法的程式碼。 下列方法定義會產生完全相同的數值序列:

public IEnumerable<int> GetSingleDigitNumbersLoop()
{
    int index = 0;
    while (index < 10)
        yield return index++;
}

您不必決定其中一個。 您可以視需要擁有許多 yield return 陳述式,以符合您的方法需求:

public IEnumerable<int> GetSetsOfNumbers()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    index = 100;
    while (index < 110)
        yield return index++;
}

上述所有範例都會有非同步對應項目。 在每個案例中,您會將 IEnumerable<T> 的傳回類型取代為 IAsyncEnumerable<T>。 例如,上一個範例會有下列非同步版本:

public async IAsyncEnumerable<int> GetSetsOfNumbersAsync()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    await Task.Delay(500);

    yield return 50;

    await Task.Delay(500);

    index = 100;
    while (index < 110)
        yield return index++;
}

這是同步和非同步迭代器的語法。 請考慮使用真實世界範例。 假設您在某個 IoT 專案中,而裝置感應器會產生很大的資料流。 為了熟悉資料,您可以撰寫一個方法,每 N 個資料項目取樣一個項目。 此小型迭代器方法即可達成目的:

public static IEnumerable<T> Sample<T>(this IEnumerable<T> sourceSequence, int interval)
{
    int index = 0;
    foreach (T item in sourceSequence)
    {
        if (index++ % interval == 0)
            yield return item;
    }
}

如果從 IoT 裝置進行讀取會產生非同步序列,則您可以修改方法,如下列方法所示:

public static async IAsyncEnumerable<T> Sample<T>(this IAsyncEnumerable<T> sourceSequence, int interval)
{
    int index = 0;
    await foreach (T item in sourceSequence)
    {
        if (index++ % interval == 0)
            yield return item;
    }
}

迭代器方法有一項重要限制︰相同的方法中不能同時有 return 陳述式和 yield return 陳述式。 下列程式碼不會進行編譯:

public IEnumerable<int> GetSingleDigitNumbers()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    // generates a compile time error:
    var items = new int[] {100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
    return items;
}

這項限制通常不成問題。 您可以選擇在整個方法中使用 yield return,或是將原始方法分成多個方法,有些方法使用 return,而有些方法使用 yield return

您可以稍微修改最後一個方法,以在所有位置使用 yield return

public IEnumerable<int> GetFirstDecile()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    var items = new int[] {100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
    foreach (var item in items)
        yield return item;
}

有時候,適當的解決方法是將一個迭代器方法分成兩個不同的方法。 其中一個方法使用 return,而第二個方法使用 yield return。 請考慮您可能需要根據布林值引數傳回空集合或前五個奇數的情況。 您可以將其撰寫成下列兩個方法:

public IEnumerable<int> GetSingleDigitOddNumbers(bool getCollection)
{
    if (getCollection == false)
        return new int[0];
    else
        return IteratorMethod();
}

private IEnumerable<int> IteratorMethod()
{
    int index = 0;
    while (index < 10)
    {
        if (index % 2 == 1)
            yield return index;
        index++;
    }
}

請看看上述方法。 第一個方法使用標準 return 陳述式來傳回空集合,或第二個方法所建立的迭代器。 第二個方法使用 yield return 陳述式來建立要求的序列。

深入探討 foreach

foreach 陳述式會展開為一個標準慣例,該慣例使用 IEnumerable<T>IEnumerator<T> 介面來逐一查看集合的所有項目。 它也會將開發人員未正確管理資源時所發生的錯誤降到最低。

編譯器會將第一個範例中所示的 foreach 迴圈轉譯為類似下列建構的程式碼:

IEnumerator<int> enumerator = collection.GetEnumerator();
while (enumerator.MoveNext())
{
    var item = enumerator.Current;
    Console.WriteLine(item.ToString());
}

編譯器所產生的實際程式碼較為複雜,而且會處理 GetEnumerator() 所傳回的物件實作 IDisposable 介面的情況。 完全展開會產生類似如下的程式碼:

{
    var enumerator = collection.GetEnumerator();
    try
    {
        while (enumerator.MoveNext())
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    }
    finally
    {
        // dispose of enumerator.
    }
}

編譯器會將第一個非同步範例轉譯為與下列建構類似的內容:

{
    var enumerator = collection.GetAsyncEnumerator();
    try
    {
        while (await enumerator.MoveNextAsync())
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    }
    finally
    {
        // dispose of async enumerator.
    }
}

用來處置列舉程式的方法取決於 enumerator 類型的特性。 在一般同步案例中,finally 子句會展開為:

finally
{
   (enumerator as IDisposable)?.Dispose();
}

一般非同步案例會展開為:

finally
{
    if (enumerator is IAsyncDisposable asyncDisposable)
        await asyncDisposable.DisposeAsync();
}

不過,如果 enumerator 的類型是密封類型,而且不會將 enumerator 的類型隱含轉換成 IDisposableIAsyncDisposable,則 finally 子句會展開為空白區塊:

finally
{
}

如果會將 enumerator 的類型隱含轉換成 IDisposable,而且 enumerator 是不可為 Null 的實值類型,則 finally 子句會展開為:

finally
{
   ((IDisposable)enumerator).Dispose();
}

還好,您不需要記住上述所有詳細資料。 foreach 陳述式會為您處理上述所有細微差異。 編譯器會為上述任何建構產生正確的程式碼。