Olá, pessoal!
Daremos continuidade ao artigo sobre IEnumerable em C#. Dessa vez veremos os efeitos colaterais de usar essa interface de maneira errada.
Caso não faça ideia do que eu estou falando, você pode conferir a primeira parte do artigo sobre o IEnumerable:
A leitura do primeiro artigo não é obrigatória para você entender este, mas recomendo, pra que você possa compreender melhor como o IEnumerable funciona.
Neste artigo veremos as diferenças de comportamento entre um IEnumerable e um List
. Parece simples, não?
Começaremos com o seguinte exemplo de código:
IEnumerable<int> colecao = new int[] { 1, 3, 5, 6, 7 };
int[] array = colecao.Where(value => value % 2 == 0)
.Select(value => value * value)
.ToArray();
Este é um código bastante simples – você provavelmente já deve ter visto algo parecido.
Mas você saberia me dizer como este código realmente é executado? Quantos elementos passam pelo Where? E pelo Select
?
Se o código fosse executado de maneira “tradicional”, como são duas chamadas distintas, o algoritmo percorreria toda a coleção removendo os elementos que não passam pelo filtro e depois disso elevando os elementos que sobraram ao quadrado.
Neste caso, esse algoritmo teria complexidade O(N) + O(M), onde N é o tamanho do array antes do filtro, e M é o tamanho do array depois do filtro.
No entanto, a complexidade real deste algoritmo é de apenas O(N). Isso porque as operações acontecem uma após a outra durante o mesmo loop – o que é bastante interessante, mas pode causar alguns efeitos comportamentais que, se não forem antecipados, podem dar uma boa dor de cabeça.
Vamos começar fazendo o exemplo utilizando uma lista, onde as coisas são executadas de maneira “tradicional”. Para começar, criaremos o método que gera a lista. Esse método fará um loop de zero até nove, adicionando o número na lista:
List<int> GenerateList()
{
List<int> list = new List<int>();
for (int index = 0; index < 10; index++)
{
Console.WriteLine($"The value {index} has been added.");
list.Add(index);
}
return list;
}
Colocaremos mensagens no console na interação com cada elemento. Dessa forma, facilitamos a visualização do que está ocorrendo durante a execução.
No método Main faremos algumas operações com essa lista. Primeiro vamos simplesmente fazer um loop utilizando o foreach:
static void Main(string[] args)
{
List<int> list = GenerateList();
Console.WriteLine("");
Console.WriteLine("List has been created");
foreach (var value in list)
{
Console.WriteLine($"The value {value} has been found.");
}
Console.ReadKey();
}
Agora continuaremos fazendo mais três operações:
- 1. Mostrar o resultado da propriedade Count
- 2. Mostrar o resultado do método Any
- 3. Executar o método ToArray
//...
Console.WriteLine("");
Console.WriteLine($"Count: {list.Count}");
Console.WriteLine("");
Console.WriteLine($"Any: {list.Any()}");
Console.WriteLine("");
int[] array = list.ToArray();
//...
Vamos executar e ver o resultado dessas operações:
Até aqui nenhuma grande surpresa: as coisas são executadas na ordem em que foram declaradas, primeiro quando cada elemento é adicionado na lista, depois no foreach
, Count
e, por fim, no Any
.
Faremos a mesma coisa, mas desta vez vamos trabalhar com um IEnumerable
ao invés de uma lista.
Começaremos com o método para gerar o IEnumerable
. Dessa vez, usando a palavra reservada yield
:
IEnumerable<int> GenerateIEnumerable()
{
for (int index = 0; index < 10; index++)
{
Console.WriteLine($"The value {index} has been added.");
yield return index;
}
}
O método em sua essência é o mesmo: gera uma coleção exibindo uma mensagem ao passar por cada elemento.
Todo o corpo do método principal não precisa ser alterado. Basta alterarmos o tipo da lista para um IEnumerable
e apontar a chamada para criação da coleção para o novo método:
static void Main(string[] args)
{
IEnumerable<int> ienumerable = GenerateIEnumerable();
//...
}
Antes de executar, você consegue prever o resultado? Talvez se assuste um pouco.
Mas vamos lá:
Se você se surpreendeu com o resultado, tenho más notícias. Talvez você esteja piorando seu software sem nem se dar conta disso.
Note que foram feitas muitas operações a mais do que no caso da lista. Isso significa que usar a lista é melhor que usar um IEnumerable
? De forma alguma!
Simplesmente estávamos trabalhando com o IEnumerable
como se fosse uma lista, o que claramente não é o caso. Vamos a um passo a passo para entender o que houve.
Note que a mensagem notificando que o IEnumerable
foi gerado foi exibida antes das mensagens que notificam que cada elemento foi adicionado na coleção. Por que?
Isso ocorre devido ao fato de o IEnumerable
possuir uma característica de avaliação lazy, ou seja, o valor de um IEnumerable
não está armazenado verdadeiramente na estrutura – ele só será computado quando precisarmos dele.
Esse é a razão dele conseguir realizar as operações no mesmo loop, o que pode causar um imenso ganho de performance (ou perda, quando usado de forma errada).
Note que quando começamos a iterar cada elemento no foreach ele é computado individualmente, ou seja, o nosso yield return é executado de um elemento, e ainda nesta iteração, a operação interna do foreach já é executada sobre o mesmo elemento.
No caso da lista, criar a lista e percorrê-la possuía complexidade O(N²), enquanto no caso do IEnumerable
realizamos estas duas operações em O(N). Legal, né?
Depois disso vimos que a lista é gerada novamente quando utilizamos o Count
, mas por que diabos isso acontece?
Você sempre precisa lembrar que o IEnumerable
não contém os dados de verdade, ele precisa computá-los – então para contar quantos elementos existem na lista ele irá percorrê-la até o fim.
No caso da lista, já sabíamos quantos elementos ela continha. Por conta disso, Count
é uma propriedade dentro da lista e um método no IEnumerable
. A diferença em termos de execução aqui também é gritante.
No caso da lista temos a complexidade O(1). Afinal, basta checarmos uma propriedade que já contém esse valor armazenado, enquanto no IEnumerable
temos a complexidade O(N).
Imagine que o Count do IEnumerable
seja um método similar a este:
int Count<T>(IEnumerable<T> source)
{
int count = 0;
using (IEnumerator<T> enumerator = source.GetEnumerator())
{
while (enumerator.MoveNext())
{
count++;
}
}
return count;
}
Talvez agora pareça um pouco mais doloroso aquele seu Count()
> 0 que eu sei que você já fez. O que nos leva ao próximo método utilizado no exemplo: Any()
.
Sempre que você precisar verificar: Count() > 0 em um IEnumerable, por favor, substitua-o por um Any(), não é de brincadeira que existem dois métodos.
Se você notar na saída do console, o método Any() exibiu apenas a mensagem do primeiro elemento sendo adicionado. Por que?
Simples, se houver qualquer elemento na coleção, ele já deve retornar true, então não faz sentido percorrer a coleção inteira, o que nos deixa sempre com a complexidade O(1).
O método Any é um método similar ao método abaixo:
bool Any<T>(IEnumerable<T> source)
{
using (IEnumerator<T> enumerator = source.GetEnumerator())
{
return enumerator.MoveNext();
}
}
Por fim, ao realizarmos o ToArray()
, notamos que o IEnumerable
é computado mais uma vez. Ou seja, tome muito cuidado com suas transformações de coleções – você pode estar gerando computação extra por nada.
Mas, afinal, qual a melhor solução?
Neste caso, o correto seria transformarmos o IEnumerable
em uma lista ou em um array no momento certo. Ou seja, temos que garantir que os dois loops sejam executados em conjunto, e que as operações seguintes (Count e Any) possam ser executadas em O(1).
Como fazer?
Teremos que realizar alguns ajustes. Isso porque o loop foreach forçará a avaliação do IEnumerable
se ele ficar neste escopo. Precisamos mudá-lo para fazer com que ele continue o comportamento lazy do IEnumerable
.
Aqui temos dois caminhos distintos:
- 1. Utilizar o Select ao invés de um foreach
- 2. Transformar essa iteração em um método separado
Primeiro faremos a implementação utilizando o método Select
. Para isso, faremos a chamada da criação do IEnumerable
normalmente, e depois vamos executar um Select seguido de um ToList
.
A partir daí salvaremos o valor em uma lista para podermos executar as próximas operações sem percorrer todos os elementos, conforme o código:
static void Main(string[] args)
{
IEnumerable<int> ienumerable = GenerateIEnumerable();
Console.WriteLine("");
Console.WriteLine("IEnumerable has been created");
List<int> list = ienumerable
.Select(value =>
{
Console.WriteLine($"The value {value} has been found.");
return value;
})
.ToList();
Console.WriteLine("");
Console.WriteLine($"Count: {list.Count}");
Console.WriteLine("");
Console.WriteLine($"Any: {list.Any()}");
Console.ReadKey();
}
Atenção
- Vale lembrar que isso é apenas para exemplificar. O método Select não deve causar nenhum tipo de efeito colateral, como uma saída no console.
Dessa forma, nossa implementação consegue utilizar o melhor dos dois mundos, usando cada tipo no seu melhor caso.
A segunda opção de implementação é transformar o loop em um método, retornando uma nova coleção através do yield return, conforme o código abaixo:
static IEnumerable<T> FindElements<T>(IEnumerable<T> source)
{
foreach (T value in source)
{
Console.WriteLine($"The value {value} has been found.");
yield return value;
}
}
Agora precisamos chamar essa nova função antes de transformar o IEnumerable
em uma lista:
static void Main(string[] args)
{
IEnumerable<int> ienumerable = GenerateIEnumerable();
Console.WriteLine("");
Console.WriteLine("IEnumerable has been created");
ienumerable = FindElements(ienumerable);
List<int> list = ienumerable.ToList();
Console.WriteLine("");
Console.WriteLine($"Count: {list.Count}");
Console.WriteLine("");
Console.WriteLine($"Any: {list.Any()}");
Console.ReadKey();
}
Com isso, temos o resultado esperado:
Atenção
- Usei a estrutura List para exemplificar, mas sempre que você não precisar alterar o tamanho da coleção, prefira utilizar um array.
A lição principal deste artigo, é: utilize as coisas com sabedoria. Nem sempre o seu ToList()
está errado, e às vezes, manter tudo como IEnumerable
custa bem caro.
O ideal é entender até onde vale manter a estrutura como um IEnumerable
e realizar a computação para uma estrutura em memória quando precisar.
Gostou desse tipo de artigo? Me conte nos comentários.
Até mais!