Есть ли причина для повторного использования C # переменной в foreach?

1685

При использовании лямбда-выражений или анонимных методов в C # мы должны опасаться доступа к измененной ловушке замыкания . Например:

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

Из-за измененного закрытия приведенный выше код приведет к тому, что все Whereпредложения в запросе будут основаны на конечном значении s.

Как объясняется здесь , это происходит потому, что sпеременная, объявленная в foreachцикле выше, переводится следующим образом в компилятор:

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

вместо этого:

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

Как указывалось здесь , нет никаких преимуществ в производительности для объявления переменной вне цикла, и при нормальных обстоятельствах единственная причина, по которой я могу придумать для этого, - это если вы планируете использовать переменную вне области действия цикла:

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

Однако переменные, определенные в foreachцикле, не могут использоваться вне цикла:

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

Таким образом, компилятор объявляет переменную таким образом, что она очень подвержена ошибкам, которые часто трудно найти и отладить, но не дает ощутимых преимуществ.

Есть ли что-то, что вы можете сделать с foreachциклами таким образом, что вы не смогли бы, если бы они были скомпилированы с переменной внутренней области видимости, или это просто произвольный выбор, который был сделан до того, как анонимные методы и лямбда-выражения были доступны или распространены, и который не было пересмотрено с тех пор?

StriplingWarrior
источник
4
Что не так с String s; foreach (s in strings) { ... }?
Брэд Кристи
5
@BradChristie ОП на самом деле говорит не о foreachлямда-выражениях, которые приводят к тому же коду, как показано ОП ...
Яхья
22
@BradChristie: Это компилируется? ( Ошибка: тип и идентификатор оба требуются в выражении foreach для меня)
Остин Салонен
32
@JakobBotschNielsen: Это закрытый внешний локальный лямбда; почему вы предполагаете, что он вообще будет в стеке? Это время жизни дольше стека !
Эрик Липперт
3
@EricLippert: я в замешательстве. Я понимаю, что лямбда захватывает ссылку на переменную foreach (которая внутренне объявлена вне цикла), и поэтому вы в конечном итоге сравниваете ее с окончательным значением; что я получаю. Что я не понимаю, так это то, как объявление переменной внутри цикла будет иметь какое-либо значение. С точки зрения компилятора, я размещаю только одну строковую ссылку (var 's') в стеке независимо от того, находится объявление внутри или вне цикла; Я, конечно, не хотел бы помещать новую ссылку в стек каждую итерацию!
Энтони

Ответы:

1407

Компилятор объявляет переменную таким образом, что она очень подвержена ошибкам, которые часто трудно найти и отладить, но при этом не дает ощутимых преимуществ.

Ваша критика полностью оправдана.

Я обсуждаю эту проблему подробно здесь:

Закрытие по переменной цикла считается вредным

Есть ли что-то, что вы можете сделать с циклами foreach таким образом, что вы не смогли бы, если бы они были скомпилированы с переменной внутренней области видимости? или это просто произвольный выбор, который был сделан до того, как анонимные методы и лямбда-выражения были доступны или распространены, и который с тех пор не пересматривался?

Последний. Спецификация C # 1.0 фактически не говорила, была ли переменная цикла внутри или снаружи тела цикла, так как она не имела заметного различия. Когда семантика замыкания была введена в C # 2.0, был сделан выбор поместить переменную цикла вне цикла, в соответствии с циклом «for».

Я думаю, что будет справедливо сказать, что все сожалеют об этом решении. Это одна из худших ошибок в C #, и мы собираемся предпринять решительные изменения, чтобы исправить это. В C # 5 переменная цикла foreach будет логически находиться внутри тела цикла, и поэтому замыкания будут каждый раз получать новую копию.

forЦикл не будет изменен, и изменение не будет «обратно портированы» в предыдущие версии C #. Поэтому вы должны продолжать соблюдать осторожность при использовании этой идиомы.

Эрик Липперт
источник
177
Мы действительно отодвинули это изменение в C # 3 и C # 4. Когда мы разработали C # 3, мы поняли, что проблема (которая уже существовала в C # 2) будет ухудшаться, потому что будет очень много лямбд (и запрос понимания, которые являются скрытыми лямбдами) в циклах foreach благодаря LINQ. Я сожалею, что мы подождали, пока проблема станет достаточно серьезной, чтобы оправдать ее исправление настолько поздно, чем исправление в C # 3.
Эрик Липперт
75
И теперь мы должны будем помнить foreach это «безопасно», но forэто не так.
Леппи
22
@michielvoo: изменение ломается в том смысле, что оно не имеет обратной совместимости. Новый код не будет работать правильно при использовании старого компилятора.
Леппи
41
@ Бенджол: Нет, поэтому мы готовы принять это. Джон Скит указал мне на важный сценарий критических изменений, который заключается в том, что кто-то пишет код в C # 5, тестирует его, а затем делится им с людьми, которые все еще используют C # 4, которые затем наивно полагают, что это правильно. Надеюсь, что количество людей, затронутых таким сценарием, невелико.
Эрик Липперт
29
Кроме того, ReSharper всегда ловил это и сообщает об этом как "доступ к измененному закрытию". Затем, нажав Alt + Enter, он автоматически исправит ваш код за вас. jetbrains.com/resharper
Майк Чемберлен
191

