Пользователь
0,0
рейтинг
2 апреля 2012 в 21:48

Разработка → Замыкания на переменных цикла в C# 5

.NET*
Многие разработчики языков программирования, библиотек, да и классов простых приложений стремятся к интуитивно понятному интерфейсу создаваемых классов. Скотт Мейерс еще полтора десятка лет назад сказал о том, чтобы мы стремились разрабатывать классы (библиотеки, языки), которые легко использовать правильно, и сложно использовать неправильно.

Если говорить о языке C#, то его разработчики подходят к вопросам «юзабилити» весьма основательно; они спокойно могут пожертвовать «объектной чистотой» в угоду здравому смыслу и удобству использования. Одним из немногих исключений из этого правила является замыкание на переменной цикла, той самой фичи, которая ведет себя не так, как считают многие разработчики. При этом количество недовольства и недопонимания настолько много, что в 5-й версии языка C# это поведение решили изменить.

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

var actions = new List<Action>();
foreach(var i in Enumerable.Range(1, 3))
{
    actions.Add(() => Console.WriteLine(i));
}

foreach(var action in actions)
{
    action();
}


Большинство разработчиков разумно предполагают, что результатом выполнения этого кода будет “1 2 3”, поскольку на каждой итерации цикла мы добавляем в список анонимный метод, который выводит на экран новое значение i. Однако если запустить этот фрагмент кода в VS2008 или VS2010, то мы получим “3 3 3”. Эта проблема настолько типична, что некоторые тулы, например, ReSharper, выдает предупреждение в строке actions.Add() о том, что мы захватываем изменяемую переменную, а Эрик Липперт настолько задолбался отвечать всем, что это фича, а не баг, что решил изменить существующее поведение в C# 5.0.

