web-dev-qa-db-fra.com

Variable capturée dans une boucle en C #

J'ai rencontré un problème intéressant à propos de C #. J'ai un 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 pense qu’il produira 0, 2, 4, 6, 8. Cependant, il délivre en réalité cinq 10.

Il semble que cela soit dû à toutes les actions faisant référence à une variable capturée. En conséquence, quand ils sont appelés, ils ont tous le même résultat.

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

194
Morgan Cheng

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 penser à cela comme si le compilateur C # créait une "nouvelle" variable locale chaque fois qu'il atteignait la déclaration de variable. En fait, cela va créer de nouveaux objets de fermeture appropriés, et cela devient compliqué (en termes de mise en œuvre) si vous faites référence à des variables dans plusieurs portées, 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.

173
Jon Skeet

Je crois que ce que vous vivez est ce que l’on appelle la fermeture http://en.wikipedia.org/wiki/Closure_ (computer_science) . Votre lamba a une référence à une variable qui est étendue 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 obtiendra la valeur de la variable au moment de l'exécution.

21
TheCodeJunkie

En coulisse, le compilateur génère une classe qui représente la clôture de votre appel de méthode. Il utilise cette instance unique 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.

10
gerrard00

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

C'EST À DIRE.

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

Oui, vous devez inclure variable dans la boucle et le 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();
5
cfeduke

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 consiste à utiliser des variables locales.

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

Cela n'a rien à voir avec les boucles.

Ce comportement est déclenché car vous utilisez une expression lambda () => variable * 2 où la portée externe variable n'est pas 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. Le passage de variables à ces méthodes soulève certains dilemmes (passage par valeur? Passage par référence? C # est associé à référence - mais cela pose un autre problème dans lequel la référence peut survivre à la variable réelle). Pour résoudre tous ces dilemmes, C # crée 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 dans votre code est traduite pour être modifiée dans cette ClosureClass.variable

Donc, votre boucle while continue de mettre à jour le ClosureClass.variable jusqu’à ce qu’il atteigne 10, alors vous exécutez les actions pour toutes les boucles, qui fonctionnent toutes sur le même ClosureClass.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 en tant qu'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 aux méthodes anonymes/expressions lambda qui utilisent des variables de portée locale - bien que certaines utilisations imprudentes de boucles démontrent des pièges de fermeture.

4
David Refaeli

C'est ce qu'on 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());
}
0
Juned Khan Momin