A maior e melhor Black Friday que já fizemos! Descontos a partir de 50% em até 12x sem juros - Garanta seu Desconto

Percorrer lista em C#: todas as formas, com benchmark

Percorrer lista em C#: todas as formas, com benchmark

Percorrer lista em C# é uma das primeiras tarefas que todo desenvolvedor aprende, e o foreach resolve a maior parte dos casos. No entanto, surgem outras necessidades quando você precisa do índice, quer remover itens durante o laço ou passa a medir desempenho.

Este guia apresenta as formas práticas de iterar um List<T>: foreach, for, Index(), List<T>.ForEach, ordem reversa, modificação durante o laço, listas aninhadas, Span<T> e laços assíncronos. Além disso, ele traz um benchmark comparando cada abordagem no .NET 10 e no .NET 11 RC 1.

Se a pressa for grande, o exemplo abaixo já atende a maioria dos cenários.

List<string> fruits = ["apple", "banana", "cherry"];

foreach (var fruit in fruits)
{
    Console.WriteLine(fruit);
}

Como percorrer lista em C# com foreach

O foreach é a escolha padrão para percorrer lista em C#. Ele tem a sintaxe mais curta, dispensa o controle de índice e funciona com qualquer coleção, não apenas com List<T>.

A documentação oficial das instruções de iteração descreve o foreach como o laço que executa o corpo uma vez para cada elemento, na ordem da coleção. Por isso, ele evita os erros clássicos de limite, como acessar uma posição além do fim.

List<string> fruits = ["apple", "banana", "cherry"];

foreach (var fruit in fruits)
{
    // cada iteração recebe o próximo item da lista
    Console.WriteLine(fruit);
}

Três regras merecem atenção antes de usar o foreach no dia a dia.

Primeiro, o foreach não depende de IEnumerable<T>. Qualquer tipo com um método público GetEnumerator() funciona, e é por isso que você também consegue iterar um Span<T>.

Em segundo lugar, uma lista null lança NullReferenceException. Por outro lado, uma lista vazia é segura, porque o corpo do laço simplesmente não executa.

Por fim, a variável do laço é somente leitura. Assim, uma instrução como fruit = "mango"; não compila, e quem precisa substituir itens deve usar um for.

Na prática, essas três regras explicam a maior parte das dúvidas de quem começa a percorrer lista em C#. Quando o foreach não atende, o motivo quase sempre é índice, substituição ou remoção.

Break e continue no foreach

O continue pula o restante da iteração atual e avança para o próximo item. Já o break encerra o laço imediatamente.

List<string> fruits = ["apple", "banana", "cherry", "date"];

foreach (var fruit in fruits)
{
    if (fruit == "banana")
        continue; // pula para o próximo item

    if (fruit == "date")
        break; // encerra o laço

    Console.WriteLine(fruit);
}
// apple
// cherry

As duas palavras-chave funcionam da mesma forma em for, while e do. Em laços aninhados, porém, o break encerra apenas o laço mais interno.

Percorrer lista em C# com for e índice

O for expõe a posição de cada item. Portanto, ele é a opção certa quando você precisa do índice ou quer substituir elementos da lista.

for (var i = 0; i < fruits.Count; i++)
{
    Console.WriteLine($"{i}: {fruits[i]}");
}
// 0: apple
// 1: banana
// 2: cherry

Como o acesso acontece direto pelo indexador, você consegue alterar os itens no próprio lugar.

for (var i = 0; i < fruits.Count; i++)
{
    // substitui o item na mesma posição
    fruits[i] = fruits[i].ToUpperInvariant();
}
// APPLE, BANANA, CHERRY

Ao percorrer lista em C# com for, prefira a propriedade Count ao método LINQ Count(). Em um List<T>, os dois devolvem o mesmo número, mas a propriedade lê um campo armazenado.

O método Count(), por sua vez, é uma extensão do LINQ que verifica o tipo da coleção antes de devolver esse mesmo campo. Em arrays, o equivalente da propriedade é Length, e o restante deste guia vale também para eles.

