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-а
}
// Явна реалізація не generic інтерфейсу 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, а також методами, що приймають IEnumerable.
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);
}
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