Clausuras, bucles y variables locales en C#

Empiezo el post de hoy con un acertijo ¿Cuál es la salida de este código?

        static void testClosureForeach()
        {
            var values = new int[] { 100, 110, 120 };
            var funcs = new List>();
            foreach (var v in values)
                funcs.Add(() => { return v; });
            foreach (var func in funcs)
                Console.WriteLine(func());
        }

Posiblemente respondas que la salida es 100, 110, 120. Piénsalo mejor😉

La respuesta es 120, 120, 120. ¿Sorprendido? Ahora, mira este código. ¿Cuál será la salida?

        static void testClosureFor()
        {
            var values = new int[] { 100, 110, 120 };
            var funcs = new List>();
            for (int i = 0; i < values.Length; i++)
                funcs.Add(() => { return values[i]; });
            foreach (var func in funcs)
                Console.WriteLine(func());
        }

¿100, 110, 120? ¿120, 120, 120? Ninguna de las dos; va a dar una excepción de índice fuera de rango.

Para explicar este comportamiento, tenemos que definir el concepto de clausura y ver cómo lo aplica C#. Una clausura es, según Wikipedia:

[…] una función que es evaluada en un entorno conteniendo una o más variables dependientes de otro entorno. Cuando es llamada, la función puede acceder a estas variables.

Una clausura puede ser usada para asociar una función con un conjunto de variables “privadas”, que persisten en las invocaciones de la función. El ámbito de la variable abarca sólo al de la función definida en la clausura, por lo que no puede ser accedida por otro código del programa. No obstante, la variable mantiene su valor de forma indefinida, por lo que un valor establecido en una invocación permanece disponible para la siguiente.

En nuestro código de ejemplo, () => {return v;} o () => {return values[i];} se ejecutan con el valor actual de v e i respectivamente, no con el valor de cuando el delegado fue creado. Es el ejemplo práctico del texto que he resaltado antes en negrita. En ambos casos, el ámbito de v e i no es el del bucle donde agregamos los delegados, sino el inmediatamente superior. Es por ese motivo que v se ha quedado con el valor del último elemento, e i se ha quedado fuera de rango (la salida del for se produce porque no se cumple que i<values.Length).

Para verlo más claro, vamos a ver el código que realmente se ejecuta. Una vez compilamos la construcción foreach (que no es más que syntactic sugar), lo que nos queda es más parecido a esto:

  {
    IEnumerator e = ((IEnumerable)values).GetEnumerator();
    try
    {
      int m; // Declarada fuera del bucle!!
      while(e.MoveNext())
      {
        // Esta es la clausura del cuerpo del foreach
        m = (int)(int)e.Current;
        funcs.Add(()=>m);
      }
    }
    finally
    {
      if (e != null) ((IDisposable)e).Dispose();
    }
  }

Y, tirando de Reflector, vamos a ver lo mismo para el caso del bucle for. El código es un poco más durillo de seguir:

private static void testClosureFor()
{
    List> list;
    Func func;
    <>c__DisplayClass10 class2;
    <>c__DisplayClassd classd;
    bool flag;
    classd = new <>c__DisplayClassd();
    classd.values = new int[] { 100, 110, 120 };
    list = new List>();
    func = null;
    class2 = new <>c__DisplayClass10(); // clausura externa
    class2.CS$<>8__localse = classd;
    class2.i = 0; // i = 0
    goto Label_0066;
Label_003C:
    if (func != null)
    {
        goto Label_0050;
    }
    // se utiliza la misma referencia para todas las invocaciones
    func = new Func(class2.b__c);
Label_0050:
    list.Add(func);
    class2.i += 1; // i++
Label_0066:
    if ((class2.i < ((int) classd.values.Length)) != null)
    {
        goto Label_003C;
    }
    // else, hemos acabado el FOR
    return;
}

¿A que ahora parece más claro el comportamiento? Tanto en el caso del bucle for como del foreach, las variables índice se declaran fuera del cuerpo del bucle, por lo que conservan su valor cuando lo abandonamos.

Solucionando el problema

