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

в 17:48, , рубрики: .net, closures, метки: ,

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

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

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js