Многие разработчики языков программирования, библиотек, да и классов простых приложений стремятся к интуитивно понятному интерфейсу создаваемых классов. Скотт Мейерс еще полтора десятка лет назад сказал о том, чтобы мы стремились разрабатывать классы (библиотеки, языки), которые легко использовать правильно, и сложно использовать неправильно.
Если говорить о языке 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 никак не изменилась и при захвате переменной цикла, вам все еще нужно самим создавать временную переменную внутри каждой итерации.
Дополнительные ссылки
- Eric Lippert Closing over loop variable considered harmful
- Eric Lippert Closing over loop variable, part two
- Замыкания в языке C#
- Visual C# Breaking Changes in Visual Studio 11 Beta
Автор: SergeyT