En ambos casos, podemos solucionar el problema declarando una variable local en el ámbito del bucle y asignándole el valor de la variable con la que iteramos. De esta manera, tendríamos el siguiente código:

        static void testClosureForeach()
        {
            var values = new int[] { 100, 110, 120 };
            var funcs = new List>();
            foreach (var v in values)
            {
                var vtmp = v; // local
                funcs.Add(() => { return vtmp; });
            }
            foreach (var func in funcs)
                Console.WriteLine(func());
        }

        static void testClosureFor()
        {
            var values = new int[] { 100, 110, 120 };
            var funcs = new List>();
            for (int i = 0; i < values.Length; i++)
            {
                 var j = i; // local
                 funcs.Add(() => { return values[j]; });
            }
            foreach (var func in funcs)
                Console.WriteLine(func());
        }

Ahora, en ambos casos la salida es 100, 110, 120. Las nuevas variables locales forman parte de la clausura del bucle, por lo que no conservan su valor para invocaciones originadas en iteraciones diferentes. Eliminando syntactic sugar, el método con foreach nos quedaría así:

      int m; // fuera
      while(e.MoveNext())
      {
        m = (int)(int)e.Current;
        int m1 = m; // local
        funcs.Add(()=>m1);
      }

Y en el caso del FOR, el desensamblado es el siguiente:

private static void testClosureFor()
{
    List> list;
    int num;
    <>c__DisplayClassf classf;
    <>c__DisplayClassd classd;
    bool flag;
    classd = new <>c__DisplayClassd(); // clausura externa
    classd.values = new int[] { 100, 110, 120 };
    list = new List>();
    num = 0; // i = 0
    goto Label_0055; // FOR
Label_0028:
    classf = new <>c__DisplayClassf(); // valor en la clausura interna
    classf.CS$<>8__localse = classd;
    classf.j = num; // el valor de i queda ahí 'congelado'
    // aquí no utilizamos la misma referencia en todas las vueltas
    list.Add(new Func(classf.b__c));
    num += 1; // i++
Label_0055:
    if ((num < ((int) classd.values.Length)) != null) // i < values.length
    {
        goto Label_0028;
    }
    // else, hemos acabado el FOR
    return;
}

¿Y esto por qué es así?

Pues bueno, fue una decisión de diseño de quienes implementaron el compilador de C#. Hay otros lenguajes, como Ruby, que no funcionan así. Es un comportamiento confuso, y como tal podría cambiarse para hacerlo más claro. Pero, a estas alturas, hay poderosas razones para dejarlo como está. En el enlace -cuyo ejemplo te va a sonar :P- están mejor explicadas pero, en resumen, las razones son:

  • Conservar la compatibilidad hacia atrás
  • Por consistencia léxica. Sería raro que para foreach(var x in o.getCollection()) o.getCollection() se ejecute antes que la declaración de x, que aparece antes (más a la izquierda).
  • Por consistencia con la sintaxis de for. A pesar de que en el ejemplo también cuesta pensarlo, nos parece lógico y forma parte de un hábito muy establecido que en for(var x=0;x<10;x++) veamos x++ como un incremento de una variable que es externa al bucle.
  • Porque una vez lo sabes es fácil de evitar. Como hemos visto, basta con declarar una variable local. Además, herramientas como ReSharper te avisan si encuentran estas situaciones.

Y esto es todo por hoy. Gracias por haber llegado hasta aquí y a ver si el próximo post más ligerito😉 Hasta la próxima!

PD) Este post nació gracias a mi colega Marcel (no se prodiga mucho), quien me habló de esta marcianada despertando mi curiosidad y originando el tocho que te acabas de leer.

Esta entrada fue publicada en Dev y etiquetada , , , , , , . Guarda el enlace permanente.

Una respuesta a Clausuras, bucles y variables locales en C#

  1. Excelente post para entender las clausuras en C#, anteriormente me las había topado en Javascript y en éste es más curioso el caso aún, debido a que en Javascript no hay scope de bloque ( un for, while o if no crean scope ), y por tanto hay que jugar con funciones anónimas para poder crear este scope y así producir el resultado deseado. Saludos!

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s