- PVSM.RU - https://www.pvsm.ru -

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

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

Если говорить о языке 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#” [1]).

В языке 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]
  2. Eric Lippert Closing over loop variable, part two [3]
  3. Замыкания в языке C# [1]
  4. Visual C# Breaking Changes in Visual Studio 11 Beta [4]

Автор: SergeyT


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/net/4703

Ссылки в тексте:

[1] “Замыкания в языке C#”: http://sergeyteplyakov.blogspot.com/2010/04/c.html

[2] Closing over loop variable considered harmful: http://blogs.msdn.com/b/ericlippert/archive/2009/11/12/closing-over-the-loop-variable-considered-harmful.aspx

[3] Closing over loop variable, part two: http://blogs.msdn.com/b/ericlippert/archive/2009/11/16/closing-over-the-loop-variable-part-two.aspx

[4] Visual C# Breaking Changes in Visual Studio 11 Beta: http://msdn.microsoft.com/en-us/library/hh678682(v=vs.110).aspx