Bucle Foreach e inicialización variable

11

¿Hay alguna diferencia entre estas dos versiones de código?

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'
}

¿O al compilador no le importa? Cuando hablo de la diferencia me refiero en términos de rendimiento y uso de memoria. ¿O básicamente cualquier diferencia o los dos terminan siendo el mismo código después de la compilación?

Alternatex
fuente
66
¿Has intentado compilar los dos y mirar la salida de bytecode?
44
@MichaelT No creo que esté calificado para comparar la salida de bytecode. Si encuentro una diferencia, no estoy seguro de poder entender lo que significa exactamente.
Alternatex
44
Si es lo mismo, no necesita estar calificado.
1
@MichaelT Aunque necesita estar lo suficientemente calificado como para adivinar si el compilador podría haberlo optimizado, y si es así, bajo qué condiciones puede hacer esa optimización.
Ben Aaronson
@BenAaronson y eso probablemente requiera un ejemplo no trivial para hacerle cosquillas a esa funcionalidad.

Respuestas:

22

TL; DR : son ejemplos equivalentes en la capa IL.


DotNetFiddle hace que esto sea bonito de responder ya que le permite ver la IL resultante.

Utilicé una variación ligeramente diferente de su construcción de bucle para acelerar mis pruebas. Solía:

Variación 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);
        }
    }
}

Variación 2:

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

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

En ambos casos, la salida IL compilada hizo lo mismo.

.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

Entonces, para responder a su pregunta: el compilador optimiza la declaración de la variable y hace que las dos variaciones sean equivalentes.

Según tengo entendido, el compilador .NET IL mueve todas las declaraciones de variables al comienzo de la función, pero no pude encontrar una buena fuente que estableciera claramente que 2 . En este ejemplo en particular, verá que los movió con esta declaración:

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

En donde nos volvemos demasiado obsesivos al hacer comparaciones ...

Caso A, ¿todas las variables se mueven hacia arriba?

Para profundizar un poco más en esto, probé la siguiente función:

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); 
    } 
}

La diferencia aquí es que declaramos un int io un string jbasado en la comparación. Nuevamente, el compilador mueve todas las variables locales a la parte superior de la función 2 con:

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

Me pareció interesante observar que, aunque int ino se declarará en este ejemplo, el código para admitirlo todavía se genera.

Caso B: ¿Qué pasa en foreachlugar de for?

Se señaló que foreachtiene un comportamiento diferente fory que no estaba comprobando lo mismo por lo que me habían preguntado. Así que puse estas dos secciones de código para comparar la IL resultante.

int declaración fuera del bucle:

    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 declaración dentro del bucle:

    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);
    }

La IL resultante con el foreachbucle fue de hecho diferente de la IL generada usando el forbucle. Específicamente, el bloque init y la sección del bucle cambiaron.

.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

El foreachenfoque generó más variables locales y requirió algunas ramificaciones adicionales. Esencialmente, la primera vez que salta al final del bucle para obtener la primera iteración de la enumeración y luego salta a casi la parte superior del bucle para ejecutar el código del bucle. Luego continúa girando como cabría esperar.

Pero más allá de las diferencias de ramificación causadas por el uso de las construcciones fory foreach, no hubo diferencias en la IL según el lugar donde int ise colocó la declaración. Entonces todavía estamos en los dos enfoques que son equivalentes.

Caso C: ¿Qué pasa con las diferentes versiones del compilador?

En un comentario que quedó 1 , había un enlace a una pregunta de SO con respecto a una advertencia sobre el acceso variable con foreach y el uso del cierre . La parte que realmente me llamó la atención en esa pregunta fue que puede haber diferencias en cómo funcionaba el compilador .NET 4.5 en comparación con versiones anteriores del compilador.

Y ahí es donde el sitio DotNetFiddler me decepcionó: todo lo que tenían disponible era .NET 4.5 y una versión del compilador de Roslyn. Así que saqué una instancia local de Visual Studio y comencé a probar el código. Para asegurarme de que estaba comparando las mismas cosas, comparé el código construido localmente en .NET 4.5 con el código DotNetFiddler.