Чтобы понять, почему данный фрагмент кода ведет себя именно так, а не иначе, давайте рассмотрим, во что компилятор разворачивает этот кода (я не буду слишком сильно углубляться в детали работы замыканий в языке C#, за подробностями обращайтесь к заметке “Замыкания в языке C#”).

В языке C# захват внешних переменных осуществляется «по ссылке», и в нашем случае это означает, что переменная i исчезает из стека и становится полем специально сгенерированного класса, в который затем помещается и тело анонимного метода:

// Упрощенная реализация объекта-замыкания
class Closure
{
    public int i;
    public void Action()
    {
        Console.WriteLine(i);
    }
}
var actions = new List<Action>();
 
using (var enumerator = Enumerable.Range(1, 3).GetEnumerator())
{
    // int current;
    // создается один объект замыкания
    var closure = new Closure();
    while(enumerator.MoveNext())
    {
        // current = enumerator.Current;
        // и он используется во всех итерациях цикла foreach
        closure.i = enumerator.Current;
        var action = new Action(closure.Action);
        actions.Add(action);
    }
}
 
foreach (var action in actions)
{
    action();
}


Поскольку внутри цикла используется один объект Closure, то после завершения первого цикла, closure.i будет равно 3, а поскольку переменная actions содержит три ссылки на один и тот же объект Closure, то не удивительно, что при последующем вызове методов closure.Action() мы получим на экране “3 3 3”.

Изменения в C# 5.0

Изменения в языке C# 5.0 не касаются замыканий как таковых и мы, как замыкались на переменные (и не делаем копии значений), так и замыкаемся. На самом деле, изменения касаются того, во что разворачивается цикл foreach. Замыкания в языке C# реализованы таким образом, что для каждой области видимости (scope), в которой содержится захватываемая переменная, создается собственный экземпляр класса замыкания. Именно поэтому, для того, чтобы получить желаемое поведение в предыдущих версиях языка C#, достаточно было написать следующее:

var actions = new List<Action>();
foreach(var i in Enumerable.Range(1, 3))
{
    var tmp = i;
    actions.Add(() => Console.WriteLine(tmp));
}


Если вернуться к нашему упрощенному примеру с классом Closure, то данное изменение приводит к тому, что создание нового экземпляра Closure происходит внутри цикла while, что приводит к сохранению нужного значения переменной i:

using (var enumerator = Enumerable.Range(1, 3).GetEnumerator())
{
    int current;
    while(enumerator.MoveNext())
    {
        current = enumerator.Current;
        // Теперь для каждой итерации цикла мы создаем
        // новый объект Closure с новым значением i
        var closure = new Closure {i = current};
        var action = new Action(closure.Action);
        actions.Add(action);
    }
}


В C# 5.0 решили изменить цикл foreach таким образом, чтобы на каждой итерации цикла переменная i создавалась вновь. По сути, в предыдущих версиях языка C# в цикле foreach была лишь одна переменная цикла, а начиная с C# 5.0, используется новая переменная для каждой итерации.

Теперь исходный цикл foreach разворачивается по-другому:

using (var enumerator = Enumerable.Range(1, 3).GetEnumerator())
{
    // В C# 3.0 и 4.0 current объявлялась здесь
    //int current;
    while (enumerator.MoveNext())
    {
        // В C# 5.0 current объявляется заново для каждой итерации
        var current = enumerator.Current;
        actions.Add(() => Console.WriteLine(current));
    }
}


Это делает временную переменную внутри цикла foreach излишней (поскольку ее добавил для нас компилятор), и при запуске этого кода мы получим ожидаемые “1 2 3”.

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

Дополнительные ссылки

  1. Eric Lippert Closing over loop variable considered harmful
  2. Eric Lippert Closing over loop variable, part two
  3. Замыкания в языке C#
  4. Visual C# Breaking Changes in Visual Studio 11 Beta
Сергей Тепляков @SergeyT
карма
196,5
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (46)

  • +2
    Большинство разработчиков разумно предполагают, что результатом выполнения этого кода будет “1 2 3”
    Я бы не сказал, что разумно. Скорее неразумно, ибо такое предположение можно сделать лишь при полном непонимании того, как работают замыкания.
    • +1
      Очень странное нововведение, а вдруг в существующем коде где-то использовалось замыкание на current в благих целях?
      • 0
        промахнулся :-(.
      • 0
        В текущем виде оно приводит к неопределённому поведению (точнее, поведение зависит от времени, когда будет выполнен код замыкания), так что не думаю, что его кто-то в здравом уме мог использовать рассчитывая на текущее поведение.
        • +1
          Нет никакого неопределённого поведения. Оно не зависит от времени. В любой момент у нас чёткая зависимость от значения переменной-итератора цикла.
          • 0
            В общем, я так и не увидел живого примера того, зачем надо замыкаться на переменную foreach-цикла в расчёте на то, что значение изменится. Пока что только придирки к формулировкам.
    • +4
      Несколько сотен постов на stackoverflow говорят о том, что очень-но многие разработчики считают именно «1 2 3» естественным результатом.

      Естественно, после того, как вы узнали как это дело *устроено*, то все вопросы отпадают. Но ведь помимо нас с вами, есть еще пара миллионов индусов (это я про мировозрение, а не рассовую принадлежность), которые с нами не согласятся, поскольку они не знают да и не хотят знать, как это дело внутри устроено.
      • 0
        Ну я не спорю, что всё правильно сделали, просто такое поведение вытекает из общей логики работы циклов и замыканий.
        • 0
          Это вытекает из того, какое количество переменных существует в цикле foreach: одна на весь foreach или на каждую итерацию создается новая переменная.
          • 0
            Какому циклу первому учат в школе? for. В for переменная одна на весь цикл, есть явные конструкции управления её изменением и выходом из цикла. Следовательно, логичным было бы предположить, что в foreach так же, а конструкции скрыты от глаз (это если не читать стандарт ECMA, на одних предположениях из «логики вещей»). Логика тех, кто предполагает, что переменная каждый раз новая, мне не ясна.
            • 0
              А цикл for никак не изменился. В нем как была одна переменная на весь цикл, так и осталась.

              Здесь был вопрос компромисса: что лучше, пожертвовать согласованностью с циклом for или устранить одно из самых назойливых непониманий языка C#. При создании C# 2.0 решили отдать предпочтение согласованности с циклом for, но со временем решили, что этой согласованностью стоит пожертвовать.
              • 0
                Эм. Где я сказал, что цикл for изменился?
                • 0
                  Нет, нигде. Так, на всяк случай напомнил.

                  Мой (точнее разработчиков C#) rationale во втором абзаце.
      • +5
        Несколько сотен постов на stackoverflow говорят о том, что очень-но многие разработчики считают именно «1 2 3» естественным результатом.

        Теперь будет полторы тысячи вопросов от тех же индусов — почему в foreach все работает «правильно», а в цикле for «мистика».
      • +1
        Zanuda mode=on
        Индус — не расовая принадлежность, а вероисповедание. Индус — это приверженец индуизма. А расовая принадлежность это индиец.
        Извините :)
    • 0
      Поскольку большинство разработчиков не представляют себе как работает .net внутри, то утверждение про разумность имеет смысл.
      • 0
        Эм. А ничего что переменная объявлена вне скобочной конструкции? Т. е. долна быть одна на все итерации того, что внутри фигурных скобок?
    • 0
      На мой взгляд «неправильный» код выглядит куда более естественно, чем код со временной переменной. Можно понимать что он неправильный, и чувствовать, что всё равно хотелось бы писать так. Тут Липперт молодец.
      Вообще в C# еще несколько таких мест.
  • 0
    Мда… Мне как то изначальный вариант казался естественным… Это ж сломает обратную совместимость.
    • +1
      На самом деле не сломает. В цикле foreach всегда объявляется новая переменная. А поскольку раньше на неё никто не делал замыканий, то новая реализация ничего не сломает.
      • 0
        Ну Вы так ловко ответили прям за всех…
        • +3
          Ну приведите пример того, зачем нужно замыкаться на постоянно изменяющуюся переменную перечислителя цикла, рассчитывая при этом, что она будет изменена? Мне сложно представить ситуацию, когда такое может вообще понадобиться.
    • +3
      Впервые эту проблему поднял Эрик в конце 2009-го года и уже тогда обсуждались решения, как это можно пофиксить. Сейчас было принято решение, что нет никакого разумно корректного кода, который бы использовал эту фичу именно таким образом (т.е. чтобы намеренно замкнулся на переменную цикла, в надежде получить сотню одинаковых результатов).
  • 0
    Таким образом, в новой версии будет двойственное поведение переменных в такой конструкции:
    var actions = new List<Action>();
    var jj = 0;
    foreach(var i in Enumerable.Range(1, 3))
    {
        actions.Add(() => Console.WriteLine(i, jj));
        jj = jj +1;
    }
    
    foreach(var action in actions)
    {
        action(); //даст для новой версии(5): 1 3   2 3   3 3
    }
    

    Скорее всего массовое непонимание усугубляет тот факт, что в цикле в этом языке создаётся окружение, а не в функции, как, например, в javascript и perl.

    Но, впрочем, и замыкания в функциях в javascript по умолчанию никто не понимает, начинают понимать после того как напишут нечаянно замыкание, не зная об этом.
    • 0
      Поведение будет таким же «согласованным» как и ранее, но для этого нужно понимать две вещи: (1) экземпляр класса замыкания для каждой области видимости и (2) каждая итерация цикла foreach содержит новую переменную i.

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

      Т.е. этот код можно рассматривать таким образом:

      var actions = new List<Action>();
      var jjClosure = new jjClosure();
      jjClosure.jj = 0;
      
      foreach(var i in Enumerable.Range(1, 3))
      {
        // внутренний объект замыкания содержит ссылку на внешний объект замыкания
         var iClosure = new iClosure() {i = i, jjClosure = jjClosure};
      
         // () => Console.WriteLine(i, jj) теперь перехало в метод Action замыкания iClosure.
        // Тело iClosure.Action выглядит так:
        // Console.WriteLine(this.i, this.jjClosure.jj);
         actions.Add(iClosure.Action);
      }
      
      


      В результате чего мы и получим 1 3, 2 3, 3 3, поскольку всеми iClosure будет использоваться один и тот же объект jjClosure.
  • +1
    Эх, а ранее Липперт приводил вполне обоснованные аргументы, почему они не собираются делать это. Я лично и сам не знаю, как именно лучше — «чистота конструкции» или «интуитивная юзабельность в общем случае» — меня не напрягает то, как это есть сейчас. Да, про «Access to Modified Closure» знали не все. Видимо, Эрика всерьез достали с этим :)

    По идее это не должно сломать существующий код, так что ужасного в этом решении ничего нет. Хотя личное ощущение, что это в чистом виде хак (да, не «синтэкс шуга», а именно хак).
    • 0
      Раньше аргумент у него был один: не ломать старый код. Но он признавался, что они хотели это починить ещё в C# 3.0.
  • 0
    Cегодня написал подобный код, но мне правда решарпер подсказал, что не все в порядке. обратил мое внимание на то, что я переменную цикла в замыкание засунул. Я заменил тело цикла на вызов функции (ну там передача по значению).
    Вобщем будет удобнее в C#5.
  • 0
    Эта фича будет «компилироваться» только в .net 4.5?

    А то если будет в .net 4, то возможна ситуация, что один и тот же код скомпиленный в VS2010 (со старым поведением) не работает, а в VS11 — работает. Совместное использование студий будет тогда проблематично.
    • +2
      Это фича именно компилятора языка C# 5.0, причем не важно, на какую платформу мы таргетимся, т.е. при запуске этого кода из VS11 будет результат «1 2 3», даже если мы таргетимся на .NET Framework 3.5.

      Поэтому ситуация, когда из VS10 будет один результат, а из VS11 — другой, вполне возможна. В данном случае решением является использования «общего» подхода, которое подойдет для всех версий — т.е. явное использование темповой переменной внутри цикла.
  • 0
    В Mono 2.10.5 результат — 3, 3, 3

    Чую — путаница будет с этими замыканиями…

    Но идея мне кажется здравой — всегда подсознательно не понимал почему в циклах «int» пишут в скобочках, а не где-нибудь еще…

    Если введут эту фичу и для for, то в моей голове «все сложится»

    Старое поведение:
    int i;
    for( i=0; i<10; i++ )
    {
      /* Здесь работаем с одной и той же переменной */
    }
    

    Новое поведение:
    for( int i=0; i<10; i++ )
    {
      /* А здесь для каждой итерации генерируется новая переменная */
    }
    

    Осталось только дождаться когда Липперт соберется с духом и возьмет на себя ответственность за обратную совместимость… :)
    • +1
      Переменную цикла пишут в скобочках, потому что она локальная для этого цикла: дело не в том, одна она или на каждый чих новая, а в области видимости. Вне цикла её не должно быть
      • 0
        Спасибо за напоминание — это я усвоил, когда изучал циклы.
        И даже могу сказать почему этой фичи никогда не будет в for — все дело в обратной совместимости с такими конструкциями:
        for( int i=0; i<10; i++ )
        {
          if( true ) i+=3;
        }
        
        • 0
          Вот только внутри if должно стоять не true, а более сложное условие.
    • 0
      Ответьте мне, пожалуйста, каким образом конструкция i++ может быть применима к переменной, генерируемой для каждой итерации заново?
      • 0
        Нда — неправильно в комментариях мысль написал. Надо было писать:

        Новое поведение:
        for( int i=0; i<10; i++ )
        {
          /* А здесь для каждой итерации создается новый экземпляр Closure, что приводит к сохранению нужного значения переменной i */
        }
        </sorce>
        
        
        • 0
          for (int i=0; i<10; i++)
          {
            Action act = () => i--; // Так будет делать нельзя - переменная i стала вдруг привязанной к итерации
            i--; // а так по-прежнему можно?
          }
          


          Вам это странным не кажется?
          • 0
            Хм…
            А как Ваш пример работает в цикле foreach?
            Неужели мы получили еще одни грабли?
            • 0
              А цикле foreach мой пример не работает.
              Я не могу придумать ни одной ситуации, в которой понадобилось бы изменять переменную цикла.

              Потому в новой версии языка foreach и переделали, а for оставили.
  • 0
    А для чего вообще сделали такое поведение, как в 4.0? Это концепция или побочный эффект?
    • 0
      Побочный эффект на пересечении двух концепций.

      Читайте статью внимательнее.
  • 0
    Мне жутко не нравится то, что если код, написанный на C# 5 (не нужны временные переменные) откомпилировать на C# 4 (нужны временные переменные) то будет непредсказуемое поведение :(
    • 0
      а зачем это делать?
      в общем случае код просто не скомпилируется.
  • –1
    Спасибо за статью. Вообще в первый раз услышал про замыкания.
    Странно, что об этом не написал Рихтер)
    • 0
      На самом деле, у Рихтера об этом все написано, включая магию генерации классов замыканий при захвате внешних переменных из анонимного метода. Просто страшных терминов, типа closure у него нет.

      См. главу 17. Delegates раздел Syntactical Shortcut #3: No Need to Wrap Local Variables in a Class Manually to Pass Them to a Callback Method
  • 0
    Отличная статья, спасибо! Жаль, что разработчики C# идут на поводу горе-программистов… Для меня лично раньше всё было очевидно, а сейчас уже нет. Тем более, что for остался прежним. Такой подход идёт вразрез с логикой…

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.