Percebo que as pessoas costumam usar construções como esta:
var length = array.Length; for (int i = 0; i < length; i++) {
Eles acham que ter uma chamada para o Array.Length em cada iteração fará com que o CLR demore mais tempo para executar o código. Para evitá-lo, eles armazenam o valor do comprimento em uma variável local.
Vamos descobrir (de uma vez por todas!) Se isso é viável ou o uso de uma variável temporária é uma perda de tempo.
Para começar, vamos examinar estes métodos C #:
public int WithoutVariable() { int sum = 0; for (int i = 0; i < array.Length; i++) { sum += array[i]; } return sum; } public int WithVariable() { int sum = 0; int length = array.Length; for (int i = 0; i < length; i++) { sum += array[i]; } return sum; }
Aqui está como ele é processado pelo compilador JIT (para .NET Framework 4.7.2 em LegacyJIT-x86):
É trivial notar que eles têm exatamente o mesmo número de instruções do assembler - 15. Mesmo a lógica dessas instruções é quase a mesma. Há uma pequena diferença na ordem de inicialização das variáveis e comparações sobre se o ciclo deve continuar. Podemos observar que, em ambos os casos, o comprimento da matriz é registrado duas vezes antes do ciclo:
- Para verificar se há 0 (arrayLength)
- Na variável temporária para verificar a condição do ciclo (arrayLength2).
Acontece que ambos os métodos serão compilados exatamente no mesmo código, mas o primeiro é escrito mais rapidamente, mesmo que não haja nenhum benefício em termos de tempo de execução.
O código do assembler acima me levou a algumas reflexões e eu decidi verificar mais alguns métodos:
public int WithoutVariable() { int sum = 0; for(int i = 0; i < array.Length; i++) { sum += array[i] + array.Length; } return sum; } public int WithVariable() { int sum = 0; int length = array.Length; for(int i = 0; i < length; i++) { sum += array[i] + length; } return sum; }
Agora, o elemento atual e o comprimento da matriz estão sendo somados, mas no primeiro caso, o comprimento da matriz é solicitado sempre e, no segundo caso, é salvo uma vez em uma variável local. Vamos dar uma olhada no código do assembler desses métodos:
Mais uma vez, o número de instruções é o mesmo, assim como (quase) as próprias instruções. A única diferença é a ordem de inicialização das variáveis e a condição de verificação para a continuação do ciclo. Você pode observar que, no cálculo da soma, apenas o primeiro comprimento da matriz é levado em consideração. É óbvio que isso:
int arrayLength2 = localRefToArray . Comprimento ;
mov edi , dword ptr [ edx + 4 ]
if ( i> = arrayLength2 ) lança novo IndexOutOfRangeException ( ) ;
cmp ecx edi
jae 04b12d39
em todos os quatro métodos, há uma matriz inline que limita a verificação e é executada para cada elemento da matriz.
Já podemos chegar à primeira conclusão: usar uma variável extra para tentar acelerar o ciclo é uma perda de tempo, pois o compilador fará isso por você de qualquer maneira. O único motivo para armazenar uma matriz de comprimento em uma variável é tornar o código mais legível.
ForEach é outra situação completamente. Considere os três métodos a seguir:
public int ForEachWithoutLength() { int sum = 0; foreach (int i in array) { sum += i; } return sum; } public int ForEachWithLengthWithoutLocalVariable() { int sum = 0; foreach (int i in array) { sum += i + array.Length; } return sum; } public int ForEachWithLengthWithLocalVariable() { int sum = 0; int length = array.Length; foreach (int i in array) { sum += i + length; } return sum; }
E aqui está o código após o JIT:
ForEachWithoutLength ()int soma = 0;
xor esi , esi
int [] localRefToArray = this.array;
mov ecx , dword ptr [ ecx + 4 ]
int = 0;
xor edx , edx
; int arrayLength = localRefToArray.Length;
mov edi , dword ptr [ ecx + 4 ]
; if (arrayLength == 0) goto exit;
teste edi edi
saída jle
; int t = matriz [i];
loop :
mov eax , dword ptr [ ecx + edx * 4 + 8 ]
soma + = i;
adicione esi , eax
; i ++;
inc edx
; if (i <arrayLength) goto loop
cmp edi edx
jg loop
soma de retorno;
saída :
mov eax , esi
ForEachWithLengthWithoutLocalVariable ()int soma = 0;
xor esi , esi
int [] localRefToArray = this.array;
mov ecx , dword ptr [ ecx + 4 ]
int = 0;
xor edx , edx
; int arrayLength = localRefToArray.Length;
mov edi , dword ptr [ ecx + 4 ]
; if (arrayLength == 0) sair
teste edi edi
saída jle
; int t = matriz [i];
loop :
mov eax , dword ptr [ ecx + edx * 4 + 8 ]
soma + = i;
adicione esi , eax
; sum + = localRefToArray.Length;
adicione esi , dword ptr [ ecx + 4 ]
; i ++;
inc edx
; if (i <arrayLength) goto loop
cmp edi edx
jg loop
soma de retorno;
saída :
mov eax , esi
ForEachWithLengthWithLocalVariable ()int soma = 0;
xor esi , esi
int [] localRefToArray = this.array;
mov edx , dword ptr [ ecx + 4 ]
int comprimento = localRefToArray.Length;
mov ebx , dword ptr [ edx + 4 ]
int = 0;
xor ecx , ecx
; int arrayLength = localRefToArray.Length;
mov edi , dword ptr [ edx + 4 ]
; if (arrayLength == 0) goto exit;
teste edi edi
saída jle
; int t = matriz [i];
loop :
mov eax , dword ptr [ edx + ecx * 4 + 8 ]
soma + = i;
adicione esi , eax
soma + = comprimento;
adicione esi , ebx
; i ++;
inc ecx
; if (i <arrayLength) goto loop
cmp edi ecx
jg loop
soma de retorno;
saída :
mov eax , esi
A primeira coisa que vem à mente é que são necessárias menos instruções do assembler que o ciclo for (por exemplo, para somar elementos simples, foram necessárias 12 instruções no
foreach , mas 15 no
for ).
No geral, aqui estão os resultados do benchmark for
vs foreach para matrizes de 1 milhão de elementos:
sum+=array[i];
E para
sum+=array[i] + array.Length;
O ForEach percorre a matriz muito mais rapidamente do que
para . Porque Para descobrir, precisamos comparar o código após o JIT:
Comparação das três opções foreach Vejamos ForEachWithoutLength. O comprimento da matriz é solicitado apenas uma vez e não há verificações para os limites da matriz. Isso acontece porque o ciclo ForEach primeiro restringe a alteração da coleção dentro do ciclo e o segundo nunca sai da coleção. Por isso, o JIT pode se dar ao luxo de remover os limites da matriz de cheques.
Agora, vamos examinar cuidadosamente ForEachWithLengthWIthoutLocalVariable. Há apenas uma parte estranha, em que soma + = length não acontece com a variável local arrayLength salva anteriormente, mas com uma nova que o aplicativo solicita da memória a cada vez. Isso significa que haverá solicitações de memória N + 1 para o comprimento da matriz, em que N é um comprimento da matriz.
E agora chegamos ao ForEachWithLengthWithLocalVariable. O código é exatamente o mesmo que no exemplo anterior, exceto a manipulação do comprimento da matriz. O compilador mais uma vez gerou uma variável local arrayLength que é usada para verificar se a matriz está vazia, mas o compilador ainda salvou honestamente nosso comprimento de variável local declarado, e é isso que é usado na soma dentro do ciclo. Acontece que esse método solicita o comprimento da matriz da memória apenas duas vezes. A diferença é muito difícil de notar no mundo real.
Em todos os casos, o código do assembler ficou tão simples porque os métodos são simples. Se os métodos tivessem mais parâmetros, ele teria que trabalhar com a pilha, as variáveis poderiam obter lojas fora dos registradores, haveria mais verificações, mas a lógica principal permaneceria a mesma: introduzir uma variável local para o comprimento da matriz é apenas útil para criar código mais legível. Também
ocorreu que o
Foreach geralmente percorre a matriz mais rapidamente que o
For .