То, о чем вы спрашиваете, подробно освещено Эриком Липпертом в его блоге. Закрытие по переменной цикла, считающейся вредной, и ее продолжение.

Для меня наиболее убедительным аргументом является то, что наличие новой переменной в каждой итерации несовместимо с for(;;)циклом стиля. Ожидаете ли вы иметь новое int iв каждой итерации for (int i = 0; i < 10; i++)?

Наиболее распространенная проблема с этим поведением заключается в закрытии переменной итерации, и она имеет простой обходной путь:

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

Мой пост в блоге об этой проблеме: закрытие над переменной foreach в C # .

Krizz
источник
18
В конечном счете, то, что люди на самом деле хотят, когда они пишут, это не иметь несколько переменных, а закрыть их по значению . И в общем случае сложно придумать подходящий для этого синтаксис.
Random832
1
Да, по значению закрыть невозможно, однако есть очень простой обходной путь, который я только что отредактировал, чтобы включить в него мой ответ.
Krizz
6
Это слишком плохие замыкания в C # по ссылкам. Если бы они закрывали значения по умолчанию, мы могли бы легко указать закрывающие переменные вместо ref.
Шон У
2
@ Криз, это тот случай, когда принудительная согласованность более вредна, чем непоследовательна. Он должен «просто работать» так, как ожидают люди, и, очевидно, люди ожидают чего-то другого, когда используют foreach, а не цикл for, учитывая количество людей, столкнувшихся с проблемами до того, как мы узнали о доступе к измененной проблеме закрытия (например, я). ,
Энди
2
@ Random832 не знает о C #, но в Common LISP есть синтаксис для этого, и он показывает, что любой язык с изменяемыми переменными и замыканиями будет (нет, должен ) иметь его тоже. Мы либо закрываем ссылку на изменяющееся место, либо значение, которое оно имеет в данный момент времени (создание замыкания). Здесь обсуждаются похожие вещи в Python и Scheme ( cutдля refs / vars и cuteдля хранения оцененных значений в частично оцененных замыканиях).
Уилл Несс
103

Будучи укушенным этим, я имею привычку включать локально определенные переменные во внутреннюю область видимости, которую я использую для переноса в любое замыкание. В вашем примере:

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

Я делаю:

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

Если у вас есть эта привычка, вы можете избежать ее в очень редком случае, который вы действительно намеревались привязать к внешним областям. Если честно, я не думаю, что когда-либо делал это.

Godeke
источник
24
Это типичный обходной путь. Спасибо за вклад. Решарпер достаточно умен, чтобы распознать этот паттерн и обратить на него внимание, что приятно. В течение некоторого времени я не обращал внимания на этот шаблон, но, поскольку он, по словам Эрика Липперта, «самый распространенный неправильный отчет об ошибках, который мы получаем», мне было любопытно узнать, почему, а не как его избежать .
StriplingWarrior
62

В C # 5.0 эта проблема исправлена, и вы можете закрыть переменные цикла и получить ожидаемые результаты.

Спецификация языка говорит:

8.8.4 Утверждение foreach

(...)

Формула foreach

foreach (V v in x) embedded-statement

затем расширяется до:

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

(...)

Размещение vвнутри цикла while важно для того, как он захватывается любой анонимной функцией, встречающейся во встроенном выражении. Например:

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

Если бы он vбыл объявлен вне цикла while, он был бы общим для всех итераций, и его значение после цикла for было бы конечным значением 13, которое fбудет выводить вызов . Вместо этого, поскольку каждая итерация имеет свою собственную переменную v, переменная , захваченная fна первой итерации, будет продолжать содержать значение 7, которое будет напечатано. ( Примечание: более ранние версии C # объявлены vвне цикла while. )

Паоло Моретти
источник
1
Почему эта ранняя версия C # объявила v внутри цикла while? msdn.microsoft.com/en-GB/library/aa664754.aspx
colinfang,
4
@colinfang Обязательно прочитайте ответ Эрика : спецификация C # 1.0 ( в вашей ссылке мы говорим о VS 2003, то есть C # 1.2 ) на самом деле не говорила, была ли переменная цикла внутри или вне тела цикла, так как она не делает видимой разницы , Когда семантика замыкания была введена в C # 2.0, был сделан выбор поместить переменную цикла вне цикла, в соответствии с циклом «for».
Паоло Моретти
1
То есть вы говорите, что примеры, приведенные в ссылке, в то время не были окончательными?
colinfang
4
@colinfang Это были точные спецификации. Проблема в том, что мы говорим о функции (то есть замыканиях функций), которая была представлена ​​позже (с C # 2.0). Когда появился C # 2.0, они решили поместить переменную цикла вне цикла. И затем они снова передумали с C # 5.0 :)
Паоло Моретти