迭代器

你编写的几乎每个程序都需要循环访问集合。 你将编写代码来检查集合中的每个项。

你还将创建迭代器方法,即为该类的元素生成 迭代器 的方法。 迭代器是遍历容器的对象,尤其是列表。 迭代器可用于:

  • 对集合中的每个项执行操作。
  • 枚举自定义集合。
  • 扩展 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。 考虑这样一种情况:需要基于布尔参数返回一个空集合,或者返回前 5 个奇数。 可编写类似以下 2 种方法的方法:

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 的类型为已密封类型,并且不存在从类型 enumeratorIDisposableIAsyncDisposable 的隐式转换,则 finally 子句扩展为一个空白块:

finally
{
}

如果存在从类型 enumeratorIDisposable 的隐式转换,并且 enumerator 是不可为 null 的值类型,则该 finally 子句将展开为:

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

值得庆幸的是,无需记住所有这些详细信息。 foreach 语句会为你处理所有这些细微差别。 编译器将为这些构造中的任何一个生成正确的代码。