Как избежать загрузки всех данных в память при использовании IEnumerable?
Что проверяют
Понимаете ли вы отложенное выполнение и где оно заканчивается. IEnumerable<T> сам по себе ничего не гарантирует: за ним может стоять ленивый итератор, а может уже загруженный List<T>. Следом обычно спрашивают про разницу с IQueryable<T> и про повторное перечисление.
Как отвечать
Начните с механики: метод с yield return компилятор превращает в конечный автомат, и элементы производятся по одному, только когда их просит foreach. Цепочка Where, Select, Take тоже ленивая и работает потоком. Дальше перечислите, что держит память под контролем:
- потоковый источник:
File.ReadLinesвместоFile.ReadAllLines,DbDataReaderилиStreamReaderвместо загрузки файла целиком; - буферизующие операторы:
OrderBy,GroupBy,Reverseчитают источник целиком до первого элемента,Distinctкопит все увиденные значения, аToList,ToArray,ToDictionaryматериализуют всё. Их ставят как можно позже или не ставят вовсе; - пачки:
Chunk(size)(есть с .NET 6) для обработки блоками, например пакетной вставки; - асинхронный поток:
IAsyncEnumerable<T>иawait foreach, в EF Core этоAsAsyncEnumerable(). Вместе сAsNoTracking(), иначе трекер изменений всё равно накопит все сущности; - Dapper:
Queryпо умолчанию буферизует результат, для потока нуженbuffered: falseилиQueryUnbufferedAsync.
Отдельно скажите про IQueryable<T>: пока запрос остаётся им, Where и Take превращаются в SQL. Стоит вызвать AsEnumerable() или передать запрос в метод, принимающий IEnumerable<T>, и фильтрация пойдёт в памяти по всей выгруженной таблице.
Где спотыкаются
Ставят ToList() в начало цепочки «чтобы было удобнее», и ленивость пропадает ещё до фильтрации.
Перечисляют последовательность дважды: Count() для проверки, потом foreach. Для ленивого запроса к базе это два запроса, для итератора с побочными эффектами два прохода с разным результатом. Анализатор CA1851 об этом предупреждает, но его редко включают.
Путают IEnumerable и IQueryable и уверены, что Where после AsEnumerable() всё равно уйдёт в SQL.