Почему C# переиспользует переменную в foreach? Разбор для новичков

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: загадка, которая мучает новичков

Представьте себе ситуацию: вы пишете простой цикл foreach на C#, сохраняете лямбда-выражение или запускаете асинхронную задачу внутри цикла, и вдруг получаете совершенно неожиданный результат. Все переменные, которые вы, казалось бы, аккуратно "захватили", вдруг ссылаются на последний элемент коллекции. Знакомо?

На Stack Overflow вопрос "Is there a reason for C#'s reuse of the variable in a foreach?" собрал тысячи просмотров и множество комментариев. Это не просто академический интерес — это практическая ловушка, в которую попадаются даже опытные разработчики. В этой статье мы разберемся, почему C# так делает, как это менялось с версиями языка и, самое главное, как избежать связанных с этим ошибок.

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

Суть проблемы: что значит "переиспользование переменной"?

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

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

var actions = new List<Action>();
var numbers = new List<int> { 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. Но на самом деле (до C# 5) он вывел бы 5, 5, 5, 5, 5.

Почему? Потому что переменная number существует в единственном экземпляре на протяжении всего цикла. Когда цикл завершается, её значение равно последнему элементу (5). Все лямбды захватили ссылку на эту переменную, а не её значение на момент создания.

Аналогия для понимания

Представьте, что у вас есть коробка (переменная), и вы кладете в неё по очереди разные предметы (значения). Вы даете друзьям (лямбдам) не сами предметы, а ключ от коробки. Когда друзья приходят забирать свои предметы, они открывают коробку, а там — последний предмет, который вы туда положили. Вот так работает старый foreach.

Исторический контекст: почему так сделали?

Многие новички спрашивают: "Это же баг! Зачем так делать?" На самом деле, это было осознанное решение разработчиков C#, основанное на производительности и простоте компиляции.

  • Производительность: Создание новой переменной на каждой итерации — это дополнительная аллокация памяти. Для большинства простых циклов это не критично, но в высоконагруженных системах каждая микрооптимизация важна.
  • Совместимость с for: В цикле for переменная тоже объявляется один раз. Разработчики хотели сохранить единообразие.
  • Простота компилятора: Старый подход был проще реализовать в компиляторе. Но цена — неочевидное поведение для программистов.

Однако сообщество быстро обнаружило эту "фичу" и начало писать баг-репорты. На Stack Overflow вопрос "Is there a reason for C#'s reuse of the variable in a foreach?" стал одним из самых популярных. Microsoft прислушалась, и в C# 5.0 (2012 год) поведение было изменено.

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

Начиная с C# 5.0, компилятор создает новую переменную для каждой итерации цикла foreach. Теперь приведенный выше пример будет работать интуитивно правильно и выведет 1, 2, 3, 4, 5.

// Этот же код на C# 5+ выведет: 1, 2, 3, 4, 5
var actions = new List<Action>();
var numbers = new List<int> { 1, 2, 3, 4, 5 };

foreach (var number in numbers)
{
    actions.Add(() => Console.WriteLine(number));
}

foreach (var action in actions)
{
    action();
}

Важно: Это изменение коснулось только foreach. Цикл for по-прежнему использует одну переменную. Поэтому будьте внимательны:

// Проблема остаётся в for!
var actions = new List<Action>();
for (int i = 0; i < 5; i++)
{
    actions.Add(() => Console.WriteLine(i));
}
// Выведет: 5, 5, 5, 5, 5

Как обойти проблему в старых версиях (или в for)?

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

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

foreach (var number in numbers)
{
    // Создаём локальную копию
    var captured = number;
    actions.Add(() => Console.WriteLine(captured));
}
// Выведет: 1, 2, 3, 4, 5

Тот же трюк работает и для for:

var actions = new List<Action>();
for (int i = 0; i < 5; i++)
{
    var captured = i;
    actions.Add(() => Console.WriteLine(captured));
}
// Выведет: 0, 1, 2, 3, 4

Этот паттерн называется "capture the loop variable" и является стандартным способом решения проблемы замыканий в циклах.

Современные сценарии: async и LINQ

Проблема особенно остро встает при использовании асинхронного программирования и LINQ. Рассмотрим пример с async:

var tasks = new List<Task>();
var urls = new List<string> { "url1", "url2", "url3" };

foreach (var url in urls)
{
    // В C# 5+ это безопасно
    tasks.Add(Task.Run(async () => 
    {
        await SomeHttpRequestAsync(url);
        Console.WriteLine($"Обработан {url}");
    }));
}

await Task.WhenAll(tasks);

В C# 5+ этот код работает корректно. Но если бы мы писали его на C# 4, все задачи использовали бы последний URL.

Блоги

Рекомендаций по книгам