Avatar ·

Почему в C# переменная цикла foreach захватывается лямбдой некорректно?

Всем привет! Я начал изучать C# недавно и столкнулся с очень странным поведением при использовании лямбда-выражений внутри цикла `foreach`. У меня есть простой код, где я создаю список действий, которые должны выводить числа от 0 до 4: ```csharp var actions = new List(); for (int i = 0; i < 5; i++) { actions.Add(() => Console.WriteLine(i)); } foreach (var action in actions) { action(); } ``` Этот код выводит: 5, 5, 5, 5, 5. Я понимаю, что это из-за того, что переменная `i` захватывается по ссылке, и к моменту вызова лямбд она уже равна 5. Это логично. Но когда я делаю то же самое с `foreach`: ```csharp var actions = new List(); foreach (var i in Enumerable.Range(0, 5)) { actions.Add(() => Console.WriteLine(i)); } foreach (var action in actions) { action(); } ``` В старых версиях C# (до 5.0) этот код тоже выводил 5, 5, 5, 5, 5. Но начиная с C# 5.0 он выводит 0, 1, 2, 3, 4. Почему так? Ведь в `foreach` переменная цикла тоже одна на всю итерацию, и, казалось бы, должна захватываться так же, как в `for`. Зачем было менять поведение? И есть ли какой-то подвох, о котором нужно знать? Буду благодарен за разъяснение с примерами! <<>>

История проблемы: почему в C# 5.0 изменили поведение foreach

Вы абсолютно правы: в C# до версии 5.0 поведение foreach было таким же "проблемным", как и у for. Это было одним из самых частых источников багов у разработчиков. Давайте разберемся, в чем тут дело и почему это изменили.

Как работает захват переменных в C#

Когда лямбда-выражение захватывает переменную, она захватывается по ссылке, а не по значению. Это значит, что лямбда хранит не копию значения переменной на момент создания, а ссылку на саму переменную. Когда вы потом вызываете лямбду, она читает текущее значение этой переменной.

Рассмотрим цикл for:

var actions = new List<Action>();
for (int i = 0; i < 5; i++)
{
    actions.Add(() => Console.WriteLine(i));
}
// К моменту вызова i == 5
foreach (var action in actions)
{
    action(); // Вывод: 5, 5, 5, 5, 5
}

Здесь переменная i одна на весь цикл. После завершения цикла i = 5. Все лямбды ссылаются на одну и ту же переменную, поэтому выводят 5.

Проблема с foreach до C# 5.0

До C# 5.0 компилятор обрабатывал foreach так же, как for — создавал одну переменную для всего цикла. Это приводило к такому же поведению:

// Старое поведение (C# 4.0 и ниже)
foreach (var i in Enumerable.Range(0, 5))
{
    actions.Add(() => Console.WriteLine(i));
}
// Вывод: 5, 5, 5, 5, 5 (все лямбды ссылаются на одну переменную)

Что изменилось в C# 5.0

Начиная с C# 5.0, компилятор изменил поведение foreach: теперь переменная цикла логически создается заново на каждой итерации. То есть для каждого шага цикла создается новая переменная, и лямбда захватывает именно эту уникальную переменную.

Теперь код работает так, как интуитивно ожидает большинство разработчиков:

// Новое поведение (C# 5.0+)
foreach (var i in Enumerable.Range(0, 5))
{
    // На каждой итерации создается НОВАЯ переменная i
    actions.Add(() => Console.WriteLine(i));
}
// Вывод: 0, 1, 2, 3, 4

Почему не изменили for?

Цикл for оставили без изменений, потому что его поведение более предсказуемо и соответствует ожиданиям опытных разработчиков. Переменная счетчика i в for явно принадлежит всему циклу, и захват по ссылке — это ожидаемое поведение. К тому же, изменение for сломало бы огромное количество существующего кода.

Как обойти проблему в for

Если вам нужно корректно захватывать переменную в цикле for, используйте временную переменную внутри тела цикла:

for (int i = 0; i < 5; i++)
{
    int temp = i; // Создаем новую переменную на каждой итерации
    actions.Add(() => Console.WriteLine(temp));
}
// Вывод: 0, 1, 2, 3, 4

Важные нюансы

  • Изменение внутри цикла: Даже в C# 5.0+, если вы измените переменную foreach внутри тела цикла, это повлияет только на текущую итерацию, но не на уже захваченные лямбды.
  • Async лямбды: Будьте осторожны с асинхронными вызовами. Даже с новым поведением, если вы запускаете асинхронную операцию, порядок выполнения может быть непредсказуемым.
  • Старые версии .NET: Если вы пишете код для .NET Framework 4.0 или ниже, вы все еще можете столкнуться со старым поведением. Используйте временную переменную для страховки.

Таким образом, изменение в C# 5.0 было сделано для устранения наиболее частого источника багов, связанных с замыканиями в циклах foreach. Это сделало язык более интуитивным для новичков, не нарушив обратную совместимость для for.

<<>>

Альтернативный взгляд: когда "старое" поведение было полезно

Хотя изменение в C# 5.0 сделало foreach более интуитивным, стоит отметить, что "старое" поведение (с захватом одной переменной) иногда было полезно для некоторых шаблонов оптимизации.

Например, если вы создаете множество делегатов в цикле и знаете, что они будут вызваны только после завершения цикла, старое поведение позволяло экономить память — создавалась одна переменная вместо N. В современном C# это преимущество исчезло, так как компилятор все равно создает отдельный объект замыкания для каждой итерации.

Если вы пишете код, который должен работать как в старых, так и в новых версиях C#, лучшая практика — всегда явно копировать переменную во временную, даже в foreach:

foreach (var item in collection)
{
    var captured = item; // Явное копирование для единообразия
    actions.Add(() => Console.WriteLine(captured));
}

Этот подход гарантирует корректное поведение во всех версиях C# и делает код более явным. Помните: явное лучше неявного, особенно когда речь идет о замыканиях.