Почему в C# переменная цикла foreach захватывается по ссылке в лямбда-выражении?
Причина проблемы: захват переменной по ссылке в замыкании
Вы столкнулись с классической проблемой, связанной с тем, как компилятор 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 — он более декларативный и реже приводит к скрытым ошибкам с захватом переменных.