Foreach i inicjalizacja zmiennej

11

Czy istnieje różnica między tymi dwiema wersjami kodu?

foreach (var thing in things)
{
    int i = thing.number;
    // code using 'i'
    // pay no attention to the uselessness of 'i'
}

int i;
foreach (var thing in things)
{
    i = thing.number;
    // code using 'i'
}

Czy to nie obchodzi kompilatora? Kiedy mówię o różnicy, mam na myśli wydajność i zużycie pamięci. ..A właściwie po prostu jakakolwiek różnica, czy może oba kończą się tym samym kodem po kompilacji?

Alternatex
źródło
6
Czy próbowałeś skompilować te dwa i spojrzeć na wyjście kodu bajtowego?
4
@MichaelT Nie wydaje mi się, żebym kwalifikował się do porównywania danych wyjściowych kodu bajtowego. Jeśli znajdę różnicę, nie jestem pewien, czy będę w stanie zrozumieć, co to dokładnie znaczy.
Alternatex
4
Jeśli jest taki sam, nie musisz mieć kwalifikacji.
1
@MichaelT Chociaż musisz mieć wystarczające kwalifikacje, aby dobrze się domyślić, czy kompilator mógł go zoptymalizować, a jeśli tak, to w jakich warunkach jest w stanie przeprowadzić tę optymalizację.
Ben Aaronson
@BenAaronson i to prawdopodobnie wymaga nietrywialnego przykładu, aby łaskotać tę funkcjonalność.

Odpowiedzi:

22

TL; DR - są to równoważne przykłady na warstwie IL.


DotNetFiddle sprawia, że ​​odpowiedź jest ładna, ponieważ pozwala zobaczyć wynikową IL.

Użyłem nieco innej odmiany twojej konstrukcji pętli, aby przyspieszyć testowanie. Użyłem:

Wariant 1:

using System;

public class Program
{
    public static void Main()
    {
        Console.WriteLine("Hello World");
        int x;
        int i;

        for(x=0; x<=2; x++)
        {
            i = x;
            Console.WriteLine(i);
        }
    }
}

Wariant 2:

        Console.WriteLine("Hello World");
        int x;

        for(x=0; x<=2; x++)
        {
            int i = x;
            Console.WriteLine(i);
        }

W obu przypadkach skompilowane wyjście IL renderowało to samo.

