1. Введение
Как вы, возможно, уже замечали, у разных коллекций C# есть родственные черты. Например, почти любую из них мы можем обойти с помощью цикла foreach:
var names = new List<string> { "Аня", "Борис", "Вика" };
// Внимание — код работает и с List, и с массивом, и даже с HashSet!
foreach (var name in names)
{
Console.WriteLine(name);
}
В чём магия? Всё дело в том, что все современные коллекции реализуют интерфейс IEnumerable<T> — некий "контракт", который гарантирует, что коллекция умеет возвращать элементы по одному, последовательно.
Представьте компанию, где для любой коллекции сотрудников (будь то отдел, команда проекта, список участников корпоратива) есть правило: если объект реализует интерфейс "IEnumerable", значит, вы всегда можете пройтись по всем сотрудникам в определённом порядке, не вопрос "как они хранятся", главное — что их можно перебрать.
2. Интерфейс IEnumerable<T> — что внутри?
Посмотрим, как выглядит этот интерфейс в .NET:
public interface IEnumerable<out T>
{
IEnumerator<T> GetEnumerator();
}
В нём всего один метод — GetEnumerator, который возвращает объект типа IEnumerator<T>. Этот объект отвечает за сам процесс "перечисления": он знает, какой элемент сейчас, как перейти к следующему, когда всё закончилось.
Кратко: если класс (или коллекция) реализует IEnumerable<T>, значит, по ней можно пройтись в цикле foreach, получать элементы один за другим — и не важно, как они внутри реализованы: хоть списком, хоть хеш-таблицей, хоть на жёстком диске.
Мини-схема
Коллекция (например, List<T>)
|
v
IEnumerable<T>
|
v
IEnumerator<T> (реальный "переборщик" элементов)
3. Практика и универсальность кода
Огромный плюс такого контракта — универсальность. Если функция или метод принимает на вход IEnumerable<T>, это значит, что он совместим с любым типом коллекции — от массивов и списков до хеш-наборов, очередей и даже собственных коллекций пользователя!
Например, рассмотрим функцию подсчёта суммы элементов:
// Можно считать сумму любого набора int, поддерживающего IEnumerable<int>
int Sum(IEnumerable<int> numbers)
{
int result = 0;
foreach (var n in numbers)
result += n;
return result;
}
// Работает с List<int>
var list = new List<int> { 1, 2, 3 };
Console.WriteLine(Sum(list));
// Работает с массивом!
int[] array = { 4, 5, 6 };
Console.WriteLine(Sum(array));
// Работает даже с результатом метода, фильтрующего элементы
Console.WriteLine(Sum(list.Where(x => x % 2 == 0))); // Используем LINQ
Пример из жизни: универсальные методы
Представьте, что вам нужно написать утилиту для поиска самого длинного слова в любой коллекции строк. Благодаря IEnumerable<string>, вы сможете сделать это для любого источника — будь то массив, список, результат методов фильтрации и т.д.:
string FindLongest(IEnumerable<string> words)
{
string longest = "";
foreach (var word in words)
if (word.Length > longest.Length)
longest = word;
return longest;
}
Использовать можно с любым подходящим набором.
4. Почему цикл foreach работает с IEnumerable<T>?
Частый вопрос: почему вообще цикл foreach "понимает" любые коллекции? Ответ прост: компилятор C# ищет в классе метод GetEnumerator и ожидает, что получит некий объект с методами MoveNext() и свойством Current. Это и есть стандартный интерфейс — IEnumerator<T>.
Благодаря такому подходу, вы можете даже написать собственный класс, который будет "выдавать" элементы по очереди (например, генератор последовательности чисел Фибоначчи), и если он реализует IEnumerable<int>, с ним можно работать в foreach так же, как с обычным списком.
5. Что такое Enumerator и как он работает?
Внутри любой коллекции, которая реализует IEnumerable<T>, скрыт специальный “переборщик” — Enumerator (или по-научному: объект, реализующий интерфейс IEnumerator<T>). Именно он и “таскает” элементы коллекции по одному, когда вы пишете цикл foreach.
Интерфейс IEnumerator<T>
Вот что умеет стандартный Enumerator:
public interface IEnumerator<T> : IDisposable
{
T Current { get; } // Текущий элемент
bool MoveNext(); // Перейти к следующему элементу
void Reset(); // Вернуться в начало (используется редко)
}
Как это работает внутри?
- MoveNext() — двигает "указатель" к следующему элементу и возвращает true, если элемент есть. Если элементов больше нет — возвращает false.
- Current — выдаёт текущий элемент (тот, на который сейчас указывает переборщик).
- Reset() — сбрасывает Enumerator в начало (но практически никогда не используется).
- Dispose() — освобождает ресурсы (нужно для коллекций, которые могут работать с файлами или сетью).
Пример: как работает foreach "под капотом"
Когда вы пишете:
var numbers = new List<int> { 1, 2, 3 };
foreach (var n in numbers)
Console.WriteLine(n);
На самом деле компилятор превращает это примерно в такой код:
var numbers = new List<int> { 1, 2, 3 };
// Получаем "переборщик"
var enumerator = numbers.GetEnumerator();
while (enumerator.MoveNext())
{
var n = enumerator.Current;
Console.WriteLine(n);
}
// Компилятор автоматически вызывает Dispose() в блоке using (если enumerator реализует IDisposable)
Важно: если коллекция работает с внешними ресурсами (например, файлы, базы данных), Enumerator может освобождать их автоматически, когда перебор заканчивается.
Визуальная схема
Начало обхода -> GetEnumerator() -> Enumerator
|
v
MoveNext() -> Current
|
v
MoveNext() -> Current
|
...
|
v
MoveNext() == false -> конец обхода
6. IEnumerable и массивы, списки, множества: кто с кем родственник
Посмотрим, какие стандартные контейнеры .NET реализуют этот интерфейс:
| Тип коллекции | Реализует IEnumerable<T>? | Можно пройти циклом foreach? |
|---|---|---|
Массив () |
✅ | ✅ |
|
✅ | ✅ |
|
✅ (по парам, ключам, знач.) | ✅ |
|
✅ | ✅ |
|
✅ | ✅ |
|
✅ | ✅ |
Даже строка (string) реализует обычный IEnumerable, так что по каждому символу строки можно так же пройтись.
7. Реализация собственного Enumerable
Интересная задача: давайте попробуем реализовать минимальную коллекцию, которая хранит чётные числа в диапазоне от 0 до N и реализует IEnumerable<int>. Тогда мы сможем пройтись по ней циклом и использовать её с LINQ.
// Класс-коллекция, который можно "перебрать"
class EvenNumbers : IEnumerable<int>
{
private int max;
public EvenNumbers(int max)
{
this.max = max;
}
public IEnumerator<int> GetEnumerator()
{
for (int i = 0; i <= max; i += 2)
yield return i; // Специальная магия для реализации enumerator-а
}
// Явная реализация не обобщенного интерфейса IEnumerable для обратной совместимости
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
// Использование:
var evens = new EvenNumbers(10);
foreach(var e in evens)
Console.Write($"{e} "); // 0 2 4 6 8 10
Отметим ключевую деталь: если ваш класс реализует IEnumerable<T> — вы автоматически делаете его совместимым с большинством инструментов .NET: LINQ, foreach, методами, которые принимают enumerable.
8. Типовые ошибки и нюансы
Иногда неопытные разработчики думают, что IEnumerable<T> — это какая-то отдельная коллекция, у которой есть элементы. На самом деле — это просто "обещание", что если мы начнем перебирать, нам будут "выдавать" элементы по одному.
Если нужно получить случайный доступ по индексу (myList[5]), использовать методы вроде Add или Remove — интерфейс IEnumerable<T> тут не поможет. Это только для последовательного прохода!
Ошибка: пытаться модифицировать коллекцию во время перебора. Например:
foreach (var item in myList)
{
if (item < 0)
myList.Remove(item); // ОПАСНО! InvalidOperationException
}
Лучше сначала сформировать отдельный список для удаления, либо использовать методы, которые создают новую коллекцию:
// Безопасный способ — создаем новую коллекцию вручную
var newList = new List<int>();
foreach (var item in myList)
{
if (item >= 0)
newList.Add(item);
}
myList = newList;
// Или удаляем по индексам в обратном порядке
for (int i = myList.Count - 1; i >= 0; i--)
{
if (myList[i] < 0)
myList.RemoveAt(i);
}
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