Variable capturada en un bucle en C #


216

Encontré un problema interesante sobre C #. Tengo un código como el de abajo.

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    actions.Add(() => variable * 2);
    ++ variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

Espero que produzca 0, 2, 4, 6, 8. Sin embargo, en realidad produce cinco 10s.

Parece que se debe a todas las acciones que se refieren a una variable capturada. Como resultado, cuando se invocan, todos tienen el mismo resultado.

¿Hay alguna forma de evitar este límite para que cada instancia de acción tenga su propia variable capturada?


15
Ver también la serie de blog de Eric Lippert sobre el tema: Cierre sobre el lazo Variable considerada perjudicial
Brian

10
Además, están cambiando C # 5 para que funcione como se espera dentro de un foreach. (cambio de ruptura)
Neal Tibrewala


3
@Neal: aunque este ejemplo todavía no funciona correctamente en C # 5, ya que aún genera cinco 10s
Ian Oakes

66
Verificó que produce cinco 10s hasta hoy en C # 6.0 (VS 2015). Dudo que este comportamiento de las variables de cierre sea un candidato para el cambio. Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured.
RBT

Respuestas:


196

Sí, tome una copia de la variable dentro del bucle:

while (variable < 5)
{
    int copy = variable;
    actions.Add(() => copy * 2);
    ++ variable;
}

Puede pensar que el compilador de C # crea una variable local "nueva" cada vez que llega a la declaración de variable. De hecho, creará nuevos objetos de cierre apropiados y se complicará (en términos de implementación) si hace referencia a variables en varios ámbitos, pero funciona :)

Tenga en cuenta que una ocurrencia más común de este problema es usar foro foreach:

for (int i=0; i < 10; i++) // Just one variable
foreach (string x in foo) // And again, despite how it reads out loud

Consulte la sección 7.14.4.2 de la especificación C # 3.0 para obtener más detalles al respecto, y mi artículo sobre cierres también tiene más ejemplos.

Tenga en cuenta que a partir del compilador de C # 5 y más allá (incluso cuando se especifica una versión anterior de C #), el comportamiento de ha foreachcambiado, por lo que ya no necesita hacer una copia local. Vea esta respuesta para más detalles.


32
El libro de Jon también tiene un muy buen capítulo sobre esto (¡deja de ser humilde, Jon!)
Marc Gravell

35
Se ve mejor si dejo que otras personas lo conecten; (confieso que tiendo a votar las respuestas que lo recomiendan).
Jon Skeet

2
Como siempre, agradeceríamos sus comentarios a skeet@pobox.com :)
Jon Skeet

77
Para C # 5.0 el comportamiento es diferente (más razonable) ver la respuesta más reciente de Jon Skeet - stackoverflow.com/questions/16264289/…
Alexei Levenkov

1
@Florimond: Así no es cómo funcionan los cierres en C #. Capturan variables , no valores . (Eso es cierto independientemente de los bucles, y se demuestra fácilmente con una lambda que captura una variable y solo imprime el valor actual cada vez que se ejecuta).
Jon Skeet

23

Creo que lo que está experimentando es algo conocido como Cierre http://en.wikipedia.org/wiki/Closure_(computer_science) . Su lamba tiene una referencia a una variable que se encuentra fuera de la función misma. Su lamba no se interpreta hasta que la invoque y una vez que lo haga, obtendrá el valor que tiene la variable en el momento de la ejecución.


11

Detrás de escena, el compilador está generando una clase que representa el cierre de su llamada al método. Utiliza esa única instancia de la clase de cierre para cada iteración del bucle. El código se parece a esto, lo que facilita ver por qué ocurre el error:

void Main()
{
    List<Func<int>> actions = new List<Func<int>>();

    int variable = 0;

    var closure = new CompilerGeneratedClosure();

    Func<int> anonymousMethodAction = null;

    while (closure.variable < 5)
    {
        if(anonymousMethodAction == null)
            anonymousMethodAction = new Func<int>(closure.YourAnonymousMethod);

        //we're re-adding the same function 
        actions.Add(anonymousMethodAction);

        ++closure.variable;
    }

    foreach (var act in actions)
    {
        Console.WriteLine(act.Invoke());
    }
}

