Variable capturée dans une boucle en C #

217

J'ai rencontré un problème intéressant à propos de C #. J'ai du code comme ci-dessous.

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

Je m'attends à ce qu'il produise 0, 2, 4, 6, 8. Cependant, il produit en fait cinq 10.

Il semble que cela soit dû à toutes les actions se référant à une variable capturée. Par conséquent, lorsqu'ils sont invoqués, ils ont tous la même sortie.

Existe-t-il un moyen de contourner cette limite pour que chaque instance d'action ait sa propre variable capturée?

Morgan Cheng
la source
15
Voir aussi la série de blogs d'Eric Lippert sur le sujet: Clôture de la variable de boucle considérée comme nuisible
Brian
10
En outre, ils modifient C # 5 pour fonctionner comme vous vous attendez dans une foreach. (rupture du changement)
Neal Tibrewala
1
EN RELATION
3
@Neal: bien que cet exemple ne fonctionne toujours pas correctement en C # 5, car il génère toujours cinq 10
Ian Oakes
6
Il a vérifié qu'il produisait cinq 10 jusqu'à aujourd'hui sur C # 6.0 (VS 2015). Je doute que ce comportement des variables de fermeture soit candidat au changement. Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured.
RBT

Réponses:

198

Oui - prenez une copie de la variable à l'intérieur de la boucle:

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

Vous pouvez y penser comme si le compilateur C # crée une "nouvelle" variable locale chaque fois qu'il frappe la déclaration de variable. En fait, cela créera de nouveaux objets de fermeture appropriés, et cela se compliquera (en termes d'implémentation) si vous faites référence à des variables dans plusieurs étendues, mais cela fonctionne :)

Notez qu'une occurrence plus courante de ce problème utilise for ou foreach:

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

Voir la section 7.14.4.2 de la spécification C # 3.0 pour plus de détails à ce sujet, et mon article sur les fermetures contient également d'autres exemples.

Notez que depuis le compilateur C # 5 et au-delà (même lorsque vous spécifiez une version antérieure de C #), le comportement de a foreachchangé de sorte que vous n'avez plus besoin de faire de copie locale. Voir cette réponse pour plus de détails.

Jon Skeet
la source
32
Le livre de Jon contient également un très bon chapitre à ce sujet (arrêtez d'être humble, Jon!)
Marc Gravell
35
Cela semble mieux si je laisse d'autres personnes le brancher;) (j'avoue que j'ai tendance à voter pour les réponses le recommandant cependant.)
Jon Skeet
2
Comme toujours, les commentaires à [email protected] seraient appréciés :)
Jon Skeet
7
Pour le comportement C # 5.0 est différent (plus raisonnable) voir la nouvelle réponse de Jon Skeet - stackoverflow.com/questions/16264289/…
Alexei Levenkov
1
@Florimond: Ce n'est tout simplement pas ainsi que fonctionnent les fermetures en C #. Ils capturent des variables , pas des valeurs . (Cela est vrai indépendamment des boucles, et est facilement démontré avec un lambda qui capture une variable et imprime simplement la valeur actuelle chaque fois qu'elle est exécutée.)
Jon Skeet
23

Je crois que ce que vous vivez est quelque chose connu sous le nom de fermeture http://en.wikipedia.org/wiki/Closure_(computer_science) . Votre lamba a une référence à une variable qui est portée en dehors de la fonction elle-même. Votre lamba n'est pas interprété jusqu'à ce que vous l'invoquiez et une fois qu'il l'est, il obtiendra la valeur de la variable au moment de l'exécution.

TheCodeJunkie
la source
11

Dans les coulisses, le compilateur génère une classe qui représente la fermeture de votre appel de méthode. Il utilise cette seule instance de la classe de fermeture pour chaque itération de la boucle. Le code ressemble à ceci, ce qui permet de voir plus facilement pourquoi le bogue se produit:

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

Ce n'est pas réellement le code compilé de votre exemple, mais j'ai examiné mon propre code et cela ressemble beaucoup à ce que le compilateur générerait réellement.

gerrard00
la source
8

La solution consiste à stocker la valeur dont vous avez besoin dans une variable proxy et à faire capturer cette variable.

C'EST À DIRE

while( variable < 5 )
{
    int copy = variable;
    actions.Add( () => copy * 2 );
    ++variable;
}
tjlevine
la source
Voir l'explication dans ma réponse modifiée. Je trouve le morceau pertinent de la spécification maintenant.
Jon Skeet
Haha jon, je viens de lire votre article: csharpindepth.com/Articles/Chapter5/Closures.aspx Vous faites du bon travail mon ami.
tjlevine
@tjlevine: Merci beaucoup. J'ajouterai une référence à cela dans ma réponse. Je l'avais oublié!
Jon Skeet
Aussi, Jon, j'aimerais lire vos réflexions sur les différentes propositions de fermeture de Java 7. Je vous ai vu mentionner que vous vouliez en écrire un, mais je ne l'ai pas vu.
tjlevine
1
@tjlevine: D'accord, je promets d'essayer de l'écrire d'ici la fin de l'année :)
Jon Skeet
6

