Olá pessoal! Tivemos um problema esta semana, em tese de origem simples, mas que nos tomou um tempo enorme de debug. Quero compartilhar aqui para tentar evitar que outros sigam pelo mesmo caminho!
Para começar, imaginem este código ilustrativo abaixo. Uma rotina simples para enumerar duas vezes por uma coleção e retornar a mesma, modificada. Claro que isto é somente representativo de um código real, mas imaginem qualquer função que por alguma razão modifica elementos de uma lista e retorna a mesma ou um subconjunto da mesma. Trivial, certo? O resultado esperado desta execução são duas linhas com o número “2”.
class MinhaClase { public decimal Valor { get; set; } } class Program { static IList<MinhaClase> Calcular(IList<MinhaClase> classes) { foreach (var minhaClase in classes) { minhaClase.Valor = 1; } foreach (var minhaClase in classes) { minhaClase.Valor++; } return classes; } static void Main(string[] args) { var classes = new[] { new MinhaClase { Valor = 0}, new MinhaClase { Valor = 0}, new MinhaClase { Valor = 1} }; var result = Calcular(classes.Where(c => c.Valor != 1).ToList()); foreach (var minhaClase in result) { Console.WriteLine(minhaClase.Valor.ToString()); } Console.ReadLine(); } }
Todo o problema começa quando a gente tenta refatorar e melhorar o código. Como todo bom desenvolvedor, nós usamos o Resharper, que é uma ferramenta fantástica da JetBrains para nos ajudar a gerar um bom código. Na White Fox, nós a usamos praticamente todo o tempo, ela nos poupa muito esforço de código e ajuda a evitar problemas graves de maneira completamente automática. Porém, neste caso, como a rotina “Calcular” simplesmente enumera, o Resharper vai sugerir que a gente mude o IList<> para sua classe base, um IEnumerable<>. A regra seguida por ele faz sentido pois se só estamos usando métodos da classe base, não seria necessário usar a classe derivada na assinatura. O código gerado então após a refatoração fica da seguinte forma:
class MinhaClase { public decimal Valor { get; set; } } class Program { static IEnumerable<MinhaClase> Calcular(IEnumerable<MinhaClase> classes) { foreach (var minhaClase in classes) { minhaClase.Valor = 1; } foreach (var minhaClase in classes) { minhaClase.Valor++; } return classes; } static void Main(string[] args) { var classes = new[] { new MinhaClase { Valor = 0}, new MinhaClase { Valor = 0}, new MinhaClase { Valor = 1} }; var result = Calcular(classes.Where(c => c.Valor != 1)); foreach (var minhaClase in result) { Console.WriteLine(minhaClase.Valor.ToString()); } Console.ReadLine(); } }
Depois deste refactor, o resultado do programa não deveria mudar, certo? Errado! Os mais experientes irão saber que o resultado da execução deste segundo código é uma lista vazia!
A explicação para isto tem a ver com o suporte do C# para closures (ver este post, que explica o que são de maneira simples, ou esta explicação mais acadêmica aqui). As operações LINQ simplesmente geram predicados para uma closure que é executada no momento que a coleção é acessada. Tem a ver também com a diferença básica entre um IList<> e um IEnumerable<>, já que num IList<>, um GetEnumerator (executado em um foreach) sempre aponta pra coleção interna, já existente… Já em um IEnumerable<>, o GetEnumerator vai sempre acionar o container para criar coleção e aí enumerar os elementos… Isto significa que a coleção de um IEnumerable<> produzido por uma closure vai ser gerada A CADA foreach, e não uma única vez. Ou seja, cada foreach da rotina “Calcular” executa a closure, bem como o foreach do resultado final. Como neste caso a closure é um filtro de Valor != 1, no segundo foreach da rotina “Calcular” e no último (onde ele imprime o resultado no console) ele simplesmente vai retornar 0 elementos!
Onde erramos? Os mais experientes vão notar que erramos na hora que tiramos o .ToList() do final da closure que define o filtro. A razão da remoção foi que como a rotina “Calcular” agora aceita um IEnumerable<>, o ToList() não seria mais necessário. Sem o ToList(), a closure não é mais executada naquele momento e sim passada para a variável e aí toda a rotina Calcular para de funcionar. Basta colocar o .ToList() de volta que tudo passa a funcionar novamente. Este erro, aparentemente óbvio, na prática é muito fácil de ser cometido, pois o uso de expressões LINQ é cada vez mais comum e, em um código complexo de negócios, se a função sendo chamada tem parametros IEnumerable<>, uma closure pode acabar sendo enviada inadivertidamente.
Portanto, neste caso, o Resharper não só facilita os erros, como pode ainda produzir alguns muito difíceis de identificar (no nosso casso, foram horas gastas até achar onde estava a closure sendo passada). Claro que desligamos esta sugestão na nossa configuração do Resharper e o problema foi resolvido. É importante ressaltar que, mesmo apesar deste caso, continuamos a achar o Resharper uma ferramenta essencial de trabalho para nós.
Outro ponto que merece ser comentado, é que pesquisando este problema, entendemos melhor o funcionamento do IEnumerable<> e o IList<>. Como o IEnumerable<> sempre tem que gerar a coleção a cada GetEnumerator(), podemos ter um desempenho pior das coleções, na maioria dos cenários, comparado a um IList<>. Só isto já seria motivo suficiente para favorecer o uso do IList<> ao invés do IEnumerable<>.
Bom, espero ter ajudado a poupar o tempo de outras pessoas ao deparar com problemas similares. Por causa deste problema, acabamos tendo que conhecer bem mais sobre closures e coleções no .NET, assim, no final, a existência deste bug até que foi benéfica!
Até a próxima!
#1 por Gustavo em 09/04/2011 - 9:45 pm
Tú é foda!!! hahahaha
Abraço
Gustavo
#2 por Christian Cunha em 11/04/2011 - 10:54 am
Demorou, mais achamos o problema, foi um tempo bem gasto 🙂 Excelente post!
#3 por Alexandre em 11/04/2011 - 4:33 pm
Problema interessante e agradeço por compartilhar sua experiência, mas, se me permite, não concordo com alguns pontos dos artigos:
1. Tanto IEnumerable<T> quanto IList<T> são interfaces. Por serem interfaces, eles não implantam seus métodos, somente definem suas assinaturas. Portanto, não é correto dizer que existem diferenças entre os métodos GetEnumerator de um IEnumerable<T> ou IList<T>, não faz sentido. Só para exemplificar, considere o trecho de código abaixo:
List<string> ls = new List<string>();
IEnumerable<string> e = ls;
IList<string> l = ls;
e.GetEnumerator();
l.GetEnumerator();
Se o que você disse fosse verdade, as chamadas
e.GetEnumerator()
e l.GetEnumerator() iriam executar métodos distintos, o que é incorreto. Ambas chamadas executam exatamente o mesmo método (no caso, List<string>.GetEnumerator).O desempenho desse método vai depender da implantação dos mesmos nas classes concretas, que nos seus exemplos são o List<decimal> e “filtro WHERE do Linq” (não sei o nome da classe concreta retornada pelo filtro Linq), respectivamente. O primeiro em geral terá desempenho melhor porque ele faz uma varredura simples em uma lista. No segundo caso, o método precisa executar a expressão do filtro para cada membro da coleção para montar a coleção resultante.
2. Não sei se esse exemplo é um bom caso para falar sobre closures. A diferença na execução dos laços foreach no método “Calcular” foi causada porque a coleção foi modificada entre a primeira e a segunda execução. Se, no seu primeiro exemplo, ao invés de alterar o valor de uma propriedade, você tivesse adicionado elementos na lista entre as duas execuções, o resultado do segundo foreach seria diferente do primeiro.
#4 por Alexandre Valente em 11/04/2011 - 4:48 pm
Olá Alexandre,
Obrigado pelo interesse! Sim, concordo com ambos os pontos. No caso que vc citou, vc já tem uma List, que é IEnumerable por herança, por isto as duas coisas são iguais. E um filtro LINQ retorna uma closure! :-).
Mas o objetivo do post foi mostrar que uma simples indicação de refactor de uma ferramenta usada pela maior parte do mercado pode gerar, sem querer, modificações profundas no seu código e novos (e chatos) bugs. O refactor no meu exemplo transformou uma List em uma closure, mudando o comportamento do IEnumerable de maneira não imediatamente óbvia e mudando o comportamento do programa. Isto pode causar dificuldades em um código mais complexo de produção até mesmo para desenvolvedores bem experientes.