Obter o índice dentro do foreach

Quem prefere a leitura do foreach, mas precisa do índice, tem uma solução nativa desde o .NET 9. O método Enumerable.Index() associa cada item à sua posição em uma tupla que você pode desconstruir.

foreach (var (index, fruit) in fruits.Index())
{
    Console.WriteLine($"{index}: {fruit}");
}

Em versões anteriores do .NET, a sobrecarga de Select que recebe o índice cumpre o mesmo papel.

foreach (var (fruit, index) in fruits.Select((f, i) => (f, i)))
{
    Console.WriteLine($"{index}: {fruit}");
}

Ambas as abordagens deixam o código legível. Contudo, o benchmark mais adiante mostra que elas têm um custo que não aparece no for.

Por isso, use o Index() para percorrer lista em C# no código do dia a dia, como telas, relatórios e logs. Em laços executados milhões de vezes, porém, o for continua sendo a opção mais econômica para obter a posição.

List.ForEach e seus limites

O List<T> tem um método próprio chamado ForEach, que recebe um Action<T>. Como ele pertence à classe List<T>, não existe em IEnumerable<T> nem em arrays.

fruits.ForEach(fruit => Console.WriteLine(fruit));

// ou com method group
fruits.ForEach(Console.WriteLine);

O LINQ também não oferece um método ForEach. Dessa forma, quem trabalha com um IEnumerable<T> deve recorrer ao foreach tradicional.

O List<T>.ForEach funciona bem em linhas únicas, mas tem limitações sérias. Primeiro, não existe break dentro dele.

Além disso, ele se comporta mal com código assíncrono. Quando você passa uma lambda async, o compilador a transforma em um método async void.

Nesse caso, o ForEach não consegue aguardar a tarefa, e o código segue adiante antes de o trabalho terminar. A documentação da Microsoft também alerta que modificar a lista dentro do delegate não é suportado e gera comportamento indefinido.

Em outras palavras, o List<T>.ForEach serve para percorrer lista em C# apenas em operações curtas, síncronas e sem interrupção. Fora desse cenário, o foreach comum entrega o mesmo resultado com mais controle.

Percorrer lista em C# de trás para frente

A forma mais direta de percorrer lista em C# na ordem inversa é um for com contador decrescente.

for (var i = fruits.Count - 1; i >= 0; i--)
{
    Console.WriteLine(fruits[i]);
}

Outra opção é o Enumerable.Reverse do LINQ, que devolve uma sequência invertida e mantém a lista original intacta.

foreach (var fruit in Enumerable.Reverse(fruits))
{
    Console.WriteLine(fruit);
}

Atenção ao chamar fruits.Reverse() diretamente. Em um List<T>, essa chamada resolve para o método de instância, que inverte a lista no próprio lugar e devolve void.

Por isso, ela não compila dentro de um foreach. Se você a chamar em uma linha isolada, acabará reordenando os dados sem perceber.

Quando o objetivo é apenas ler o último item, o operador de índice do C# 8 resolve: fruits[^1]. No .NET 11, o JIT também remove mais verificações de limite redundantes nesse tipo de acesso.

Remover itens ao percorrer lista em C#

Remover itens dentro de um foreach compila sem erro, mas falha em tempo de execução. O runtime lança InvalidOperationException com a mensagem "Collection was modified; enumeration operation may not execute".

List<int> numbers = [1, 2, 3, 4, 5, 6];

foreach (var n in numbers)
{
    if (n % 2 == 0)
        numbers.Remove(n); // lança exceção na próxima iteração
}

O motivo está no enumerador da lista, que guarda um número de versão. Qualquer Add ou Remove incrementa essa versão, e a próxima chamada a MoveNext detecta a mudança e lança a exceção.

Existem três formas de resolver o problema ao percorrer lista em C# com remoções.

Usar RemoveAll

Quando a intenção é filtrar, o RemoveAll faz tudo em uma única chamada. Ele recebe um predicado e devolve a quantidade de itens removidos.

