Operador "is" do C#: não é reflection | Lambda3
por Giovanni Bassi | jan 27, 2016 | Microsoft | 6 Comentários
Estava trabalhando no CodeCracker e conversei com um colaborador do projeto (alias Vossekop), que havia mandado um Pull Request, sobre algumas mudanças que eu havia feito. Eu havia alterado umas partes do que ele havia submetido, substituindo expressões com o operador "is" por comparações de valores da propriedade "Kind" (ou derivadas, usando "IsKind"), que nas APIs do compilador do C# indicam o tipo do nó sintático que você está trabalhando. Minha intenção era clara, substituir uma operação de Reflection, extremamente cara, por uma simples comparação de valores. Eu estava errado, e o colaborador apontou esse fato.
Ele comentou que o "is" possui uma tradução para uma instrução na IL, e que tem um desempenho excelente. E é verdade. Fui investigar.
A instrução é a "isinst". Ela compara o objeto no topo da pilha com um tipo, e então coloca em cima da pilha um nulo se a conversão é inválida, ou o valor convertido, se o objeto for do tipo especificado. Se o operador do C# usado for o "is", em seguida o compilador emite uma comparação por nulo. O "isinst" também é usado para o operador "as" – o que é bem evidente – e nesse caso ele simplesmente armazena o resultado do topo da pilha na variável especificada. O "isinst", nesse sentido, parece muito com o operador "as" do C#, ainda que também seja usado pelo "is".
Testando o desempenho com C# Interativo
Fui fazer uma comparação mais bruta. Coloquei o resultado em um arquivo "csx", que é novidade no C#, somente disponível com o ferramental do Visual Studio 2015 Update 1. Arquivos csx podem ser executados diretamente pelo C# Interativo, o executável "csi". Se você executar "csi" sem nenhum parâmetro, você entra no REPL:
E você também pode executar um arquivo csx passando ele como parâmetro:
O arquivo que executei está disponível como um gist.
interface BaseBase { }
interface Base : BaseBase { }
class Foo : Base, IFoo { }
struct Bar : IFoo { }
struct Baz : IFoo { }
BaseBase foo = new Foo();
IFoo bar = new Bar();
var times = 10 * 1000 * 1000;
check = foo is Foo;
WriteLine($"is Class: {stopWatch.Elapsed.TotalMilliseconds:n}");
check = foo is Base;
WriteLine($"is Class (no match): {stopWatch.Elapsed.TotalMilliseconds:n}");
check = bar is Bar;
WriteLine($"is Struct: {stopWatch.Elapsed.TotalMilliseconds:n}");
check = bar is Baz;
WriteLine($"is Struct (no match): {stopWatch.Elapsed.TotalMilliseconds:n}");
check = foo is IFoo;
WriteLine($"is interface: {stopWatch.Elapsed.TotalMilliseconds:n}");
check = foo is ICollectData;
WriteLine($"is interface (no match): {stopWatch.Elapsed.TotalMilliseconds:n}");
node = foo as Foo;
WriteLine($"as Class: {stopWatch.Elapsed.TotalMilliseconds:n}");
o = foo as ICollectData;
WriteLine($"as interface (no match): {stopWatch.Elapsed.TotalMilliseconds:n}");
check = typeof(Foo).IsAssignableFrom(foo.GetType());
WriteLine($"IsAssignableFrom: {stopWatch.Elapsed.TotalMilliseconds:n}");
E aqui o resultado rodando em um Surface Pro 4 com um processador i5 de última geração:
is Class: 39.59
is Class (no match): 48.91
is Struct: 59.04
is Struct (no match): 56.03
is interface: 54.98
is interface (no match): 60.32
as Class: 49.14
as interface (no match): 71.54
IsAssignableFrom: 141.57
Note a diferença entre fazer a verificação com "is" ou "as" e fazê-la com Reflection, ficando entre o dobro e o triplo. Nos meus testes da API do Roslyn, a comparação do "is" com a propridade "Kind" no máximo empatou. No pior caso, ficou no dobro do tempo. E quando o objetivo era depois fazer uma atribuição a uma variável de novo tipo o tempo aumentava entre 50% a 200% a mais (um "if" usando "IsKind" seguido de uma atribuição com cast direto).
A IL gerada
Coloquei o código também em um projeto console e compilei. Queria olhar a IL gerada. Veja abaixo o primeiro "is", que está na linha 20, com highlight:
IL_0031:  isinst     Foo
IL_0037:  cgt.un
Linha a linha o que ele faz:
Linha IL_0030: carrega a variável foo na pilha.
Linha IL_0031: Verifica se a variável foo é to tipo Foo, com a chamada ao "isinst", colocando na pilha nulo, ou o resultado da conversão
Linha IL_0036: carrega nulo na pilha
Linha IL_0037: compara os 2 valores do topo da pilha
Linha IL_0039: Armazena o resultado na variável "check"
Exatamente o que eu esperava. Para ver o que cada instrução faz você pode olhar na Wikipedia. O ECMA-335, que documenta e padroniza a CLI, aprofunda, se você se interessar. Veja a seção III.4.6.
Para fazer isso, basta rodar o "ildasm" para o executável gerado, assim: "ildasm bin\Debug\Perf.exe". E então olhar o método "Main":
O impacto no desempenho utilizando reflection é grande. Mas tudo depende do que você vai fazer, e quantas vezes vai fazer. Microbenchmarks são perigosos, então temos que olhar pra eles com cuidado. A única conclusão que tiro de fato é que posso, e devo, usar e abusar de "is" e "as", sem me preocupar com desempenho. Um ponto importante, no entanto, é que o uso de "is" e "as" é um grande code smell. Use com moderação, já que ele pode indicar um problema no seu desenho de OO. No caso da API do C#, a inspeção dos tipos de nós pode ser feita dessa forma, é idiomático dessa API. Isso não quer dizer que deve ser usado em todo lugar.
E deixo uma brincadeira pra você: se você rodar o mesmo programa em modo de release, onde o compilador do C# vai otimizar a execução, o resultado muda muito, e a IL é completamente diferente. Por que isso acontece? Responda aqui nos comentários. E pra quem quiser experimentar, coloque o código em uma console application e rode como release em vez de debug.