Desenvolvimento

23 abr, 2019

Vamos entender o IEnumerable? – Parte 02

Publicidade

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!