О вреде замыканий на переменных цикла


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

Но я забегаю немного вперед. Что выведет следующий фрагмент кода?

var values = new List<int>() { 100, 110, 120 };
var funcs = new List<Func<int>>();
foreach(var v in values)
  funcs.Add( ()=>v );
foreach(var f in funcs)
  Console.WriteLine(f());

Большая часть людей считает, что в результате будет: 100 / 110 / 120. А на самом деле: 120 / 120 / 120. Почему?

Потому что код () =>v означает «вернуть текущее значение переменной v», а не «вернуть значение переменной v, которое было до создания делегата». Замыкания применяются к переменным, а не к значениям переменных. При выполнении, совершенно очевидно, что последнее значение, присвоенное переменной v было 120, и значение переменной v остается именно таким.

Это здорово сбивает с толку. Вот правильный способ:

foreach(var v in values)
{
  var v2 = v;
  funcs.Add( ()=>v2 );
}

Что происходит в этом случае? Каждый раз, при выполнении тела цикла мы логически создаем новую переменную v2. Теперь замыкание каждый раз производится на разную переменную v2, значение которой устанавливается только один раз, сохраняя при этом корректное значение.

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

{
    IEnumerator<int> e = ((IEnumerable<int>)values).GetEnumerator();
    try
    {
      int m; // ЗА ЦИКЛОМ foreach
      while(e.MoveNext())
      {
        m = (int)(int)e.Current;
        funcs.Add(()=>m);
      }
    }
    finally
    {
      if (e != null) ((IDisposable)e).Dispose();
    }
  }

При объявлении локальной переменной код будет таким:

try
    {
      while(e.MoveNext())
      {
        int m; // ВНУТРИ ЦИКЛА
        m = (int)(int)e.Current;
        funcs.Add(()=>m);
      }

В этом случае поведение будет отвечать ожидаемому.

Необходимо рассмотреть возможность исправления такого поведения в будущих версиях языка C#, и я бы хотел услышать ваше мнение о том, стоит ли исправлять это поведение или нет. Причины, по которым СТОИТ изменить это поведение довольно ясны. Текущее поведение сбивает с толку многих людей, и LINQ, к сожалению, только ухудшает ситуацию, увеличивая количество возможностей применения замыканий в телах циклов. Кроме того, кажется вполне разумными ожидания пользователя, что внутри foreach цикла всегда содержится «новая» переменная, а не новое значение старой переменной. Отсутствие возможности изменений этой переменной пользователем подтверждает мнение о том, что в цикле применяется набор переменных (одна для каждой итерации), а не одна и та же переменная снова и снова. В конечном счете, такое изменение повлияет только на замыкания. (На самом деле, в спецификации языка C# 1.0 не было точных разъяснений на счет того, является ли переменная цикла одной и той же переменной или нет, т.к. без замыканий нет никакой разницы). Но чтобы не говорили, существует причины не делать никаких изменений.

Первая причина состоит в том, что такое изменение, очевидно, приведет к нарушению работы существующего кода. А мы ненавидим это. Будет нарушена работа кода всех разработчиков, которые применяют эту возможность и рассчитывают, что замыкание на переменную цикла будет указывать на последнее значение переменной. Я могу только надеяться, что количество таких людей будет очень небольшим. Весьма странно, если кто-то будет рассчитывать на такое поведение. В основном люди не ожидают и не рассчитывают на него.

Во-вторых, такое изменение приведет к непоследовательности синтаксиса цикла foreach. Давайте рассмотрим следующий фрагмент: foreach(int x in M()). Заголовок цикла состоит из двух частей: объявления int x и выражения, возвращающего коллекцию, M(). Выражение int x находится слева от выражения M(). Очевидно, что выражение M() находится снаружи цикла и выполняется только один раз, перед началом выполнения цикла. Так почему же, что-то слева от выражения, возвращающего коллекцию, должно быть внутри цикла? Это противоречит нашему основному правилу, когда что-то слева логически «происходит до того», как выполняется что-то справа. С точки зрения лексики языка, объявление происходит ВНЕ тела цикла. Так почему же мы должны трактовать объявление переменной внутри цикла?

В-третьих, это приведет к тому, что семантика цикла “foreach” будет противоречить семантике цикла “for”. У нас такая же проблема имеется и с циклом “for”, но понятие «переменная цикла» для блоков “for” значительно уже. В заголовке цикла “for” может быть объявлено более одной переменной, эти переменные могут инкрементироваться различными способами, и кажется невероятным, что люди будут рассматривать, будто каждая итерация цикла “for” содержит свой набор переменных. Когда вы пишите for(int i; i < 10; i += 1) кажется совершенно очевидным, что "i += 1" означает «увеличить переменную цикла» и что цикл содержит одну переменную для всего цикла, а не новую переменную “i” для каждой итерации! И мы конечно же не будем вносить подобное изменение для циклов “for”.

В-четвертых, хотя эта проблема неприятна, существует простые обходные пути. Инструменты вроде ReSharper-а определяют подобную ситуацию и предлагают советы, как ее исправить. Мы также можем определять эту ситуацию во время компиляции и выдавать предупреждение. (Хотя добавление новых предупреждений может приводить к множеству своих проблем, о которых я расскажу как-нибудь в другой раз.) Хотя это и неприятно, на самом деле, не так много людей сталкиваются с такой проблемой, которую, к тому же, довольно легко обойти. Поэтому, зачем нам напрягаться и тратить средства на изменения, которые так легко обойти?

Проектирование – это, прежде всего, искусство компромисса перед лицом противоречивых правил. «Устранить глюк» в этом случае противоречит принципу «отсутствие ломающих изменений» и «быть последовательным с другими возможностями языка». Мы были бы очень благодарны за любые ваши мысли «за и против» этого изменения в будущих версиях языка C#.

Оригинальное сообщение: Closing over the loop variable considered harmful

Comments (2)

  1. konstardiy says:

    May be add new constructs?

    something like

    for every (T x in enumerable)

    {

    ///

    }

    and

    foreach every (T v = startvalue;v++;v < stopvalue)

    {

    }

    where behavior is like variables are "inside" cycle?

  2. Sergey Teplyakov says:

    Эрик отвечает на этот вопрос во втором посте, посвященному замыканиям (http://blogs.msdn.com/ruericlippert/archive/2009/11/16/9984832.aspx). Если кратко, то подобные изменения не были приняты именно потому, что пользователю нужно будет знать о втором варианте синтаксиса  (т.е. он все равно должен знать о проблеме замыканий на переменные цикла). Так если человеку все равно нужно знать об этой проблеме, тогда в чем смысл нового синтаксиса? Он с тем же успехом может добавить локальную переменную и решить эту проблему.

Skip to main content