Sequências Sob Demanda: IEnumerable, yield e Coleções Imutáveis

Sequências Sob Demanda: IEnumerable, yield e Coleções Imutáveis

O que o foreach faz por baixo com IEnumerable e IEnumerator, iteradores com yield return, sequências infinitas, a validação adiada e como evitá-la com uma função local, tipos próprios percorríveis e as coleções imutáveis e congeladas do .NET (ImmutableList, FrozenDictionary).
Linguagem C#

• • 16 min de leitura

Uma sequência não precisa existir inteira na memória: basta saber produzir o próximo elemento quando alguém pedir.

Desde a Fase 1 percorremos arrays, listas e dicionários com foreach, e na Fase 3 encadeamos operadores LINQ sobre eles. Em "LINQ, Parte II" vimos que o Where não filtra na hora em que é chamado: ele entrega os elementos um a um, só quando alguém os pede. Nunca perguntamos o que torna uma coleção "percorrível", nem como o Where consegue esse comportamento. Nesta aula abrimos essa caixa: a interface que o foreach usa, a palavra-chave que permite escrever sequências próprias em poucas linhas e, no fim, as coleções que nunca mudam, uma ferramenta poderosa para escrever código que não surpreende.

O que o foreach realmente faz

Um foreach é açúcar sintático. Este laço:

foreach (string titulo in titulos)
    Console.WriteLine(titulo);

é traduzido pelo compilador, aproximadamente, para:

using (IEnumerator<string> cursor = titulos.GetEnumerator())
{
    while (cursor.MoveNext())
    {
        string titulo = cursor.Current;
        Console.WriteLine(titulo);
    }
}

Duas interfaces estão em jogo. IEnumerable<T> tem um único método, GetEnumerator(), que devolve um cursor novo. IEnumerator<T> é o cursor: MoveNext() avança para o próximo elemento e devolve false quando acabou, e Current é o elemento atual. O cursor também é IDisposable, e o using garante que ele libere o que estiver usando, como um arquivo aberto, mesmo que o laço seja interrompido. É uma aplicação do try/finally de "Quando Algo Dá Errado".

Essa separação entre "a coleção" e "o cursor sobre ela" é o que permite percorrer a mesma lista em dois laços aninhados: cada foreach pede o seu próprio cursor. E é por isso que qualquer tipo que implemente IEnumerable<T> funciona com foreach e com todos os operadores do LINQ.

Escrevendo uma sequência: do jeito difícil ao yield

Implementar um cursor à mão exige uma classe com estado, MoveNext, Current, Reset e Dispose. São dezenas de linhas para algo conceitualmente simples. O C# 2.0 introduziu uma alternativa que transforma essa tarefa em algo trivial: o iterador, um método que usa yield return.

static IEnumerable<int> Intervalo(int inicio, int fim)
{
    for (int i = inicio; i <= fim; i++)
        yield return i;
}

foreach (int n in Intervalo(1, 5))
    Console.Write($"{n} ");   // 1 2 3 4 5

yield return significa "entregue este elemento a quem está pedindo e pause aqui". Quando o foreach pede o próximo, a execução continua exatamente do ponto onde parou, com todas as variáveis locais preservadas. yield break encerra a sequência antes do fim.

Por baixo, o compilador reescreve o método inteiro como uma classe que implementa IEnumerable<int> e IEnumerator<int>, uma máquina de estados que lembra em que ponto do código estava. Você escreve um laço comum, e o compilador gera o cursor.

A consequência mais importante é que nada executa na chamada. Intervalo(1, 5) devolve imediatamente um objeto, e o corpo só roda quando alguém começa a percorrer. Veja:

static IEnumerable<int> Contar()
{
    Console.WriteLine("  (começou)");
    for (int i = 1; i <= 3; i++)
    {
        Console.WriteLine($"  (entregando {i})");
        yield return i;
    }
    Console.WriteLine("  (terminou)");
}

var sequencia = Contar();
Console.WriteLine("Sequência criada.");
foreach (int n in sequencia)
    Console.WriteLine($"Recebi {n}");
Sequência criada.
  (começou)
  (entregando 1)
Recebi 1
  (entregando 2)
Recebi 2
  (entregando 3)
Recebi 3
  (terminou)

É exatamente o comportamento do experimento com Where em "LINQ, Parte II". E agora o mistério se resolve por completo: o Where é um iterador. Esta é, em essência, a sua implementação:

