Публикую следующую часть статьи про производительность коллекций. Сегодня про Enumerator.
---
Предположим, у нас есть массив _transactionsArray и список _transactionsList. Сама транзакция выглядит следующим образом:
public record class Transaction(
Guid Id,
int Amount,
string Description);
Существует множество способов пройтись по двум вышеупомянутым коллекциям:
// Индексатор массива
var sum = 0;
for (var i = 0; i < _transactionsArray.List; i++) {
sum += _transactionsArray[i].Amount;
}
// Индексатор списка
var sum = 0;
for (var i = 0; i < _transactionsList.Count; i++) {
sum += _transactionsList[i].Amount;
}
// Энумератор IEnumerable<T>
var sum = 0;
var collection = (IEnumerable<T>)_transactionsArray;
foreach (var item in collection) {
sum += item.Amount;
}
var sum = 0;
var collection = (IEnumerable<T>)_transactionsList;
foreach (var item in collection) {
sum += item.Amount;
}
// Энумератор List<T>
var sum = 0;
foreach (var item in _transactionsList) {
sum += item.Amount;
}
Один из этих способов аллоцирует больше памяти, чем остальные – это приведение _transactionsList к IEnumerable<T>. Причина кроется в реализации энумератора для списков. У типа List<T> есть собственный энумератор-структура.
Если использовать foreach с типом List<T>, то проблем не возникает. Компилятор C# сгенерирует код, который будет использовать эту структуру напрямую.
// Исходный код
var nums= new List<int>();
var sum = 0;
foreach (n in nums) sum +=n;
// Код после компиляции
var nums = new List<int>();
var num = 0;
List<int>.Enumerator enumerator =
nums.GetEnumerator();
while (enumerator.MoveNext()) {
num += enumerator.Current;
}
Но если привести List<T>, например, к IList<T> или IReadOnlyList<T>, т.е. к любому интерфейсу, реализующему IEnumerable<T>, то произойдёт неявная упаковка List<T>.Enumerator. Это происходит из-за того, что IEnumerable<T>.GetEnumerator() возвращает интерфейс IEnumerator<T>.
// Исходный код
var nums = new List<int>();
var collection = (IEnumerable<int>) nums;
var sum = 0;
foreach (n in collection) sum +=n;
// Код после компиляции
var nums = new List<int>();
var collection = ((IEnumerable<int>)nums);
var num = 0;
IEnumerator<int>.Enumerator enumerator =
// упаковка
collection.GetEnumerator();
while (enumerator.MoveNext()) {
num += enumerator.Current;
}
Аналогичный подход с энумераторами также встречается и других коллекциях: LinkedList<T>, Stack<T>, Queue<T> и т.д. Исключением являются, например, массивы.
Насколько упаковка влияет на производительность можно понять из графиков. Бенчмарк 100 раз прошёлся по каждой из коллекции. На первом графике ось Y отображает проценты от бенчмарка с массивом, а на втором — миллисекунды. Шкала оси X в обоих случаях логарифмическая.
Если говорить об абсолютных значениях, разница не так велика — десятки миллисекунд для коллекций размером 100 000 элементов и более. Наибольшее коварство такое поведение представляет, когда у вас много небольших коллекций. Я в своей практике встречал проблему, когда упаковка энумератора приводила к аллокациям десятков и сотен мегабайт. Это было большое дерево директорий, а доступ к дочерним директориям был только через IReadOnlyList<T>.