Cela n'a rien à voir avec les boucles.

Ce comportement est déclenché car vous utilisez une expression lambda () => variable * 2où la portée externevariable n'est pas réellement définie dans la portée interne du lambda.

Les expressions lambda (en C # 3 +, ainsi que les méthodes anonymes en C # 2) créent toujours des méthodes réelles. Passer des variables à ces méthodes implique certains dilemmes (passer par valeur? Passer par référence? C # va de pair avec référence - mais cela ouvre un autre problème où la référence peut survivre à la variable réelle). Ce que C # fait pour résoudre tous ces dilemmes est de créer une nouvelle classe d'assistance ("fermeture") avec des champs correspondant aux variables locales utilisées dans les expressions lambda et des méthodes correspondant aux méthodes lambda réelles. Toute modification apportée àvariable votre code sont en fait traduites pour en changerClosureClass.variable

Ainsi, votre boucle while met à jour la ClosureClass.variablejusqu'à ce qu'elle atteigne 10, puis vous pour les boucles exécute les actions, qui fonctionnent toutes sur la mêmeClosureClass.variable .

Pour obtenir le résultat attendu, vous devez créer une séparation entre la variable de boucle et la variable en cours de fermeture. Vous pouvez le faire en introduisant une autre variable, à savoir:

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

Vous pouvez également déplacer la fermeture vers une autre méthode pour créer cette séparation:

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

Vous pouvez implémenter Mult comme une expression lambda (fermeture implicite)

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

ou avec une classe d'assistance réelle:

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

Dans tous les cas, les «fermetures» ne sont PAS un concept lié aux boucles , mais plutôt à l'utilisation de méthodes / expressions lambda anonymes de variables de portée locale - bien que certaines utilisations imprudentes des boucles démontrent des pièges à fermetures.

David Refaeli
la source
5

Oui, vous devez définir la portée variabledans la boucle et la transmettre au lambda de cette façon:

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();
cfeduke
la source
5

La même situation se produit dans le multi-threading (C #, .NET 4.0].

Voir le code suivant:

Le but est d'imprimer 1,2,3,4,5 dans l'ordre.

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

La sortie est intéressante! (Cela pourrait être comme 21334 ...)

La seule solution est d'utiliser des variables locales.

for (int counter = 1; counter <= 5; counter++)
{
    int localVar= counter;
    new Thread (() => Console.Write (localVar)).Start();
}
Sunil
la source
Cela ne semble pas m'aider. Toujours non déterministe.
Mladen Mihajlovic
0

Étant donné que personne ici n'a cité directement ECMA-334 :

10.4.4.10 Pour les relevés

Affectation définitive vérifiant une instruction for du formulaire:

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

se fait comme si la déclaration était écrite:

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

Plus loin dans la spécification,

12.16.6.3 Instanciation des variables locales

Une variable locale est considérée comme instanciée lorsque l'exécution entre dans la portée de la variable.

[Exemple: Par exemple, lorsque la méthode suivante est invoquée, la variable locale xest instanciée et initialisée trois fois, une fois pour chaque itération de la boucle.

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

Cependant, déplacer la déclaration de l' xextérieur de la boucle se traduit par une seule instanciation de x:

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

exemple de fin]

Lorsqu'elles ne sont pas capturées, il n'y a aucun moyen d'observer exactement à quelle fréquence une variable locale est instanciée - car les durées de vie des instanciations sont disjointes, il est possible pour chaque instanciation d'utiliser simplement le même emplacement de stockage. Cependant, lorsqu'une fonction anonyme capture une variable locale, les effets de l'instanciation deviennent apparents.

[Exemple: l'exemple

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

produit la sortie:

1
3
5

Cependant, lorsque la déclaration de xest déplacée hors de la boucle:

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 sortie est:

5
5
5

Notez que le compilateur est autorisé (mais pas obligatoire) pour optimiser les trois instanciations en une seule instance déléguée (§11.7.2).

Si une boucle for déclare une variable d'itération, cette variable elle-même est considérée comme déclarée en dehors de la boucle. [Exemple: Ainsi, si l'exemple est modifié pour capturer la variable d'itération elle-même:

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

une seule instance de la variable d'itération est capturée, ce qui produit la sortie:

3
3
3

exemple de fin]

Oh oui, je suppose qu'il convient de mentionner qu'en C ++, ce problème ne se produit pas car vous pouvez choisir si la variable est capturée par valeur ou par référence (voir: Capture Lambda ).

Nathan Chappell
la source
-1

Cela s'appelle le problème de fermeture, utilisez simplement une variable de copie, et c'est fait.

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());
}
Juned Khan Momin
la source
4
En quoi votre réponse est différente de la réponse fournie par quelqu'un ci-dessus?
Thangadurai