Y a-t-il une raison pour la réutilisation par C # de la variable dans un foreach?

1685

Lorsque vous utilisez des expressions lambda ou des méthodes anonymes en C #, nous devons nous méfier de l' accès aux pièges de fermeture modifiés . Par exemple:

foreach (var s in strings)
{
   query = query.Where(i => i.Prop == s); // access to modified closure
   ...
}

En raison de la fermeture modifiée, le code ci-dessus entraînera que toutes les Whereclauses de la requête seront basées sur la valeur finale de s.

Comme expliqué ici , cela se produit car la svariable déclarée dans la foreachboucle ci-dessus est traduite comme ceci dans le compilateur:

string s;
while (enumerator.MoveNext())
{
   s = enumerator.Current;
   ...
}

au lieu de comme ça:

while (enumerator.MoveNext())
{
   string s;
   s = enumerator.Current;
   ...
}

Comme indiqué ici , il n'y a aucun avantage en termes de performances à déclarer une variable en dehors de la boucle, et dans des circonstances normales, la seule raison pour laquelle je peux penser à cela est si vous prévoyez d'utiliser la variable en dehors de la portée de la boucle:

string s;
while (enumerator.MoveNext())
{
   s = enumerator.Current;
   ...
}
var finalString = s;

Cependant les variables définies dans une foreachboucle ne peuvent pas être utilisées en dehors de la boucle:

foreach(string s in strings)
{
}
var finalString = s; // won't work: you're outside the scope.

Ainsi, le compilateur déclare la variable d'une manière qui la rend très sujette à une erreur qui est souvent difficile à trouver et à déboguer, tout en ne produisant aucun avantage perceptible.

Y a-t-il quelque chose que vous pouvez faire avec des foreachboucles de cette façon que vous ne pourriez pas faire si elles étaient compilées avec une variable de portée interne, ou est-ce juste un choix arbitraire qui a été fait avant que les méthodes anonymes et les expressions lambda ne soient disponibles ou communes, et qui n'ont pas pas été révisé depuis?

