понедельник, 2 апреля 2012 г.

Замыкание на переменных цикла в C# 5.0

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

Если говорить о языке 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

13 комментариев:

  1. Не знаю стоило ли ломать обратную совместимость, да ещё и так явно.

    Видно действительно достали. :)

    И теперь разница в замыканиях foreach и for, как-то...

    ОтветитьУдалить
  2. Обратная совместимость здесь весьма условная, поскольку вероятность существования корректного кода, который бы использовал старое поведение очень мала.

    А вот разное поведение циклов for и foreach - это да. Но, насколько я понял, это не Эрик принимал решение, а выше.

    ОтветитьУдалить
  3. Да, на счёт совместимости согласен "корректный код" скорее всего это поведение как раз обходил.

    Кажись я понял почему for и foreach работают по разному - в for можно принести внешнюю переменную:
    int i = 0;
    for(; i<5; i++)
    а её "пересоздавать" на каждой итерации нельзя...

    ОтветитьУдалить
  4. Это труба. Потом будут появляться "маги", заменяющие for на foreach и говорящие что for неправильный... ппц

    ОтветитьУдалить
  5. var mas = Enumerable.Range(1, 3).ToArray();
    Array.ForEach(mas, (el) => { actions.Add(new Action(() => { Console.WriteLine(el); })); });

    Прекрасно работает. Левая рука не знает, что творит правая.

    ОтветитьУдалить
  6. Нет, просто здесь принципиально другой код получается.

    В вашем примере вы замыкаетесь только на переменную actions, а "переменная цикла" el передается в виде параметра в лямбда-выражение.

    Так что все руки знают, что они делают и я пока что не вижу никаких противоречий.

    ОтветитьУдалить
  7. var mas = Enumerable.Range(1, 3).ToArray();
    Array.ForEach(mas, (el) => { actions.Add(() => { Console.WriteLine(el); }); });

    так и это работает?

    ОтветитьУдалить
  8. А так: Array.ForEach(mas, (el) => { Console.WriteLine(el); });

    ?

    Нормально все выдает.

    ОтветитьУдалить
  9. Извините, что долго соображал, в моем примере каждый элемент массива el представляет собой отдельную переменную, поэтому замыкание захватывает ее правильно.

    ОтветитьУдалить
  10. Да, совершенно верно (по поводу последнего сообщения). В данном случае на каждый вызов новая переменная, вот поэтому и проблем никаких нет.

    ОтветитьУдалить
  11. C# пошел по скользкой дороге...

    ОтветитьУдалить