var removed = numbers.RemoveAll(n => n % 2 == 0);
// removed = 3; a lista fica com 1, 3, 5

Laço for de trás para frente

Remover um item desloca todos os itens seguintes uma posição para trás. Quando o laço começa pelo fim, os itens deslocados já foram verificados, então nenhum fica de fora.

for (var i = numbers.Count - 1; i >= 0; i--)
{
    if (numbers[i] % 2 == 0)
        numbers.RemoveAt(i);
}

Iterar sobre uma cópia

O ToList() cria uma cópia da lista. Assim, você pode adicionar ou remover itens da original sem exceção, ao custo de uma alocação extra e de uma cópia completa.

foreach (var n in numbers.ToList())
{
    if (n % 2 == 0)
        numbers.Remove(n);
}

Alterar propriedades dos itens dentro de um foreach, por outro lado, é seguro. Com tipos de referência, você altera os objetos para os quais a lista aponta, e a lista em si continua a mesma.

foreach (var product in products)
{
    product.Price *= 1.1m; // seguro: a lista não muda
}

Listas aninhadas e SelectMany

Para listas de listas, a solução natural é aninhar os laços.

List<List<int>> grid = [[1, 2, 3], [4, 5], [6]];

foreach (var row in grid)
{
    foreach (var cell in row)
    {
        Console.Write($"{cell} ");
    }
    Console.WriteLine();
}
// 1 2 3
// 4 5
// 6

Cada nível de laço visita uma camada da estrutura. Assim, o laço externo entrega as linhas e o interno percorre os valores de cada linha.

Quando as linhas não importam e você quer apenas cada item, o SelectMany achata a estrutura em uma sequência única.

foreach (var cell in grid.SelectMany(row => row))
{
    Console.Write($"{cell} ");
}
// 1 2 3 4 5 6

Com o SelectMany, o código para percorrer lista em C# aninhada volta a ter um único foreach. Contudo, você perde a noção de onde cada linha termina, o que importa em saídas formatadas.

Percorrer lista em C# com Span

Em caminhos críticos de desempenho, o CollectionsMarshal.AsSpan devolve um Span<T> sobre o array interno da lista. Com isso, o laço ignora as verificações de versão do enumerador e acessa o array diretamente.

using System.Runtime.InteropServices;

foreach (var score in CollectionsMarshal.AsSpan(scores))
{
    Console.WriteLine(score);
}

O Span<T> também permite iterar por ref. Na prática, isso significa alterar itens no próprio lugar, inclusive campos de structs, algo que o foreach comum sobre uma lista não permite.

List<int> scores = [10, 20, 30];

foreach (ref var score in CollectionsMarshal.AsSpan(scores))
{
    score += 5; // altera o valor direto no array interno
}
// 15, 25, 35

A documentação de CollectionsMarshal.AsSpan avisa que não se deve adicionar nem remover itens da lista enquanto o span estiver em uso. Afinal, o span não tem verificação de versão.

Se a lista crescer e realocar o array interno, o span continua apontando para o array antigo. Nesse cenário, as leituras perdem os itens novos e as escritas vão para o array descartado, sem nenhuma exceção.

Portanto, reserve essa técnica para código sensível a desempenho em que você controla a lista do início ao fim.

Percorrer lista em C# de forma assíncrona

Existem três tipos de laço assíncrono, e cada um resolve um problema diferente ao percorrer lista em C#.

Se você tem uma lista e precisa aguardar uma operação para cada item, use um foreach comum com await dentro. Cada iteração termina antes de a próxima começar.

foreach (var url in urls)
{
    var result = await FetchAsync(url);
    Console.WriteLine(result);
}

Quando os dados chegam de forma assíncrona, como um IAsyncEnumerable<T>, o caminho é o await foreach.

await foreach (var line in ReadLinesAsync())
{
    Console.WriteLine(line);
}