class CompilerGeneratedClosure
{
    public int variable;

    public int YourAnonymousMethod()
    {
        return this.variable * 2;
    }
}

Este no es realmente el código compilado de su muestra, pero he examinado mi propio código y se parece mucho a lo que realmente generaría el compilador.


8

La forma de evitar esto es almacenar el valor que necesita en una variable proxy y hacer que esa variable sea capturada.

ES DECIR

while( variable < 5 )
{
    int copy = variable;
    actions.Add( () => copy * 2 );
    ++variable;
}

Vea la explicación en mi respuesta editada. Estoy encontrando la parte relevante de la especificación ahora.
Jon Skeet

Jaja jon, en realidad acabo de leer tu artículo: csharpindepth.com/Articles/Chapter5/Closures.aspx Haces un buen trabajo, amigo mío.
tjlevine

@tjlevine: Muchas gracias. Agregaré una referencia a eso en mi respuesta. ¡Me había olvidado de eso!
Jon Skeet

Además, Jon, me encantaría leer tu opinión sobre las diversas propuestas de cierre de Java 7. Te he visto mencionar que querías escribir uno, pero no lo he visto.
tjlevine

1
@tjlevine: De acuerdo, prometo intentar escribirlo para fin de año :)
Jon Skeet

6

Esto no tiene nada que ver con los bucles.

Este comportamiento se desencadena porque utiliza una expresión lambda () => variable * 2donde el ámbito externo variableno está realmente definido en el ámbito interno del lambda.