public static IEnumerable<T> Onde<T>(this IEnumerable<T> origem, Func<T, bool> criterio)
{
    foreach (T item in origem)
        if (criterio(item))
            yield return item;
}

É o Filtrar de "Delegates, Lambdas e Eventos" com uma única diferença: yield return no lugar de resultado.Add. Essa troca é toda a diferença entre montar uma lista inteira na memória e entregar elementos sob demanda.

Sequências infinitas

Como os elementos só são produzidos quando pedidos, uma sequência pode ser infinita:

static IEnumerable<long> Fibonacci()
{
    long a = 0, b = 1;
    while (true)
    {
        yield return a;
        (a, b) = (b, a + b);
    }
}

Console.WriteLine(string.Join(", ", Fibonacci().Take(10)));
// 0, 1, 1, 2, 3, 5, 8, 13, 21, 34

long primeiroAcimaDeMil = Fibonacci().First(n => n > 1000);   // 1597

O while (true) não trava o programa, porque o iterador só avança quando alguém pede. Take(10) pede dez elementos e para. First para no primeiro que satisfaz o critério. Mas cuidado com operadores que precisam de tudo: Fibonacci().Count(), .ToList() ou .OrderBy(...) nunca terminam. Eles pedem elementos para sempre, e o programa trava consumindo memória até falhar.

Sequências sob demanda também servem para dados grandes. File.ReadLines(caminho) devolve as linhas de um arquivo como um iterador: um arquivo de 10 GB pode ser filtrado com LINQ usando memória para uma linha por vez. Já File.ReadAllLines carrega tudo num array. Os arquivos ganham aula própria na Fase 4, mas a distinção começa aqui.

A armadilha da validação adiada

Como o corpo de um iterador só executa quando a sequência é percorrida, as validações dentro dele também atrasam:

static IEnumerable<int> Multiplos(int de, int quantidade)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);   // só executa no foreach!
    for (int i = 1; i <= quantidade; i++)
        yield return de * i;
}

var multiplos = Multiplos(3, -1);   // nenhuma exceção aqui
// ... muitas linhas depois ...
foreach (var m in multiplos) { }    // a exceção aparece só aqui, longe da causa

O erro está na linha da chamada, mas a exceção explode no foreach, possivelmente em outro método. A correção é separar o método em duas partes: uma externa comum, que valida imediatamente, e um iterador interno, numa função local:

static IEnumerable<int> Multiplos(int de, int quantidade)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);   // executa na chamada
    return Gerar();

    IEnumerable<int> Gerar()
    {
        for (int i = 1; i <= quantidade; i++)
            yield return de * i;
    }
}

Como o método externo não tem yield, ele é um método comum e valida na hora. O iterador interno captura de e quantidade e produz a sequência sob demanda. É exatamente assim que os operadores do LINQ são implementados na biblioteca padrão: Where(null, ...) lança a exceção na chamada, e não no foreach.

Tornando um tipo seu percorrível

Um tipo seu pode implementar IEnumerable<T>, e um iterador torna isso quase trivial:

using System.Collections;

public sealed class Estante : IEnumerable<Produto>
{
    private readonly List<Produto> _produtos = [];

    public void Colocar(Produto produto) => _produtos.Add(produto);