Já quando os itens são independentes e podem ser processados ao mesmo tempo, o Parallel.ForEachAsync executa a lambda assíncrona para vários itens em paralelo. Ele termina quando todos os itens forem concluídos.

await Parallel.ForEachAsync(
    urls,
    new ParallelOptions { MaxDegreeOfParallelism = 4 },
    async (url, cancellationToken) =>
    {
        var result = await FetchAsync(url, cancellationToken);
        Console.WriteLine(result);
    });

A propriedade MaxDegreeOfParallelism limita quantas execuções ocorrem ao mesmo tempo. Além disso, espere que a ordem de saída mude a cada execução.

Em nenhum desses cenários passe uma lambda async para o List<T>.ForEach. Como vimos, ele não consegue aguardar a tarefa, e o laço termina antes do trabalho.

Benchmark: qual laço é mais rápido

Para responder se o for supera o foreach, o autor do estudo original usou o BenchmarkDotNet com [MemoryDiagnoser]. A medição soma um List<int> com 10, 1.000 e 100.000 itens para percorrer lista em C# em escalas diferentes.

Em seguida, o mesmo código rodou no .NET 10.0.12 e no .NET 11 RC 1, em um Apple M4 Pro, com um job do BenchmarkDotNet por runtime. Para atender às duas versões, o projeto declara <TargetFrameworks>net10.0;net11.0</TargetFrameworks>.

A configuração abaixo cria um job para cada runtime e define o .NET 10 como referência.

var config = DefaultConfig.Instance
    .AddJob(Job.Default
        .WithToolchain(CsProjCoreToolchain.From(new NetCoreAppSettings("net10.0", null, ".NET 10")))
        .WithId(".NET 10").AsBaseline())
    .AddJob(Job.Default
        .WithToolchain(CsProjCoreToolchain.From(new NetCoreAppSettings("net11.0", null, ".NET 11")))
        .WithId(".NET 11"));

BenchmarkRunner.Run<LoopBenchmarks>(config, args);

Com essa estrutura, cada forma de percorrer lista em C# roda nas mesmas condições nos dois runtimes. Dessa maneira, as diferenças refletem o laço escolhido, e não o ambiente.

A classe de benchmark segue o mesmo padrão para todas as abordagens.

[MemoryDiagnoser]
public class LoopBenchmarks
{
    private List<int> _numbers = [];

    [Params(10, 1_000, 100_000)]
    public int Size { get; set; }

    [GlobalSetup]
    public void Setup() => _numbers = Enumerable.Range(1, Size).ToList();

    [Benchmark(Baseline = true)]
    public long For()
    {
        long sum = 0;
        for (var i = 0; i < _numbers.Count; i++)
            sum += _numbers[i];
        return sum;
    }

    [Benchmark]
    public long Foreach()
    {
        long sum = 0;
        foreach (var number in _numbers)
            sum += number;
        return sum;
    }

    [Benchmark]
    public long Span()
    {
        long sum = 0;
        foreach (var number in CollectionsMarshal.AsSpan(_numbers))
            sum += number;
        return sum;
    }
}

A classe completa também mede List.ForEach e Index(). No teste com Index(), o índice entra na soma para impedir que o JIT descarte o valor.

Gráfico de barras com o tempo para percorrer lista em C# com 100 mil itens: AsSpan 27 µs, for e foreach 35 µs, List.ForEach 52 µs e Index 196 µs no .NET 10 e no .NET 11
Tempo médio de cada abordagem com 100.000 itens; barras menores indicam laços mais rápidos.

A tabela abaixo reúne os resultados para os três tamanhos.

