Цикл foreach в C# — это оператор, который по очереди берет каждый элемент коллекции (массива, списка, словаря, строки) и выполняет для него тело цикла, не заставляя вас вести счетчик и индекс. Записывается он так: foreach (var item in collection) { ... }. Ниже — как foreach устроен внутри, почему нельзя удалять элементы во время перебора, когда нужен ref foreach и await foreach, и чем похожие циклы в C++ и Java отличаются от C#.
Содержание
- Мини-словарь: три слова, которые путают
- Синтаксис foreach в C# на полном примере
- Как foreach устроен внутри: GetEnumerator и MoveNext
- Почему нельзя удалять элементы внутри foreach
- Итерационная переменная только для чтения: ref foreach
- await foreach: перебор асинхронного потока
- foreach в Си-семействе: C, C++, Java и C
- Когда foreach, а когда for
- Выводы
- Где применяется / связь с практикой
- FAQ
Все примеры проверены на .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> — стандартный и рекомендуемый способ выполнить это требование, но не единственный.
Упрощенная схема выше опускает три детали, которые компилятор добавляет сам:
- Если перечислитель реализует
IDisposable, цикл оборачивается вtry/finallyиDispose()вызывается даже приbreak,returnили исключении. - Для массивов перечислитель не создается вовсе: компилятор генерирует обычный
forпо индексу. - Итерационная переменная объявляется внутри цикла, поэтому лямбда, захватившая ее, видит значение своего шага. Так стало в 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, оправдан, когда важно избежать выделений памяти.



