Vinicius Quinafelex Alves

🌐English version

[C#] O poder do IEnumerable e Yield

IEnumerable é uma interface que representa um conjunto de dados que podem ser lidos item por item, podendo apenas percorrer para frente de forma similar a um stream.

Coleções nativas do .NET, como array e List<T>, por padrão já implementam IEnumerable pela facilidade de uso.

Instâncias de IEnumerable costumam ser consumidas por foreach.

IEnumerable<string> items = new string[] { "stub" };

foreach(string item in items)
    Console.WriteLine(item.ToLower());

System.Linq disponibiliza alguns extension methods para facilitar o uso do IEnumerable, como por exemplo o Count() para calcular quantos itens existe no conjunto, Select() para gerar um novo objeto para cada item do conjunto e Sum() para fazer a somatória de todos os itens de um conjunto.

using System.Linq;

var items = new int[] { 1, 2, 3 };
var summation = items.Sum();

Por baixo dos panos, um IEnumerable é percorrido através de um IEnumerator gerado por ele, que pode ser consumido manualmente.

IEnumerable<string> items = new string[] { "stub" };

using(IEnumerator<string> enumerator = items.GetEnumerator())
    while(enumerator.MoveNext())
        Console.WriteLine(enumerator.Current.ToLower());

IEnumerable também pode ser retornado como resultado de uma função geradora, aonde os dados são gerados durante a execução, e não precisam ser carregados todos em memória logo no começo.

No C#, uma função geradora pode ser escrita usando yield.

Funções geradoras são úteis para processar dados sem precisar de um carregamento inicial de todos os items. Isso possibilita a criação de algoritmos com consumo constante de memória - O(1) - enquanto a função é executada.

O exemplo abaixo demonstra uma forma de criar uma função geradora capaz de ler um arquivo .csv de qualquer tamanho por retornar o conteúdo linha por linha.

public static IEnumerable<string> EnumerateLinesOfFile()
{
    using (var reader = new StreamReader("file.csv"))
        while (!reader.EndOfStream)
            yield return reader.ReadLine();
}

static void Main(string[] args)
{
    var lineCount = EnumerateLinesOfFile().Count();
    var message = $"The file has {lineCount} lines";
    Console.WriteLine(message);
}

Outro cenário prático é o processamento do resultado de uma consulta SQL que retorna muitos dados, que podem ser percorridos sem necessidade de carregar todos os dados de uma vez.

Importante notar que a conexão permanece aberta enquanto o loop está executando, e só será fechado quando o IEnumerable sofrer Dispose(), seja implicito (no fim de um foreach) ou explícito (via using ou manualmente).

public IEnumerable<string> EnumerateFromDatabase()
{
    using(var connection = new SqlConnection("<connection_string>"))
    using(var command = connection.CreateCommand())
    {
        command.CommandText = "<select>";

        connection.Open();
        using (var reader = command.ExecuteReader())
            while(reader.Read())
                yield return reader[0].ToString();
    }
}

Funções geradoras só começam sua execução quando o IEnumerable começa a ser percorrido, por exemplo quando um foreach é executado ou através do IEnumerator. Algumas funções Linq, como Count(), FirstOrDefault() e ToList() também executam a função.

Importante notar que algumas funções Linq são funções geradoras e não são imediatamente executadas quando invocadas, por exemplo Select() e Where(). Não há diferença se essas funções forem executados em cima de um IEnumerable de função geracional ou uma coleção carregada em memória.

Outra opção menos comum é a criação de uma função que pode ser infinitamente executada, e que pode ser interrompida por break ou algum método limitador do Linq, como First() ou Take().

/// <summary>Function that generate Fibonacci numbers</summary>
static IEnumerable<int> Fibonacci()
{
    int value1 = 1;
    int value2 = 1;

    yield return value1;
    yield return value2;

    while (true)
    {
        int aux = value2;
        value2 += value1;
        value1 = aux;

        yield return value2;
    }
}

static void Main(string[] args)
{
    // Take the 10th number of fibonacci = 55
    var fibonacciTenth = Fibonacci().Skip(9).First();

    // Sum the 5 first numbers = 12
    var sumFirstFive = Fibonacci().Take(5).Sum();

    // Throws exception, because the function evaluation is infinite
    var allFibonacciNumbers = Fibonacci().ToList();
}

C# 8.0 também introduziu a interface IAsyncEnumerable para funções geradoras com assincronia. Este link leva a um artigo escrito sobre assincronia.

Para executar métodos Linq em IAsyncEnumerable é necessário adicionar o pacote System.Linq.Async no projeto. Esse pacote é oficialmente suportado pela Microsoft.

// Generator function with async
public async IAsyncEnumerable<string> EnumerateLinesAsync()
{
    using(var reader = new StreamReader("path.csv"))
        while(!reader.EndOfStream)
            yield return await reader.ReadLineAsync();
}
// Consuming an IAsyncEnumerable
await foreach(var line in EnumerateLinesAsync())
{
    Console.WriteLine(line);
}

Importante notar que a interface IAsyncEnumerable só está disponível a partir do .NET Standard 2.1 ou .NET Core 3.0, e não está disponível no .NET Framework (atualmente na versão 4.8).

Usar IAsyncEnumerable é a forma recomendada de escrever funções geradoras assíncronas. Não é trivial tentar implementar um IEnumerable com capacidades de assincronia, pois durante a execução o IEnumerator chama o método MoveNext(), que é um método bloqueante e impede os benefícios da assincronia.