Método Itens .NET 10 .NET 11 RC 1 Alocado
for 10 2,93 ns 4,47 ns 0
foreach 10 3,20 ns 6,51 ns 0
List.ForEach 10 6,56 ns 7,47 ns 24 B
Index() 10 30,75 ns 30,52 ns 104 B
AsSpan 10 2,88 ns 2,96 ns 0
for 1.000 353,6 ns 355,4 ns 0
foreach 1.000 351,0 ns 351,8 ns 0
List.ForEach 1.000 520,3 ns 521,4 ns 24 B
Index() 1.000 1.949,0 ns 2.010,8 ns 104 B
AsSpan 1.000 262,8 ns 262,5 ns 0
for 100.000 34,70 µs 35,49 µs 0
foreach 100.000 35,18 µs 34,74 µs 0
List.ForEach 100.000 51,93 µs 51,77 µs 24 B
Index() 100.000 196,39 µs 194,84 µs 104 B
AsSpan 100.000 27,27 µs 27,19 µs 0

O que os números mostram

O for e o foreach empatam. Com 1.000 e 100.000 itens, a diferença entre eles fica abaixo de 2% nos dois runtimes.

Isso acontece porque o JIT otimiza o foreach sobre um List<T> tão bem quanto o for. Logo, a escolha entre os dois deve seguir a legibilidade.

O AsSpan é o mais rápido. Ele roda de 21% a 26% mais rápido que o for a partir de 1.000 itens, porque acessa o array interno e evita as verificações de limite do indexador.

O List.ForEach é cerca de 1,5 vez mais lento. Cada item custa uma chamada de delegate e, como a lambda captura a variável sum, cada chamada aloca uma closure de 24 bytes.

O Index() é cerca de 5,6 vezes mais lento e aloca 104 bytes. Ele passa pelo iterador do LINQ em vez do enumerador struct da lista, então convém evitá-lo em laços críticos.

Por fim, o .NET 11 se comporta como o .NET 10. Com 1.000 e 100.000 itens, toda abordagem fica a menos de 4% do tempo anterior.

Com 10 itens, o for e o foreach mediram alguns nanossegundos a mais no RC 1, mas a diferença some a partir de 1.000 itens. As melhorias do JIT do .NET 11 atingem padrões como i + 1 < length e values[^1], que esse benchmark não usa.

Como escolher a forma de percorrer lista em C#

Depois de comparar todas as opções, a decisão para percorrer lista em C# segue um roteiro simples.

  • foreach para ler todos os itens, como escolha padrão.
  • for quando você precisa do índice ou vai substituir itens.
  • Index() quando quer a leitura do foreach junto com o índice, a partir do .NET 9.
  • RemoveAll ou um for reverso para remover itens.
  • CollectionsMarshal.AsSpan para caminhos críticos em que você controla a lista.
  • await foreach para IAsyncEnumerable<T> e Parallel.ForEachAsync para trabalho concorrente.

Vale lembrar que as listas também evoluem na sintaxe de criação. O artigo sobre Collection Expression Arguments no C# 15 mostra como definir a capacidade inicial de um List<T> com collection expressions, o que evita realocações antes mesmo do laço.

Em resumo, a forma mais legível costuma ser também a mais rápida para percorrer lista em C#. O ganho real de desempenho aparece apenas com Span<T>, e ele exige disciplina no controle da lista.

FAQ

Como percorrer uma lista em C#?

Use foreach (var item in list) { } como padrão. Troque para um for quando precisar do índice ou quiser substituir itens.

Como sair de um foreach em C#?

Use break para encerrar o laço ou continue para pular para o próximo item. Nenhum dos dois funciona dentro de List<T>.ForEach.

O for é mais rápido que o foreach em C#?

Em um List<T>, os dois têm o mesmo desempenho, com diferença abaixo de 2% no benchmark. Para cerca de 20% a mais de velocidade, use CollectionsMarshal.AsSpan.

Como obter o índice em um foreach?

No .NET 9 ou superior, use list.Index(). Em versões anteriores, use list.Select((item, index) => (item, index)).

Como resolver o erro "Collection was modified"?

Evite adicionar ou remover itens dentro do foreach. Use RemoveAll, um for reverso com RemoveAt ou itere sobre uma cópia criada com ToList().

Arrays são percorridos da mesma forma?

Sim, o foreach funciona sem mudanças. No for, troque list.Count por array.Length.