Iterators

Quase todos os programas que você escrever terão alguma necessidade de iterar em uma coleção. Você escreverá um código que examina cada item em uma coleção.

Você também criará métodos de iterador, que são métodos que produzem um iterator para os elementos dessa classe. Um iterator é um objeto que percorre contêineres, particularmente listas. Os iteradores podem ser usados para:

  • Executar uma ação em cada item em uma coleção.
  • Enumerar uma coleção personalizada.
  • Estender LINQ ou outras bibliotecas.
  • Criar um pipeline de dados em que os dados fluem com eficiência pelos métodos de iterador.

A linguagem C# fornece recursos para gerar e consumir sequências. Essas sequências podem ser produzidas e consumidas de maneira síncrona ou assíncrona. Este artigo fornece uma visão geral desses recursos.

iterando com foreach

Enumerar uma coleção é simples: a palavra-chave foreach enumera uma coleção, executando a instrução inserida uma vez para cada elemento na coleção:

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

Isso é tudo. Para iterar em todo o conteúdo de uma coleção, a instrução foreach é tudo o que você precisa. No entanto, a instrução foreach não é mágica. Ele se baseia em duas interfaces genéricas definidas na biblioteca do .NET Core a fim de gerar o código necessário para iterar uma coleção: IEnumerable<T> e IEnumerator<T>. Esse mecanismo é explicado mais detalhadamente abaixo.

Essas duas interfaces também têm contrapartes não genéricas: IEnumerable e IEnumerator. As versões genéricas são preferenciais para o código moderno.

Quando uma sequência é gerada de maneira assíncrona, você pode usar a instrução await foreach para consumir essa sequência assincronamente:

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

Quando a sequência é uma System.Collections.Generic.IEnumerable<T>, você usa foreach. Quando a sequência é uma System.Collections.Generic.IAsyncEnumerable<T>, você usa await foreach. No último caso, a sequência é gerada de maneira assíncrona.

Fontes de enumeração com métodos de iterador

Outro ótimo recurso da linguagem C# permite que você crie métodos que criam uma fonte para uma enumeração. Esses métodos são chamados de métodos de iterador. Um método de iterador define como gerar os objetos em uma sequência quando solicitado. Você usa as palavras-chave contextuais yield return para definir um método iterador.

Você poderia escrever esse método para produzir a sequência de inteiros de 0 a 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;
}

O código acima mostra instruções yield return distintas para destacar o fato de que você pode usar várias instruções yield return discretas em um método iterador. Você pode (e frequentemente o faz) usar outros constructos de linguagem para simplificar o código de um método iterador. A definição do método abaixo produz a mesma sequência exata de números:

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

Você não precisa determinar uma ou a outra. Você pode ter quantas instruções yield return forem necessárias para atender as necessidades do seu método:

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

    yield return 50;

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

Todos esses exemplos anteriores teriam um equivalente assíncrono. Em cada caso, você substituiria o tipo de retorno IEnumerable<T> por IAsyncEnumerable<T>. O exemplo anterior, por exemplo, teria a seguinte versão assíncrona:

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++;
}

Essa é a sintaxe para iteradores síncronos e assíncronos. Vamos considerar um exemplo do mundo real. Imagine que você está em um projeto de IoT e os sensores de dispositivo geram um enorme fluxo de dados. Para ter uma noção dos dados, você pode escrever um método realiza a amostragem a cada N elementos de dados. Esse pequeno método iterador resolve:

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;
    }
}

Se a leitura do dispositivo IoT produzir uma sequência assíncrona, você modificará o método como mostra o seguinte exemplo:

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;
    }
}

Há uma restrição importante em métodos de iterador: você não pode ter uma instrução return e uma instrução yield return no mesmo método. O seguinte código não será compilado:

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;
}

Essa restrição normalmente não é um problema. Você tem a opção de usar yield return em todo o método ou separar o método original em vários métodos, alguns usando return e alguns usando yield return.

Você pode modificar um pouco o último método para usar yield return em todos os lugares:

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;
}

Às vezes, a resposta certa é dividir um método iterador em dois métodos diferentes. Um que usa return e outro que usa yield return. Considere a situação em que você talvez deseja retornar uma coleção vazia ou os primeiros cinco números ímpares, com base em um argumento booliano. Você poderia escrever isso como esses dois métodos:

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++;
    }
}

Observe os métodos acima. O primeiro usa a instrução return padrão para retornar uma coleção vazia ou o iterador criado pelo segundo método. O segundo método usa a instrução yield return para criar a sequência solicitada.

Aprofundamento em foreach

A instrução foreach se expande em uma expressão padrão que usa as interfaces IEnumerable<T> e IEnumerator<T> para iterar em todos os elementos de uma coleção. Ela também minimiza os erros cometidos pelos desenvolvedores por não gerenciarem os recursos adequadamente.

O compilador converte o loop foreach mostrado no primeiro exemplo em algo semelhante a esse constructo:

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

O código exato gerado pelo compilador é mais complicado e lida com situações em que o objeto retornado por GetEnumerator() implementa a interface IDisposable. A expansão completa gera um código mais semelhante a esse:

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

O compilador converte a primeira amostra assíncrona em algo semelhante a este constructo:

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

A maneira na qual o enumerador é descartado depende das características do tipo de enumerator. No caso síncrono geral, a cláusula finally se expande para:

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

O caso assíncrono geral se expande para:

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

No entanto, se enumerator é de um tipo selado e não há nenhuma conversão implícita do tipo de enumerator para IDisposable ou IAsyncDisposable, a cláusula finally se expande para um bloco vazio:

finally
{
}

Se houver uma conversão implícita do tipo de enumerator para IDisposable e enumerator for um tipo de valor não anulável, a cláusula finally se expandirá para:

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

Felizmente, você não precisa se lembrar de todos esses detalhes. A instrução foreach trata todas essas nuances para você. O compilador gerará o código correto para qualquer um desses constructos.