Avatar ·

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

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

Причина проблемы: захват переменной по ссылке в замыкании

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

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

Когда цикл завершается, переменная number содержит последнее значение (5). Все созданные делегаты Action при вызове читают текущее значение этой единственной переменной — то есть 5. Отсюда и вывод пяти пятёрок.

Для наглядности, вот что происходит "под капотом" (упрощённо):

// Компилятор генерирует что-то вроде:
int number; // Одна переменная!
var enumerator = numbers.GetEnumerator();
while (enumerator.MoveNext())
{
    number = enumerator.Current; // Просто присваивание, не новая переменная
    actions.Add(() => Console.WriteLine(number)); // Захват ссылки на number
}
// После цикла number == 5
// Все лямбды ссылаются на эту же number

Как это исправить?

Начиная с C# 5.0 (2012 год) поведение foreach было изменено: теперь переменная цикла создаётся заново на каждой итерации. Если вы используете .NET Framework 4.5+ или .NET Core / .NET 5+, ваш код будет работать как ожидалось:

// C# 5.0+ — работает правильно!
var actions = new List<Action>();
var numbers = new List<int> { 1, 2, 3, 4, 5 };

foreach (var number in numbers)
{
    // Каждая итерация получает свою копию number
    actions.Add(() => Console.WriteLine(number));
}

foreach (var action in actions)
{
    action(); // Выведет: 1 2 3 4 5
}

Для старых версий C# (до 5.0) — временная переменная

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

foreach (var number in numbers)
{
    var copy = number; // Создаём копию на каждой итерации
    actions.Add(() => Console.WriteLine(copy));
}

Теперь каждая лямбда захватывает свою собственную переменную copy, которая не изменяется на следующих итерациях.

Почему for работает иначе?

С циклом for ситуация обратная:

for (int i = 0; i < numbers.Count; i++)
{
    actions.Add(() => Console.WriteLine(numbers[i]));
    // Проблема та же! i — одна переменная, захватывается по ссылке
}

Здесь тоже будет захвачена одна переменная i со значением 5. Исправление аналогичное — копия:

for (int i = 0; i < numbers.Count; i++)
{
    var index = i; // Копия
    actions.Add(() => Console.WriteLine(numbers[index]));
}

Вывод

  • В C# до версии 5.0 переменная foreach — одна на весь цикл.
  • Начиная с C# 5.0 — создаётся новая на каждой итерации (это исправление было внесено именно из-за этой проблемы).
  • Для for проблема остаётся во всех версиях.
  • Универсальное решение: создавайте локальную копию внутри цикла.
<<>>

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

Если ваша цель — просто выполнить действие для каждого элемента, можно обойтись без явного создания списка делегатов. Используйте LINQ с ToList() и ForEach():

var numbers = new List<int> { 1, 2, 3, 4, 5 };

// Вариант 1: Immediate execution
numbers.ForEach(n => Console.WriteLine(n));

// Вариант 2: Создание списка действий через Select
var actions = numbers.Select(n => (Action)(() => Console.WriteLine(n))).ToList();

foreach (var action in actions)
{
    action(); // Работает корректно в C# 5.0+
}

Второй вариант интересен тем, что Select передаёт каждый элемент n как параметр лямбды, а не захватывает переменную цикла. Параметр метода — это всегда новая переменная на каждый вызов.

Также можно использовать Parallel.ForEach для параллельного выполнения:

Parallel.ForEach(numbers, n => Console.WriteLine(n));

Это не только решает проблему замыканий, но и улучшает производительность на многоядерных процессорах. Однако помните о потокобезопасности, если разделяете общие ресурсы.

Лично я предпочитаю функциональный подход с LINQ — он более декларативный и реже приводит к скрытым ошибкам с захватом переменных.