Многие разработчики языков программирования, библиотек, да и классов простых приложений стремятся к интуитивно понятному интерфейсу создаваемых классов. Скотт Мейерс еще полтора десятка лет назад сказал о том, чтобы мы стремились разрабатывать классы (библиотеки, языки), которые легко использовать правильно, и сложно использовать неправильно.
Если говорить о языке 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 никак не изменилась и при захвате переменной цикла, вам все еще нужно самим создавать временную переменную внутри каждой итерации.
Не знаю стоило ли ломать обратную совместимость, да ещё и так явно.
ОтветитьУдалитьВидно действительно достали. :)
И теперь разница в замыканиях foreach и for, как-то...
Обратная совместимость здесь весьма условная, поскольку вероятность существования корректного кода, который бы использовал старое поведение очень мала.
ОтветитьУдалитьА вот разное поведение циклов for и foreach - это да. Но, насколько я понял, это не Эрик принимал решение, а выше.
Да, на счёт совместимости согласен "корректный код" скорее всего это поведение как раз обходил.
ОтветитьУдалитьКажись я понял почему for и foreach работают по разному - в for можно принести внешнюю переменную:
int i = 0;
for(; i<5; i++)
а её "пересоздавать" на каждой итерации нельзя...
Это труба. Потом будут появляться "маги", заменяющие for на foreach и говорящие что for неправильный... ппц
ОтветитьУдалитьvar mas = Enumerable.Range(1, 3).ToArray();
ОтветитьУдалитьArray.ForEach(mas, (el) => { actions.Add(new Action(() => { Console.WriteLine(el); })); });
Прекрасно работает. Левая рука не знает, что творит правая.
Нет, просто здесь принципиально другой код получается.
ОтветитьУдалитьВ вашем примере вы замыкаетесь только на переменную actions, а "переменная цикла" el передается в виде параметра в лямбда-выражение.
Так что все руки знают, что они делают и я пока что не вижу никаких противоречий.
var mas = Enumerable.Range(1, 3).ToArray();
ОтветитьУдалитьArray.ForEach(mas, (el) => { actions.Add(() => { Console.WriteLine(el); }); });
так и это работает?
А ну это тоже самое :)
ОтветитьУдалитьА так: Array.ForEach(mas, (el) => { Console.WriteLine(el); });
ОтветитьУдалить?
Нормально все выдает.
Извините, что долго соображал, в моем примере каждый элемент массива el представляет собой отдельную переменную, поэтому замыкание захватывает ее правильно.
ОтветитьУдалитьДа, совершенно верно (по поводу последнего сообщения). В данном случае на каждый вызов новая переменная, вот поэтому и проблем никаких нет.
ОтветитьУдалитьC# пошел по скользкой дороге...
ОтветитьУдалить@Alexander: глубокомысленно;)
ОтветитьУдалить