IEnumerable против IQueryable
Две строчки кода выглядят почти одинаково. Одна вытянет из базы десять пользователей, другая — все два миллиона. Разница — в одном слове в объявлении переменной.
IEnumerable<T> — «дай мне элементы, я переберу их в памяти». IQueryable<T> — «вот описание запроса, переведи его в SQL и выполни на сервере БД». Второй наследуется от первого, поэтому подмена происходит незаметно для компилятора.
Вопрос: в чём разница между этими двумя методами?
// Вариант А
public List<User> GetAdultsA(AppDbContext db)
{
IQueryable<User> users = db.Users;
return users.Where(u => u.Age >= 18).ToList();
}
// Вариант Б — отличается ОДНИМ словом
public List<User> GetAdultsB(AppDbContext db)
{
IEnumerable<User> users = db.Users;
return users.Where(u => u.Age >= 18).ToList();
}
Компилятор проглотит оба: DbSet<User> реализует и IQueryable<User>, и IEnumerable<User>. А вот база увидит разное:
Вариант А: SELECT [u].[Id], [u].[Name], [u].[Age] FROM [Users] AS [u] WHERE [u].[Age] >= 18
Вариант Б: SELECT [u].[Id], [u].[Name], [u].[Age] FROM [Users] AS [u]
Вариант Б выкачивает всю таблицу по сети, материализует каждую строку в объект User, а потом уже в памяти приложения отбрасывает ненужное. На таблице в двести строк вы этого не заметите. На двух миллионах — приложение ляжет по памяти, а вы будете час искать, почему «тот же самый запрос» работает по-разному в двух методах.
Причина в том, какой набор методов расширения выберет компилятор. Для IQueryable<T> подтянется Queryable.Where, который принимает Expression<Func<User, bool>>. Для IEnumerable<T> — Enumerable.Where, который принимает обычный Func<User, bool>. Разница в одном слове Expression и определяет всё.
Func — это код. Expression — это данные
Скомпилированный Func можно только вызвать: он — чёрный ящик из машинных инструкций, заглянуть внутрь нельзя. Expression — это дерево выражений: разобранная структура вашей лямбды, по которой можно ходить и читать её как данные. Именно это и делает EF, превращая лямбду в SQL. Посмотрим на дерево своими глазами — запустите:
using System;
using System.Linq.Expressions;
class Program
{
static void Main()
{
Func<int, bool> code = n => n > 18; // это КОД
Expression<Func<int, bool>> tree = n => n > 18; // это ДАННЫЕ
Console.WriteLine($"Вызов Func: {code(20)}");
Console.WriteLine($"Дерево: {tree}");
var body = (BinaryExpression)tree.Body;
Console.WriteLine($" операция: {body.NodeType}");
Console.WriteLine($" слева: {body.Left} ({body.Left.NodeType})");
Console.WriteLine($" справа: {body.Right} ({body.Right.NodeType})");
Func<int, bool> compiled = tree.Compile(); // данные -> код
Console.WriteLine($"После Compile(): {compiled(10)}");
}
}
Результат:
Вызов Func: True
Дерево: n => (n > 18)
операция: GreaterThan
слева: n (Parameter)
справа: 18 (Constant)
После Compile(): False
Вот и весь секрет провайдера LINQ to Entities. Он рекурсивно обходит такое дерево: увидел GreaterThan — пишет >= или >, увидел MemberAccess по свойству Age — пишет имя колонки, увидел Constant — подставляет параметр запроса. На выходе — строка SQL. С обычным Func так сделать нельзя: там нечего обходить, есть только исполняемый код, который умеет работать лишь с объектами в памяти.
Как это работает под капотом
У IQueryable<T> ровно два интересных свойства: Expression (накопленное дерево запроса) и Provider (тот, кто умеет это дерево исполнить). Каждый вызов Where, OrderBy, Select над IQueryable ничего не выполняет — он достраивает дерево и возвращает новый IQueryable. Поэтому цепочку можно собирать по кусочкам:
IQueryable<User> q = db.Users;
if (onlyActive)
q = q.Where(u => u.IsActive); // SQL ещё не отправлен
if (!string.IsNullOrEmpty(city))
q = q.Where(u => u.City == city); // и здесь тоже
var page = await q.OrderBy(u => u.Name)
.Skip(20)
.Take(10)
.ToListAsync(); // ВОТ ЗДЕСЬ уходит один-единственный SQL
Все условия склеятся в один WHERE ... AND ..., сортировка станет ORDER BY, а Skip/Take превратятся в OFFSET ... FETCH NEXT. База отдаст ровно десять строк. Это и есть суперсила IQueryable: динамическая сборка запроса без единого лишнего похода в БД.
И сразу оборотная сторона: провайдер понимает не всё. Если внутри Where вызвать свой метод, дерево построится, а перевести его в SQL будет нечем:
// EF Core бросит InvalidOperationException:
// The LINQ expression could not be translated
var users = db.Users
.Where(u => IsVip(u)) // IsVip — обычный C#-метод, SQL про него не знает
.ToList();
// Осознанное решение: сначала сузили выборку в БД, потом перешли в память
var users = db.Users
.Where(u => u.Age >= 18) // это переводится в SQL
.AsEnumerable() // дальше — LINQ to Objects, в памяти
.Where(u => IsVip(u))
.ToList();
Ключевое слово здесь — осознанное. AsEnumerable() — не костыль, а явный переключатель «дальше считаем на клиенте». Важно, что до EF Core 3.0 такой переход происходил молча: провайдер видел непереводимое выражение и тихо тянул таблицу в память. Ровно поэтому в EF Core 3.0 поведение изменили — теперь он бросает исключение. Это отличный факт для собеседования: он показывает, что вы понимаете, почему граница между базой и памятью должна быть видимой.
Таблица для ответа
| Критерий | IEnumerable<T> | IQueryable<T> |
| Пространство имён операторов | Enumerable (LINQ to Objects) | Queryable |
| Что принимает Where | Func<T, bool> — код | Expression<Func<T, bool>> — дерево |
| Где выполняется фильтр | в памяти приложения | на сервере БД (в SQL) |
| Что летит по сети | вся выборка | только отфильтрованное |
| Типичный источник | List, массив, результат yield | DbSet, внешний API-провайдер |
| Любой код в лямбде | да | нет — только переводимый в SQL |
Оба интерфейса при этом ленивые — отложенное выполнение работает и там, и там. Разница не в «когда», а в «где».
Частые ошибки на собеседовании
- «IQueryable всегда лучше». Нет. Для
Listв памятиAsQueryable()не даст ничего, кроме накладных расходов на построение и разбор деревьев: провайдерEnumerableQueryпросто скомпилирует их обратно в делегаты и выполнит там же, в памяти. - Не знать, что IQueryable наследуется от IEnumerable. Отсюда и вся проблема: присвоение
IEnumerable<User> users = db.Users;компилируется без единого предупреждения. - Возвращать
IEnumerable<T>из репозитория, а потом фильтровать снаружи. Классический способ незаметно превратить весь слой доступа к данным в «выкачай таблицу целиком». - Ставить
ToList()до пагинации:db.Users.ToList().Skip(20).Take(10)— таблица уже в памяти,Skipсчитает по списку. Правильно — наоборот. - Считать, что
Count()на IQueryable тянет строки. Не тянет: он превращается вSELECT COUNT(*)и возвращает одно число.
Итоги-шпаргалка
IQueryable<T> : IEnumerable<T>. Подмена типа компилируется молча, а работать начинает в разы медленнее.Enumerable.WhereберётFunc(код) → фильтр в памяти.Queryable.WhereберётExpression<Func>(дерево) → фильтр в SQL.- Дерево выражений — разобранная лямбда как структура данных; провайдер обходит её и генерирует запрос.
- Цепочка
Where/OrderBy/Skip/TakeнадIQueryableсклеивается в один SQL и уходит в БД приToList()/foreach/First(). - Непереводимый код в лямбде →
InvalidOperationExceptionв EF Core 3.0+. Переход в память делайте явно, черезAsEnumerable(), и только после фильтрации. - Из репозитория отдавайте
IQueryable, если снаружи будут доуточнять запрос, и материализованныйList, если нет.