.class public auto ansi beforefieldinit Program
       extends [mscorlib]System.Object
{
  .method public hidebysig static void  Main() cil managed
  {
    // 
    .maxstack  2
    .locals init (int32 V_0,
             int32 V_1,
             bool V_2)
    IL_0000:  nop
    IL_0001:  ldstr      "Hello World"
    IL_0006:  call       void [mscorlib]System.Console::WriteLine(string)
    IL_000b:  nop
    IL_000c:  ldc.i4.0
    IL_000d:  stloc.0
    IL_000e:  br.s       IL_001f

    IL_0010:  nop
    IL_0011:  ldloc.0
    IL_0012:  stloc.1
    IL_0013:  ldloc.1
    IL_0014:  call       void [mscorlib]System.Console::WriteLine(int32)
    IL_0019:  nop
    IL_001a:  nop
    IL_001b:  ldloc.0
    IL_001c:  ldc.i4.1
    IL_001d:  add
    IL_001e:  stloc.0
    IL_001f:  ldloc.0
    IL_0020:  ldc.i4.2
    IL_0021:  cgt
    IL_0023:  ldc.i4.0
    IL_0024:  ceq
    IL_0026:  stloc.2
    IL_0027:  ldloc.2
    IL_0028:  brtrue.s   IL_0010

    IL_002a:  ret
  } // end of method Program::Main

Aby odpowiedzieć na twoje pytanie: kompilator optymalizuje deklarację zmiennej i równoważy te dwie odmiany.

O ile mi wiadomo, kompilator .NET IL przenosi wszystkie deklaracje zmiennych na początek funkcji, ale nie mogłem znaleźć dobrego źródła, które wyraźnie stwierdziłoby, że 2 . W tym konkretnym przykładzie widać, że przesunęło to ich o następującą instrukcję:

    .locals init (int32 V_0,
             int32 V_1,
             bool V_2)

W tym przypadku stajemy się zbyt obsesyjni w dokonywaniu porównań ...

Przypadek A: czy wszystkie zmienne są przenoszone w górę?

Aby zagłębić się w to, przetestowałem następującą funkcję:

public static void Main()
{
    Console.WriteLine("Hello World");
    int x=5;

    if (x % 2==0) 
    { 
        int i = x; 
        Console.WriteLine(i); 
    }
    else 
    { 
        string j = x.ToString(); 
        Console.WriteLine(j); 
    } 
}

Różnica polega na tym, że możemy zadeklarować OSOBĄ int ilub string jw oparciu o porównanie. Ponownie kompilator przenosi wszystkie zmienne lokalne na szczyt funkcji 2 za pomocą:

.locals init (int32 V_0,
         int32 V_1,
         string V_2,
         bool V_3)

Zauważyłem, że warto zauważyć, że chociaż int inie zostanie zadeklarowany w tym przykładzie, kod do jego obsługi jest generowany.

Przypadek B: A może foreachzamiast for?

Wskazano, że foreachma inne zachowanie fori że nie sprawdzałem tego, o co pytano. Wstawiłem więc te dwie sekcje kodu, aby porównać wynikową IL.

int deklaracja poza pętlą:

    Console.WriteLine("Hello World");
    List<int> things = new List<int>(){1, 2, 3, 4, 5};
    int i;

    foreach(var thing in things)
    {
        i = thing;
        Console.WriteLine(i);
    }

int deklaracja wewnątrz pętli:

    Console.WriteLine("Hello World");
    List<int> things = new List<int>(){1, 2, 3, 4, 5};

    foreach(var thing in things)
    {
        int i = thing;
        Console.WriteLine(i);
    }

Powstała IL z foreachpętlą rzeczywiście różniła się od IL wygenerowanej za pomocą forpętli. W szczególności zmieniono blok inicjujący i sekcję pętli.

.locals init (class [mscorlib]System.Collections.Generic.List`1<int32> V_0,
         int32 V_1,
         int32 V_2,
         class [mscorlib]System.Collections.Generic.List`1<int32> V_3,
         valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32> V_4,
         bool V_5)
...
.try
{
  IL_0045:  br.s       IL_005a

  IL_0047:  ldloca.s   V_4
  IL_0049:  call       instance !0 valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()
  IL_004e:  stloc.1
  IL_004f:  nop
  IL_0050:  ldloc.1
  IL_0051:  stloc.2
  IL_0052:  ldloc.2
  IL_0053:  call       void [mscorlib]System.Console::WriteLine(int32)
  IL_0058:  nop
  IL_0059:  nop
  IL_005a:  ldloca.s   V_4
  IL_005c:  call       instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()
  IL_0061:  stloc.s    V_5
  IL_0063:  ldloc.s    V_5
  IL_0065:  brtrue.s   IL_0047

  IL_0067:  leave.s    IL_0078

}  // end .try
finally
{
  IL_0069:  ldloca.s   V_4
  IL_006b:  constrained. valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>
  IL_0071:  callvirt   instance void [mscorlib]System.IDisposable::Dispose()
  IL_0076:  nop
  IL_0077:  endfinally
}  // end handler

foreachPodejście bardziej zmienne generowane lokalnych i wymaga pewnych dodatkowych rozgałęzień. Zasadniczo za pierwszym razem przeskakuje na koniec pętli, aby uzyskać pierwszą iterację wyliczenia, a następnie przeskakuje z powrotem na prawie szczyt pętli, aby wykonać kod pętli. Następnie przechodzi przez pętlę, jak można się spodziewać.

Ale poza różnicami rozgałęziających spowodowane użyciem fori foreachkonstrukcje, nie było bez różnicy w IL oparciu którym int izgłoszenie zostało złożone. Więc nadal jesteśmy przy dwóch podejściach równoważnych.

Przypadek C: Co z różnymi wersjami kompilatora?

W komentarzu, który pozostawiono 1 , był link do pytania SO dotyczącego ostrzeżenia o zmiennym dostępie z foreach i zamykaniem . Część, która naprawdę przykuła moją uwagę w tym pytaniu, polegała na tym, że mogły istnieć różnice w działaniu kompilatora .NET 4.5 w porównaniu z wcześniejszymi wersjami kompilatora.

I właśnie tam zawiodła mnie witryna DotNetFiddler - mieli tylko .NET 4.5 i wersję kompilatora Roslyn. Więc przywołałem lokalną instancję Visual Studio i zacząłem testować kod. Aby upewnić się, że porównuję te same rzeczy, porównałem lokalnie zbudowany kod w .NET 4.5 z kodem DotNetFiddler.

Jedyną różnicą, którą zauważyłem, był lokalny blok init i deklaracja zmiennej. Lokalny kompilator był nieco bardziej szczegółowy w nazywaniu zmiennych.

  .locals init ([0] class [mscorlib]System.Collections.Generic.List`1<int32> things,
           [1] int32 thing,
           [2] int32 i,
           [3] class [mscorlib]System.Collections.Generic.List`1<int32> '<>g__initLocal0',
           [4] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32> CS$5$0000,
           [5] bool CS$4$0001)

Ale z tą niewielką różnicą było to do tej pory tak dobre. Miałem równoważne wyjście IL między kompilatorem DotNetFiddler a tym, co produkowała moja lokalna instancja VS.

Więc następnie przebudowałem projekt ukierunkowany na .NET 4, .NET 3.5 i dla pewności tryb wydania .NET 3.5.

I we wszystkich tych trzech dodatkowych przypadkach wygenerowana IL była równoważna. Wybrana wersja .NET nie miała wpływu na IL wygenerowaną w tych próbkach.


Podsumowując tę ​​przygodę: Myślę, że możemy śmiało powiedzieć, że kompilator nie dba o to, gdzie deklarujesz typ prymitywny i że nie ma to żadnego wpływu na pamięć ani wydajność żadnej z metod deklaracji. I to obowiązuje niezależnie od użycia pętli forlub foreach.

Zastanawiałem się nad uruchomieniem kolejnej sprawy, która zawiera zamknięcie wewnątrz foreachpętli. Ale zapytałeś o skutki, gdzie zadeklarowano zmienną typu pierwotnego, więc pomyślałem, że sięgam zbyt daleko poza to, o co chciałeś zapytać. Pytanie SO, o którym wspomniałem wcześniej, ma świetną odpowiedź, która zapewnia dobry przegląd efektów zamknięcia na zmiennych iteracji foreach.

1 Dziękujemy Andy'emu za dostarczenie oryginalnego linku do pytania SO dotyczącego zamykania foreachpętli.

2 Warto zauważyć, że specyfikacja ECMA-335 rozwiązuje ten problem w sekcji I.12.3.2.2 „Zmienne lokalne i argumenty”. Musiałem zobaczyć wynikową IL, a następnie przeczytać sekcję, aby było jasne, co się dzieje. Dzięki grzechotnikowi za wskazanie tego na czacie.

Społeczność
źródło
1
For i foreach nie zachowują się tak samo, a pytanie zawiera kod, który jest inny, co staje się ważne, gdy nastąpi zamknięcie w pętli. stackoverflow.com/questions/14907987/...
Andy
1
@Andy - dzięki za link! Poszedłem dalej i sprawdziłem wygenerowane dane wyjściowe za pomocą foreachpętli, a także sprawdziłem docelową wersję .NET.
0

W zależności od używanego kompilatora (nawet nie wiem, czy C # ma więcej niż jeden), kod zostanie zoptymalizowany przed przekształceniem w program. Dobry kompilator zobaczy, że za każdym razem ponownie inicjujesz tę samą zmienną z inną wartością i efektywnie zarządzasz dla niej pamięcią.

Jeśli za każdym razem inicjalizujesz tę samą zmienną na stałą, kompilator również zainicjuje ją przed pętlą i odniesie do niej.

Wszystko zależy od tego, jak dobrze napisany jest Twój kompilator, ale jeśli chodzi o standardy kodowania, zmienne powinny zawsze mieć możliwie najmniejszy zakres. Więc deklarowanie wewnątrz pętli jest tym, czego zawsze mnie uczono.

Leylandski
źródło
3
To, czy ostatni akapit jest prawdziwy, czy nie, zależy od dwóch rzeczy: ważności minimalizacji zakresu zmiennej w unikalnym kontekście własnego programu oraz wewnętrznej wiedzy kompilatora, czy faktycznie optymalizuje wiele przypisań.
Robert Harvey
A potem jest środowisko wykonawcze, które dodatkowo tłumaczy kod bajtowy na język maszynowy, gdzie wykonuje się wiele takich samych optymalizacji (omawianych tutaj jako optymalizacje kompilatora).
Erik Eidt,
-2

na początku deklarujesz i inicjujesz pętlę wewnętrzną, więc za każdym razem pętla będzie ponownie inicjowana „i” pętli wewnętrznej. W drugiej chwili deklarujesz tylko poza pętlą.

użytkownik304046
źródło
1
wydaje się, że nie oferuje to nic istotnego w porównaniu z punktami i wyjaśnieniami w najwyższej odpowiedzi, która została opublikowana ponad 2 lata temu
komnata
2
Dziękujemy za udzielenie odpowiedzi, ale nie zawiera ona żadnych nowych aspektów , które nie zostały już zaakceptowane, najwyżej ocenione odpowiedzi (szczegółowo).
CharonX