Цикл foreach в C#: синтаксис, устройство и частые ошибки

Цикл foreach в C#: синтаксис, устройство и частые ошибки Полезное

Цикл foreach в C# — это оператор, который по очереди берет каждый элемент коллекции (массива, списка, словаря, строки) и выполняет для него тело цикла, не заставляя вас вести счетчик и индекс. Записывается он так: foreach (var item in collection) { ... }. Ниже — как foreach устроен внутри, почему нельзя удалять элементы во время перебора, когда нужен ref foreach и await foreach, и чем похожие циклы в C++ и Java отличаются от C#.

Все примеры проверены на .NET 10 SDK (C# 14) как консольная программа с top-level statements: код можно целиком вставить в Program.cs проекта dotnet new console и запустить dotnet run.

Мини-словарь: три слова, которые путают

  • Коллекция (перечисляемый объект) — то, что стоит справа от in. Обычно реализует IEnumerable<T>, но строго компилятору нужен только метод GetEnumerator().
  • Перечислитель (enumerator) — отдельный объект-курсор, который возвращает GetEnumerator(). У него есть метод MoveNext() (сдвинуться на следующий элемент, вернуть false, если элементов больше нет) и свойство Current (текущий элемент).
  • Итерационная переменная — item в заголовке цикла. Это копия Current на текущем шаге, по умолчанию доступная только для чтения.

Синтаксис foreach в C# на полном примере

var langs = new List<string> { "C#", "Java", "C++" };

foreach (var lang in langs)
{
    Console.WriteLine($"{lang} - длина {lang.Length}");
}

var prices = new Dictionary<string, int> { ["кофе"] = 250, ["чай"] = 180 };
foreach (var (name, price) in prices)
{
    Console.WriteLine($"{name}: {price} руб.");
}

foreach (var (i, lang) in langs.Index())
{
    Console.WriteLine($"{i}: {lang}");
}

Результат запуска:

C# - длина 2
Java - длина 4
C++ - длина 3
кофе: 250 руб.
чай: 180 руб.
0: C#
1: Java
2: C++

Разбор по строкам:

  • var lang — тип выводится из коллекции (string). Можно написать тип явно: foreach (string lang in langs).
  • Словарь перебирается парами KeyValuePair<TKey, TValue>; запись var (name, price) раскладывает пару на ключ и значение. Порядок элементов Dictionary контрактом не гарантирован: в этом прогоне он совпал с порядком добавления, но полагаться на это нельзя — для упорядоченного вывода берите SortedDictionary или сортировку.
  • Своего индекса у foreach нет. Если он нужен, в .NET 9 и новее есть LINQ-метод Index(), который выдает пары (индекс, элемент). В более старых версиях — Select((x, i) => ...) или обычный for.

Как foreach устроен внутри: GetEnumerator и MoveNext

Компилятор превращает foreach в цикл while вокруг перечислителя. Самое наглядное доказательство — класс, который не реализует ни IEnumerable, ни IEnumerator, но все равно перебирается:

foreach (var n in new Countdown(3))
{
    Console.Write($"{n} ");
}
Console.WriteLine();

// Что примерно делает компилятор с тем же циклом
var e = new Countdown(3).GetEnumerator();
while (e.MoveNext())
{
    var n = e.Current;
    Console.Write($"{n} ");
}
Console.WriteLine();

// Ни IEnumerable, ни IEnumerator: хватает методов с нужными именами
class Countdown(int from)
{
    public Enumerator GetEnumerator() => new Enumerator(from);

    public struct Enumerator(int from)
    {
        private int _current = from + 1;
        public int Current => _current;
        public bool MoveNext() => --_current > 0;
    }
}

Оба цикла печатают 3 2 1. Это так называемый шаблон (pattern-based) foreach: компилятору достаточно публичного GetEnumerator(), у результата которого есть MoveNext() и Current. Интерфейс IEnumerable<T> — стандартный и рекомендуемый способ выполнить это требование, но не единственный.

Упрощенная схема выше опускает три детали, которые компилятор добавляет сам:

  1. Если перечислитель реализует IDisposable, цикл оборачивается в try/finally и Dispose() вызывается даже при break, return или исключении.
  2. Для массивов перечислитель не создается вовсе: компилятор генерирует обычный for по индексу.
  3. Итерационная переменная объявляется внутри цикла, поэтому лямбда, захватившая ее, видит значение своего шага. Так стало в C# 5; в более ранних версиях все лямбды видели последнее значение.

Почему нельзя удалять элементы внутри foreach

Самая частая ошибка — изменить коллекцию, которую перебираешь. Неверный код:

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

try
{
    foreach (var n in nums)
    {
        if (n % 2 == 0)
            nums.Remove(n);
    }
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
Console.WriteLine(string.Join(", ", nums));

Фактический результат:

InvalidOperationException: Collection was modified; enumeration operation may not execute.
1, 3, 4, 5, 6

Двойка успела удалиться, а на следующем MoveNext() перечислитель List<T> заметил, что список изменился (внутри хранится счетчик версий), и бросил исключение. Так ведут себя стандартные List<T>, Dictionary<TKey, TValue>, HashSet<T>; для собственных коллекций это поведение не обязательно, а потокобезопасные коллекции из System.Collections.Concurrent намеренно разрешают изменение во время перебора.

Три рабочих исправления, все печатают 1, 3, 5:

var a = new List<int> { 1, 2, 3, 4, 5, 6 };
a.RemoveAll(n => n % 2 == 0);
Console.WriteLine(string.Join(", ", a));

var b = new List<int> { 1, 2, 3, 4, 5, 6 };
for (int i = b.Count - 1; i >= 0; i--)
{
    if (b[i] % 2 == 0)
        b.RemoveAt(i);
}
Console.WriteLine(string.Join(", ", b));

var c = new List<int> { 1, 2, 3, 4, 5, 6 };
foreach (var n in c.ToList())
{
    if (n % 2 == 0)
        c.Remove(n);
}
Console.WriteLine(string.Join(", ", c));
Способ Когда брать Цена
RemoveAll(условие) удалить из List<T> по условию один проход, самый простой вариант
for с конца нужна своя логика на каждом шаге обход с конца, чтобы сдвиг не пропускал элементы
foreach по копии ToList() коллекция не List или код уже написан через foreach лишняя копия в памяти, а Remove ищет элемент заново

Итерационная переменная только для чтения: ref foreach

Присвоить что-то итерационной переменной нельзя:

int[] scores = { 10, 20, 30 };
foreach (var s in scores)
{
    s = s * 2;
}

Компилятор .NET 10 SDK отвечает ошибкой:

error CS1656: Cannot assign to 's' because it is a 'foreach iteration variable'

Для структур в списке похожая ошибка — CS1654: Cannot modify members of 'p' because it is a 'foreach iteration variable', если попытаться написать p.X = 5. Причина одна: переменная — копия элемента, и изменение копии ничего бы не дало. Изменить сами элементы массива можно через Span<T> и ref-итерационную переменную:

int[] scores = { 10, 20, 30 };

foreach (ref int s in scores.AsSpan())
{
    s *= 2;
}
Console.WriteLine(string.Join(", ", scores));

var points = new Point[] { new(1, 1), new(2, 2) };
foreach (ref var p in points.AsSpan())
{
    p.X += 10;
}
Console.WriteLine(string.Join("; ", points));

struct Point(int x, int y)
{
    public int X = x;
    public int Y = y;
    public override string ToString() => $"({X}, {Y})";
}

Вывод: 20, 40, 60 и (11, 1); (12, 2). Для массива простых чисел то же самое проще сделать обычным for с индексом; ref foreach полезен, когда элементы — крупные структуры и копировать их на каждом шаге дорого. Для списка ссылочных объектов (классов) ref не нужен: item.Name = "..." меняет сам объект, потому что копируется только ссылка.

await foreach: перебор асинхронного потока

Если элементы приходят не сразу (страницы из API, строки из базы), источник возвращает IAsyncEnumerable<T>, а перебирают его через await foreach (появился в C# 8):

await foreach (var page in LoadPagesAsync(3))
{
    Console.WriteLine($"получена {page}");
}

static async IAsyncEnumerable<string> LoadPagesAsync(int count)
{
    for (int i = 1; i <= count; i++)
    {
        await Task.Delay(100); // имитация запроса к сети или БД
        yield return $"страница {i}";
    }
}

Строки получена страница 1 … получена страница 3 появляются с паузой, по мере готовности, а не все разом после загрузки последней. Обычный foreach по IAsyncEnumerable<T> не скомпилируется: нужен именно await foreach внутри асинхронного метода (или в top-level коде, как здесь).

foreach в Си-семействе: C, C++, Java и C

Название статьи — про семейство языков с синтаксисом из C. Цикл «для каждого» в них есть не везде и устроен по-разному:

Язык Запись Что под капотом Изменение коллекции во время перебора
C нет, только for с индексом или указателем — на совести программиста
C++ (с C++11) for (auto& x : v) begin()/end() и итераторы для std::vector вставка может сделать итераторы недействительными: неопределенное поведение, исключения нет
C++ (алгоритм) std::for_each(first, last, f) из <algorithm> функция над диапазоном итераторов то же, что выше
Java (с Java 5) for (T x : coll) Iterable/Iterator стандартные коллекции обычно бросают ConcurrentModificationException
C# foreach (var x in coll) GetEnumerator()/MoveNext()/Current стандартные коллекции бросают InvalidOperationException

Главное отличие C++ от C#: в C++ вы сами решаете, копировать элемент или брать ссылку, и компилятор не запретит изменить копию:

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{10, 20, 30};

    for (auto s : scores) s *= 2;          // копия: вектор не меняется
    for (auto& s : scores) s += 1;         // ссылка: меняем элементы
    for (const auto& s : scores) std::cout << s << ' ';
    std::cout << '\n';

    std::for_each(scores.begin(), scores.end(),
                  [](int s) { std::cout << s * 10 << ' '; });
    std::cout << '\n';
}

Программа печатает 11 21 31 и 110 210 310: первый цикл изменил только копии, и об этом компилятор (здесь Apple clang 21 с -Wall) сообщил лишь предупреждением variable 's' set but not used. В C# та же попытка — ошибка компиляции CS1656, а для изменения на месте нужен явный ref.

Когда foreach, а когда for

  • foreach — когда нужно просто пройти все элементы по порядку и прочитать их: код короче, нет ошибок на единицу в границах.
  • for — когда нужен индекс, обход с конца, шаг больше 1, удаление или замена элементов по месту.
  • LINQ (Where, Select, Sum) — когда результат — новая последовательность или одно значение, а не действия с побочным эффектом.

Разницу в скорости между foreach и for для массивов и List<T> в современном .NET обычно не видно в прикладном коде; если она важна для горячего участка, ее измеряют бенчмарком на своих данных, а не выбирают цикл «на глаз».

Выводы

  • foreach в C# перебирает любую коллекцию с методом GetEnumerator(); компилятор разворачивает его в while (e.MoveNext()) с Current и вызывает Dispose() при выходе.
  • Итерационная переменная — копия элемента только для чтения (CS1656); менять элементы массива или span на месте можно через foreach (ref var x in span).
  • Удаление из List<T> внутри foreach дает InvalidOperationException; вместо этого — RemoveAll, for с конца или перебор копии.
  • Для асинхронных источников (IAsyncEnumerable<T>) нужен await foreach; индекс в .NET 9+ дает Index().
  • В C++ аналог — range-for и std::for_each, но там изменение контейнера во время перебора не ловится исключением, а ведет к неопределенному поведению.

Где применяется / связь с практикой

Освойте тему на практике

foreach встречается почти в каждом C#-проекте: обработка ответов API, строк из базы через await foreach, обход объектов сцены в Unity, отчеты по коллекциям. Ошибки из этой статьи — изменение коллекции при переборе и попытка изменить копию — типичны для первых месяцев работы с языком. Системно разобрать коллекции, интерфейсы IEnumerable/IEnumerator, yield return и асинхронность можно на курсе «C#-разработчик. Базовый уровень», а посмотреть формат занятий бесплатно — на открытых уроках Otus.

FAQ

Можно ли выйти из foreach досрочно?
Да, break завершает цикл, continue переходит к следующему элементу, return выходит из метода. Перечислитель при этом корректно освобождается через Dispose().

Работает ли foreach со строкой?
Да, foreach (char ch in text) перебирает кодовые единицы UTF-16. Эмодзи и некоторые символы занимают две такие единицы. EnumerateRunes() перебирает кодовые точки Unicode, но и этого мало для видимых символов: эмодзи «семья» — это 8 единиц UTF-16, 5 кодовых точек и 1 видимый символ. Видимые символы (графемы) перебирают через StringInfo.

Как написать свою коллекцию для foreach?
Проще всего реализовать IEnumerable<T> через метод с yield return: компилятор сам сгенерирует перечислитель. Ручной struct-перечислитель, как в примере Countdown, оправдан, когда важно избежать выделений памяти.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)