La única diferencia que noté fue con el bloque de inicio local y la declaración de variable. El compilador local fue un poco más específico al nombrar las variables.

  .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)

Pero con esa pequeña diferencia, fue tan lejos, tan bueno. Tuve una salida IL equivalente entre el compilador DotNetFiddler y lo que estaba produciendo mi instancia VS local.

Entonces, reconstruí el proyecto dirigido a .NET 4, .NET 3.5 y, en buena medida, el modo de lanzamiento de .NET 3.5.

Y en los tres casos adicionales, la IL generada fue equivalente. La versión específica de .NET no tuvo ningún efecto sobre la IL que se generó en estas muestras.


Para resumir esta aventura: creo que podemos decir con confianza que al compilador no le importa dónde declara el tipo primitivo y que no hay ningún efecto sobre la memoria o el rendimiento con ninguno de los métodos de declaración. Y eso es cierto independientemente de usar un bucle foro foreach.

Pensé en ejecutar otro caso más que incorporaba un cierre dentro del foreachbucle. Pero usted había preguntado acerca de los efectos de dónde se declaró una variable de tipo primitiva, así que pensé que estaba profundizando demasiado más allá de lo que le interesaba preguntar. La pregunta SO que mencioné anteriormente tiene una gran respuesta que proporciona una buena visión general sobre los efectos de cierre en las variables de iteración foreach.

1 Gracias a Andy por proporcionar el enlace original a la pregunta SO que aborda los cierres dentro de los foreachbucles.

2 Vale la pena señalar que la especificación ECMA-335 aborda esto con la sección I.12.3.2.2 'Variables locales y argumentos'. Tuve que ver la IL resultante y luego leer la sección para que quede claro con respecto a lo que estaba sucediendo. Gracias a Ratchet Freak por señalar eso en el chat.

Comunidad
fuente
1
For y foreach no se comportan igual, y la pregunta incluye un código diferente que se vuelve importante cuando hay un cierre en el ciclo. stackoverflow.com/questions/14907987/…
Andy
1
@Andy - ¡Gracias por el enlace! Seguí adelante y verifiqué la salida generada usando un foreachbucle y también verifiqué la versión de .NET objetivo.
0

Dependiendo del compilador que use (ni siquiera sé si C # tiene más de uno), su código se optimizará antes de convertirse en un programa. Un buen compilador verá que reinicia la misma variable cada vez con un valor diferente y administrará el espacio de memoria de manera eficiente.

Si inicializara la misma variable a una constante cada vez, el compilador también la inicializaría antes del ciclo y la referenciaría.

Todo depende de qué tan bien esté escrito su compilador, pero en lo que respecta a los estándares de codificación, las variables siempre deben tener el menor alcance posible . Así que declarar dentro del ciclo es lo que siempre me han enseñado.

leylandski
fuente
3
Si su último párrafo es verdadero o no depende de dos cosas: la importancia de minimizar el alcance de la variable dentro del contexto único de su propio programa y el conocimiento interno del compilador sobre si realmente optimiza o no las asignaciones múltiples.
Robert Harvey
Y luego está el tiempo de ejecución, que traduce aún más el código de bytes al lenguaje máquina, donde también se realizan muchas de estas mismas optimizaciones (que se analizan aquí como optimizaciones del compilador).
Erik Eidt
-2

en primer lugar, solo está declarando e inicializando el bucle interno para que cada vez que se repita el bucle se reinicialice "i". En segundo lugar, solo declaras fuera del ciclo.

usuario304046
fuente
1
Esto no parece ofrecer nada sustancial sobre los puntos formulados y explicados en la respuesta superior que se publicó hace más de 2 años
mosquito
2
Gracias por dar una respuesta, pero no da ningún aspecto nuevo que la respuesta aceptada y mejor calificada no cubra (en detalle).
CharonX