    public IEnumerator<Produto> GetEnumerator()
    {
        foreach (var p in _produtos)
            yield return p;
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

A última linha merece explicação. IEnumerable<T> herda da versão antiga, não genérica, IEnumerable, da época do C# 1.0. Por isso a classe precisa oferecer as duas. A versão antiga é implementada explicitamente, com o nome da interface na frente: ela só é visível quando o objeto é tratado como IEnumerable não genérico, e simplesmente repassa a chamada para a versão moderna. Agora foreach (var p in estante) e estante.Where(...) funcionam.

Coleções imutáveis

Uma última peça fecha a aula. Em "Propriedades e Encapsulamento" protegemos a lista de itens do pedido com IReadOnlyList<T> e AsReadOnly(). Isso impede que quem recebe a lista a altere, mas a lista original continua mutável por dentro, e quem recebeu a visão enxerga as mudanças. Às vezes queremos mais: uma coleção que ninguém, nunca, pode alterar. O namespace System.Collections.Immutable oferece exatamente isso:

using System.Collections.Immutable;

ImmutableList<string> a = ["Dom Casmurro", "Vidas Secas"];
ImmutableList<string> b = a.Add("São Bernardo");

Console.WriteLine(a.Count);   // 2: a original não mudou
Console.WriteLine(b.Count);   // 3: uma NOVA lista

Todo "método de alteração" de uma coleção imutável devolve uma coleção nova e deixa a original intacta. É a expressão with dos records de "Classe, Struct ou Record", aplicada a coleções. E não é tão caro quanto parece: ImmutableList<T> é implementada como uma árvore, e a lista nova compartilha quase toda a estrutura com a antiga. Acrescentar um elemento cria só alguns nós novos, e não uma cópia inteira.

As principais opções, e quando usar cada uma:

  • ImmutableList<T> e ImmutableDictionary<TKey, TValue>: coleções que "mudam" com frequência, sempre gerando versões novas. Boas para estado compartilhado entre várias partes do programa ou entre threads, que estudaremos na aula de concorrência.
  • ImmutableArray<T>: um array que não pode ser alterado. É leve e rápido para ler, e caro para "alterar", porque copia tudo. É ideal para dados pequenos, criados uma vez.
  • FrozenDictionary<TKey, TValue> e FrozenSet<T> (.NET 8, namespace System.Collections.Frozen): coleções congeladas, otimizadas para leitura. A criação é mais lenta, porque elas analisam os dados para montar a estrutura de busca mais eficiente possível, mas as consultas ficam mais rápidas que as de um Dictionary comum. São perfeitas para tabelas montadas na inicialização e consultadas milhões de vezes.

Um aviso honesto sobre o alcance da imutabilidade: ela é rasa. Uma ImmutableList<Produto> garante que ninguém adicione, remova ou troque produtos da lista, mas os próprios produtos continuam objetos mutáveis: o estoque de um LivroImpresso dentro dela ainda muda quando alguém chama Vender. A imutabilidade de verdade exige que os elementos também sejam imutáveis, como records e readonly record struct.

Juntando tudo: um catálogo imutável e uma tabela congelada

O catálogo da Livraria pode ser um valor imutável: cada inclusão produz um catálogo novo. Quem estiver percorrendo a versão anterior nunca verá a coleção mudar no meio do caminho:

using System.Collections;
using System.Collections.Immutable;

public sealed class Catalogo : IEnumerable<Produto>
{
    public static readonly Catalogo Vazio = new(ImmutableList<Produto>.Empty);

    private readonly ImmutableList<Produto> _produtos;

    private Catalogo(ImmutableList<Produto> produtos) => _produtos = produtos;

    public int Quantidade => _produtos.Count;

    public Catalogo Com(Produto produto)
    {
        ArgumentNullException.ThrowIfNull(produto);
        return new Catalogo(_produtos.Add(produto));
    }

    public Catalogo Sem(int id)
    {
        Produto? alvo = _produtos.Find(p => p.Id == id);
        return alvo is null ? this : new Catalogo(_produtos.Remove(alvo));
    }

    public IEnumerable<Produto> Disponiveis()
    {
        foreach (var p in _produtos)
        {
            if (p is LivroImpresso { Estoque: 0 })
                continue;
            yield return p;
        }
    }

    public IEnumerator<Produto> GetEnumerator() => _produtos.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

O construtor é privado. A única forma de obter um catálogo é partir de Catalogo.Vazio e usar Com, que devolve um catálogo novo a cada inclusão. Sem devolve o próprio catálogo quando o identificador não existe: se nada muda, não há por que criar outro. E Disponiveis é um iterador que pula os impressos esgotados, avaliado sob demanda: ele reflete o estoque do momento em que é percorrido, e não do momento em que foi chamado.

Para a entrega, uma tabela de prazos por estado é montada uma vez na inicialização e consultada a cada pedido, o caso ideal para uma coleção congelada:

using System.Collections.Frozen;
using System.Globalization;

CultureInfo.CurrentCulture = new CultureInfo("pt-BR");

FrozenDictionary<string, int> prazoEmDias = new Dictionary<string, int>
{
    ["PR"] = 2, ["SP"] = 3, ["SC"] = 3, ["RS"] = 4, ["RJ"] = 4, ["MG"] = 4,
    ["BA"] = 7, ["PE"] = 8, ["AM"] = 12,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);

Catalogo catalogo = Catalogo.Vazio
    .Com(new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 10))
    .Com(new LivroImpresso("Memórias Póstumas de Brás Cubas", "Machado de Assis", 44.90m, pesoGramas: 300, estoque: 0))
    .Com(new Ebook("Vidas Secas", "Graciliano Ramos", 24.90m, tamanhoMb: 1.8));

Console.WriteLine($"No catálogo: {catalogo.Quantidade}; disponíveis: {catalogo.Disponiveis().Count()}");

foreach (var p in catalogo.Disponiveis().OrderBy(p => p.Preco))
    Console.WriteLine($"  {p.Titulo,-30}{p.Preco,10:C}");

string uf = "sp";
Console.WriteLine(prazoEmDias.TryGetValue(uf, out int dias)
    ? $"Entrega para {uf.ToUpperInvariant()}: {dias} dia(s) úteis"
    : $"Ainda não entregamos em {uf.ToUpperInvariant()}");
No catálogo: 3; disponíveis: 2
  Vidas Secas                     R$ 24,90
  Dom Casmurro                    R$ 59,90
Entrega para SP: 3 dia(s) úteis

O catálogo se lê como uma frase: vazio, com isto, com aquilo, com mais isto. A tabela de prazos ignora maiúsculas e minúsculas, porque o comparador foi passado na criação, e nunca vai mudar durante a execução, o que qualquer leitor sabe só pelo tipo FrozenDictionary.

Nesta e nas aulas anteriores, vários trechos de código traziam informações entre colchetes: [Flags] num enum, [NotNullWhen(true)] num parâmetro. São informações sobre o código, e não instruções executáveis. Alguém as lê: às vezes o compilador, às vezes o próprio programa em execução, examinando os seus tipos, propriedades e métodos como se fossem dados. Essa capacidade de um programa olhar para si mesmo é o que permite que bibliotecas como o serializador JSON, o Entity Framework e o ASP.NET funcionem com classes que elas nunca viram.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva um iterador Intervalo(int inicio, int fim, int passo) que produza números de inicio até fim (inclusive) de passo em passo, aceitando passos negativos para contagem decrescente. Um passo zero deve lançar exceção na chamada.

Ver resposta

✓ Resposta:

static IEnumerable<int> Intervalo(int inicio, int fim, int passo)
{
    ArgumentOutOfRangeException.ThrowIfZero(passo);
    return Gerar();

    IEnumerable<int> Gerar()
    {
        if (passo > 0)
            for (int i = inicio; i <= fim; i += passo) yield return i;
        else
            for (int i = inicio; i >= fim; i += passo) yield return i;
    }
}

Console.WriteLine(string.Join(" ", Intervalo(0, 10, 3)));    // 0 3 6 9
Console.WriteLine(string.Join(" ", Intervalo(10, 0, -4)));   // 10 6 2

A validação fica no método externo, sem yield, para executar na chamada, como vimos na seção sobre validação adiada. Com o passo zero dentro do iterador, Intervalo(0, 10, 0) só falharia quando percorrido, ou pior: sem a validação, geraria um laço infinito devolvendo sempre o mesmo número. Os dois for tratam a direção: com passo negativo, a condição de continuação se inverte.

Exercício 2

Usando o iterador Fibonacci da aula, obtenha os números de Fibonacci pares menores que 1000. Depois explique o que aconteceria com Fibonacci().Where(n => n % 2 == 0).ToList().

Ver resposta

✓ Resposta:

var pares = Fibonacci()
    .TakeWhile(n => n < 1000)
    .Where(n => n % 2 == 0);

Console.WriteLine(string.Join(", ", pares));   // 0, 2, 8, 34, 144, 610

TakeWhile pega elementos enquanto a condição for verdadeira e para no primeiro que falha, o que interrompe a sequência infinita em 987, o último abaixo de 1000. Aqui a ordem não muda o resultado: .Where(...).TakeWhile(n => n < 1000) também funcionaria, porque o primeiro par acima de 1000 (2584) encerraria o TakeWhile. Já Fibonacci().Where(n => n % 2 == 0).ToList() nunca termina: o ToList pede todos os elementos, e a sequência não tem fim. O programa ficaria preso para sempre ou, na prática, o long estouraria em silêncio por volta do 93º termo e a lista cresceria até esgotar a memória. Com sequências infinitas, todo pipeline precisa de um operador que limite a quantidade (Take, TakeWhile ou First) antes de qualquer operador que precise de tudo.

Exercício 3

O iterador abaixo tem um defeito de desenho relacionado à validação. Mostre o sintoma e corrija-o.

static IEnumerable<string> LinhasNaoVazias(string[] linhas)
{
    ArgumentNullException.ThrowIfNull(linhas);
    foreach (var l in linhas)
        if (!string.IsNullOrWhiteSpace(l))
            yield return l.Trim();
}
Ver resposta

✓ Resposta: Por conter yield, o método inteiro é um iterador, inclusive a validação. var r = LinhasNaoVazias(null!); não lança nada. A exceção só aparece quando r é percorrido, talvez em outro método, e o stack trace aponta o foreach, e não a chamada com argumento nulo. A correção separa validação e geração:

static IEnumerable<string> LinhasNaoVazias(string[] linhas)
{
    ArgumentNullException.ThrowIfNull(linhas);
    return Filtrar();

    IEnumerable<string> Filtrar()
    {
        foreach (var l in linhas)
            if (!string.IsNullOrWhiteSpace(l))
                yield return l.Trim();
    }
}

Agora o erro aparece imediatamente, na linha que passou o nulo. Esse padrão, com o método externo validando e a função local iterando, é o mesmo usado nos operadores do LINQ.

Exercício 4

Preveja a saída e explique o custo de cada operação:

using System.Collections.Immutable;

var original = ImmutableList.Create(1, 2, 3);
var comQuatro = original.Add(4);
var semUm = comQuatro.Remove(1);
Console.WriteLine($"{original.Count} {comQuatro.Count} {semUm.Count}");
Console.WriteLine(string.Join(",", semUm));
Ver resposta

✓ Resposta: Imprime 3 4 3 e depois 2,3,4. Cada operação devolveu uma lista nova, e nenhuma alterou a anterior: original continua com três elementos, e comQuatro continua com o 1. Quanto ao custo: ImmutableList<T> é uma árvore balanceada, então Add e Remove custam na ordem de log(n), e a nova lista reaproveita quase todos os nós da anterior. Não há cópia integral. Em compensação, o acesso por índice também custa log(n), mais lento que o acesso instantâneo de uma List<T>. Por isso, para dados criados de uma vez e só lidos, ImmutableArray<T> ou FrozenSet<T> são melhores escolhas, e ImmutableList<T> compensa quando a coleção "muda" com frequência e precisa ser compartilhada com segurança.

Exercício 5

Crie uma classe ContagemRegressiva que implemente IEnumerable<int> e, dado um número inicial, produza a contagem até zero. Use-a num foreach e num LINQ que some apenas os números pares.

Ver resposta

✓ Resposta:

using System.Collections;

public sealed class ContagemRegressiva(int inicio) : IEnumerable<int>
{
    public IEnumerator<int> GetEnumerator()
    {
        for (int i = inicio; i >= 0; i--)
            yield return i;
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

var contagem = new ContagemRegressiva(5);
foreach (int n in contagem)
    Console.Write($"{n} ");                                  // 5 4 3 2 1 0

Console.WriteLine();
Console.WriteLine(contagem.Where(n => n % 2 == 0).Sum());    // 6 (4 + 2 + 0)

Um iterador pode ser o próprio GetEnumerator: o compilador gera o cursor, e a classe fica com poucas linhas. Como cada chamada a GetEnumerator cria um cursor novo, o mesmo objeto contagem pode ser percorrido várias vezes, e cada foreach recomeça do início. A implementação explícita da interface não genérica é obrigatória, pela herança histórica de IEnumerable<T>. O construtor primário deixa o parâmetro inicio disponível para o iterador. E, ao implementar IEnumerable<int>, a classe ganha todo o LINQ sem nenhuma linha extra: é o poder dos métodos de extensão sobre interfaces, de "LINQ, Parte I".

Comentários

Mais em Linguagem C#

Classe, Struct ou Record: Identidade, Valor e Igualdade
Classe, Struct ou Record: Identidade, Valor e Igualdade

Classe, struct ou record em C#: identidade contra valor, structs e a semântica…

O caixa da Livraria em Avalonia: MVVM, navegação, leitor de código de barras e vendas offline
O caixa da Livraria em Avalonia: MVVM, navegação, leitor de código de barras e vendas offline

O caixa da loja física em Avalonia, para Windows e Linux: MVVM com…

Do Console ao Jogo Online: o Mapa da Jornada em C#
Do Console ao Jogo Online: o Mapa da Jornada em C#

O mapa do curso de C#: setenta e três aulas em onze fases, dos fundamentos e…