Las expresiones lambda (en C # 3 +, así como los métodos anónimos en C # 2) aún crean métodos reales. Pasar variables a estos métodos involucra algunos dilemas (¿pasar por valor? ¿Pasar por referencia? C # va con referencia - pero esto abre otro problema donde la referencia puede sobrevivir a la variable real). Lo que C # hace para resolver todos estos dilemas es crear una nueva clase auxiliar ("cierre") con campos correspondientes a las variables locales utilizadas en las expresiones lambda y métodos correspondientes a los métodos lambda reales. Cualquier cambio variableen su código se traduce realmente como cambio en eseClosureClass.variable

Por lo tanto, su bucle while continúa actualizando ClosureClass.variablehasta que alcanza 10, luego usted para bucles ejecuta las acciones, que operan en el mismo ClosureClass.variable.

Para obtener el resultado esperado, debe crear una separación entre la variable de bucle y la variable que se está cerrando. Puede hacer esto introduciendo otra variable, es decir:

List<Func<int>> actions = new List<Func<int>>();
int variable = 0;
while (variable < 5)
{
    var t = variable; // now t will be closured (i.e. replaced by a field in the new class)
    actions.Add(() => t * 2);
    ++variable; // changing variable won't affect the closured variable t
}
foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

También puede mover el cierre a otro método para crear esta separación:

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    actions.Add(Mult(variable));
    ++variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

Puede implementar Mult como una expresión lambda (cierre implícito)

static Func<int> Mult(int i)
{
    return () => i * 2;
}

o con una clase de ayuda real:

public class Helper
{
    public int _i;
    public Helper(int i)
    {
        _i = i;
    }
    public int Method()
    {
        return _i * 2;
    }
}

static Func<int> Mult(int i)
{
    Helper help = new Helper(i);
    return help.Method;
}

En cualquier caso, los "cierres" NO son un concepto relacionado con los bucles , sino más bien con el uso de métodos anónimos / expresiones lambda de variables de ámbito local, aunque algunos usos incautos de bucles demuestran trampas de cierres.


5

Sí, debe buscar variabledentro del bucle y pasarlo a la lambda de esa manera:

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    int variable1 = variable;
    actions.Add(() => variable1 * 2);
    ++variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

Console.ReadLine();

5

La misma situación está ocurriendo en subprocesos múltiples (C #, .NET 4.0).

Ver el siguiente código:

El propósito es imprimir 1,2,3,4,5 en orden.

for (int counter = 1; counter <= 5; counter++)
{
    new Thread (() => Console.Write (counter)).Start();
}

¡La salida es interesante! (Puede ser como 21334 ...)

La única solución es usar variables locales.

for (int counter = 1; counter <= 5; counter++)
{
    int localVar= counter;
    new Thread (() => Console.Write (localVar)).Start();
}

Esto no parece ayudarme. Aún no determinista.
Mladen Mihajlovic

0

Como aquí nadie citó directamente a ECMA-334 :

10.4.4.10 Para declaraciones

Comprobación de asignación definitiva para una declaración for del formulario:

for (for-initializer; for-condition; for-iterator) embedded-statement

se hace como si la declaración estuviera escrita:

{
    for-initializer;
    while (for-condition) {
        embedded-statement;
    LLoop: for-iterator;
    }
}

Más adelante en la especificación,

12.16.6.3 Instanciación de variables locales

Una variable local se considera instanciada cuando la ejecución entra en el alcance de la variable.

[Ejemplo: por ejemplo, cuando se invoca el siguiente método, la variable local xse instancia e inicializa tres veces, una para cada iteración del bucle.

static void F() {
  for (int i = 0; i < 3; i++) {
    int x = i * 2 + 1;
    ...
  }
}

Sin embargo, mover la declaración de xfuera del ciclo da como resultado una sola instanciación de x:

static void F() {
  int x;
  for (int i = 0; i < 3; i++) {
    x = i * 2 + 1;
    ...
  }
}

ejemplo final]

Cuando no se captura, no hay forma de observar exactamente con qué frecuencia se instancia una variable local, ya que las vidas de las instancias son disjuntas, es posible que cada instancia simplemente use la misma ubicación de almacenamiento. Sin embargo, cuando una función anónima captura una variable local, los efectos de la instanciación se hacen evidentes.

[Ejemplo: el ejemplo

using System;

delegate void D();

class Test{
  static D[] F() {
    D[] result = new D[3];
    for (int i = 0; i < 3; i++) {
      int x = i * 2 + 1;
      result[i] = () => { Console.WriteLine(x); };
    }
  return result;
  }
  static void Main() {
    foreach (D d in F()) d();
  }
}

produce la salida:

1
3
5

Sin embargo, cuando la declaración de xse mueve fuera del bucle:

static D[] F() {
  D[] result = new D[3];
  int x;
  for (int i = 0; i < 3; i++) {
    x = i * 2 + 1;
    result[i] = () => { Console.WriteLine(x); };
  }
  return result;
}

la salida es:

5
5
5

Tenga en cuenta que el compilador está permitido (pero no es obligatorio) para optimizar las tres instancias en una sola instancia de delegado (§11.7.2).

Si un ciclo for declara una variable de iteración, se considera que esa variable se declara fuera del ciclo. [Ejemplo: por lo tanto, si se cambia el ejemplo para capturar la variable de iteración en sí:

static D[] F() {
  D[] result = new D[3];
  for (int i = 0; i < 3; i++) {
    result[i] = () => { Console.WriteLine(i); };
  }
  return result;
}

solo se captura una instancia de la variable de iteración, que produce la salida:

3
3
3

ejemplo final]

Ah sí, supongo que debería mencionarse que en C ++ este problema no ocurre porque puedes elegir si la variable se captura por valor o por referencia (ver: captura de Lambda ).


-1

Se llama el problema de cierre, simplemente use una variable de copia y ya está.

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    int i = variable;
    actions.Add(() => i * 2);
    ++ variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

44
¿De qué manera su respuesta es diferente de la respuesta proporcionada por alguien anterior?
Thangadurai
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.