StriplingWarrior
la source
4
Qu'est-ce qui ne va pas String s; foreach (s in strings) { ... }?
Brad Christie
5
@BradChristie l'OP ne parle pas vraiment foreachmais d'expressions lamda résultant en un code similaire à celui de l'OP ...
Yahia
22
@BradChristie: Est-ce que cela se compile? ( Erreur: le type et l'identifiant sont tous deux requis dans une déclaration foreach pour moi)
Austin Salonen
32
@JakobBotschNielsen: C'est un local externe fermé d'un lambda; pourquoi supposez-vous que ce sera sur la pile? Sa durée de vie est plus longue que le cadre de pile !
Eric Lippert
3
@EricLippert: Je suis confus. Je comprends que lambda capture une référence à la variable foreach (qui est déclarée en interne en dehors de la boucle) et donc vous finissez par comparer avec sa valeur finale; que je reçois. Ce que je ne comprends pas, c'est comment déclarer la variable à l' intérieur de la boucle fera une différence. Du point de vue du compilateur-écrivain, je n'alloue qu'une seule référence de chaîne (var 's') sur la pile, que la déclaration soit à l'intérieur ou à l'extérieur de la boucle; Je ne voudrais certainement pas pousser une nouvelle référence sur la pile à chaque itération!
Anthony

Réponses:

1407

Le compilateur déclare la variable d'une manière qui la rend très sujette à une erreur qui est souvent difficile à trouver et à déboguer, tout en ne produisant aucun avantage perceptible.

Votre critique est entièrement justifiée.

Je discute ce problème en détail ici:

La fermeture de la variable de boucle est considérée comme nuisible

Y a-t-il quelque chose que vous pouvez faire avec les boucles foreach de cette façon que vous ne pourriez pas faire si elles étaient compilées avec une variable de portée interne? ou s'agit-il simplement d'un choix arbitraire qui a été fait avant que les méthodes anonymes et les expressions lambda ne soient disponibles ou communes, et qui n'a pas été révisé depuis?

Le dernier. La spécification C # 1.0 n'a en fait pas indiqué si la variable de boucle était à l'intérieur ou à l'extérieur du corps de la boucle, car elle ne faisait aucune différence observable. Lorsque la sémantique de fermeture a été introduite en C # 2.0, le choix a été fait de placer la variable de boucle en dehors de la boucle, cohérente avec la boucle "for".

Je pense qu'il est juste de dire que tous regrettent cette décision. C'est l'un des pires "accrochages" en C #, et nous allons prendre le changement de rupture pour le corriger. En C # 5, la variable de boucle foreach sera logiquement à l' intérieur du corps de la boucle, et donc les fermetures obtiendront une nouvelle copie à chaque fois.

La forboucle ne sera pas modifiée et la modification ne sera pas "redirigée" vers les versions précédentes de C #. Vous devez donc continuer à être prudent lorsque vous utilisez cet idiome.

Eric Lippert
la source
177
En fait, nous avons repoussé ce changement en C # 3 et C # 4. Lorsque nous avons conçu C # 3, nous avons réalisé que le problème (qui existait déjà en C # 2) allait s'aggraver car il y aurait tellement de lambdas (et de requêtes compréhensions, qui sont des lambdas déguisés) en boucles foreach grâce à LINQ. Je regrette que nous ayons attendu que le problème soit suffisamment grave pour justifier de le réparer si tard, plutôt que de le corriger en C # 3.
Eric Lippert
75
Et maintenant, nous devons nous rappeler foreachest «sûr», mais forne l'est pas.
leppie
22
@michielvoo: Le changement s'interrompt dans le sens où il n'est pas rétrocompatible. Le nouveau code ne s'exécutera pas correctement lors de l'utilisation d'un ancien compilateur.
leppie
41
@Benjol: Non, c'est pourquoi nous sommes prêts à le prendre. Jon Skeet m'a fait remarquer un scénario de changement de rupture important, à savoir que quelqu'un écrit du code en C # 5, le teste, puis le partage avec des personnes qui utilisent toujours C # 4, qui croient ensuite naïvement qu'il est correct. Espérons que le nombre de personnes touchées par un tel scénario soit faible.
Eric Lippert
29
Soit dit en passant, ReSharper a toujours saisi cela et le signale comme "accès à une fermeture modifiée". Ensuite, en appuyant sur Alt + Entrée, il corrigera même automatiquement votre code pour vous. jetbrains.com/resharper
Mike Chamberlain
191

Ce que vous demandez est entièrement couvert par Eric Lippert dans son article de blog Clôture sur la variable de boucle considérée comme nuisible et sa suite.

Pour moi, l'argument le plus convaincant est que le fait d'avoir une nouvelle variable à chaque itération serait incompatible avec la for(;;)boucle de style. Vous attendriez-vous à en avoir un nouveau int ià chaque itération de for (int i = 0; i < 10; i++)?

Le problème le plus courant avec ce comportement est une variable de fermeture sur itération et il a une solution de contournement facile:

foreach (var s in strings)
{
    var s_for_closure = s;
    query = query.Where(i => i.Prop == s_for_closure); // access to modified closure

Mon article de blog sur ce problème: Clôture de la variable foreach en C # .

Krizz
la source
18
En fin de compte, ce que les gens veulent réellement quand ils écrivent ce n'est pas d'avoir plusieurs variables, c'est de fermer la valeur . Et il est difficile de penser à une syntaxe utilisable pour cela dans le cas général.
Random832
1
Oui, il n'est pas possible de fermer par la valeur, mais il existe une solution de contournement très simple que je viens de modifier ma réponse pour inclure.
Krizz
6
Il est dommage que les fermetures en C # se ferment sur les références. S'ils fermaient les valeurs par défaut, nous pourrions facilement spécifier la fermeture des variables à la place avec ref.
Sean U
2
@Krizz, c'est un cas où la cohérence forcée est plus nuisible qu'incohérente. Cela devrait "simplement fonctionner" comme les gens l'attendent, et clairement les gens s'attendent à quelque chose de différent lorsqu'ils utilisent foreach par opposition à une boucle for, étant donné le nombre de personnes qui ont rencontré des problèmes avant que nous connaissions l'accès à un problème de fermeture modifié (comme moi) .
Andy
2
@ Random832 ne connaît pas C # mais dans Common LISP il y a une syntaxe pour cela, et il suppose que tout langage avec des variables et des fermetures mutables l'aurait (non, doit ) aussi. On ferme soit sur référence au lieu changeant, soit sur une valeur qu'il a à un moment donné (création d'une fermeture). Ceci discute des choses similaires en Python et Scheme ( cutpour les refs / vars et cutepour garder les valeurs évaluées dans les fermetures partiellement évaluées).
Will Ness
103

Ayant été mordu par cela, j'ai l'habitude d'inclure des variables définies localement dans la portée la plus intérieure que j'utilise pour transférer à n'importe quelle fermeture. Dans votre exemple:

foreach (var s in strings)
    query = query.Where(i => i.Prop == s); // access to modified closure

Je fais:

foreach (var s in strings)
{
    string search = s;
    query = query.Where(i => i.Prop == search); // New definition ensures unique per iteration.
}        

Une fois que vous avez pris cette habitude, vous pouvez l'éviter dans les très rares cas où vous aviez réellement l'intention de vous lier aux portées extérieures. Pour être honnête, je ne pense pas l'avoir fait.

Godeke
la source
24
C'est la solution de contournement typique Merci pour la contribution. Resharper est assez intelligent pour reconnaître ce motif et le porter à votre attention, ce qui est bien. Je n'ai pas été mordu par ce modèle depuis un certain temps, mais comme c'est, selon les mots d'Eric Lippert, "le rapport de bogue incorrect le plus courant que nous obtenons", j'étais curieux de savoir le pourquoi plus que le comment l'éviter .
StriplingWarrior
62

Dans C # 5.0, ce problème est résolu et vous pouvez fermer les variables de boucle et obtenir les résultats attendus.

La spécification de langue dit:

8.8.4 L'instruction foreach

(...)

Une déclaration foreach du formulaire

foreach (V v in x) embedded-statement

est ensuite étendu à:

{
  E e = ((C)(x)).GetEnumerator();
  try {
      while (e.MoveNext()) {
          V v = (V)(T)e.Current;
          embedded-statement
      }
  }
  finally {
       // Dispose e
  }
}

(...)

Le placement de l' vintérieur de la boucle while est important pour la façon dont il est capturé par toute fonction anonyme se produisant dans l'instruction intégrée. Par exemple:

int[] values = { 7, 9, 13 };
Action f = null;
foreach (var value in values)
{
    if (f == null) f = () => Console.WriteLine("First value: " + value);
}
f();

Si vétait déclaré en dehors de la boucle while, il serait partagé entre toutes les itérations, et sa valeur après la boucle for serait la valeur finale 13, qui est ce que l'invocation de fimprimerait. Au lieu de cela, parce que chaque itération a sa propre variable v, celle capturée par fdans la première itération continuera à contenir la valeur 7, qui est ce qui sera imprimé. ( Remarque: les versions antérieures de C # déclarées en vdehors de la boucle while. )

Paolo Moretti
la source
1
Pourquoi cette première version de C # a déclaré v à l'intérieur de la boucle while? msdn.microsoft.com/en-GB/library/aa664754.aspx
colinfang
4
@colinfang Assurez-vous de lire la réponse d'Eric : la spécification C # 1.0 ( dans votre lien, nous parlons de VS 2003, c.-à-d. C # 1.2 ) n'a en fait pas dit si la variable de boucle était à l'intérieur ou à l'extérieur du corps de la boucle, car elle ne fait aucune différence observable . Lorsque la sémantique de fermeture a été introduite en C # 2.0, le choix a été fait de placer la variable de boucle en dehors de la boucle, cohérente avec la boucle "for".
Paolo Moretti
1
Vous dites donc que les exemples dans le lien n'étaient pas des spécifications définitives à l'époque?
colinfang
4
@colinfang C'étaient des spécifications définitives. Le problème est que nous parlons d'une fonctionnalité (c'est-à-dire des fermetures de fonctions) qui a été introduite plus tard (avec C # 2.0). Lorsque C # 2.0 est apparu, ils ont décidé de mettre la variable de boucle en dehors de la boucle. Et puis ils ont changé d'avis à nouveau avec C # 5.0 :)
Paolo Moretti