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
Что принимает WhereFunc<T, bool> — кодExpression<Func<T, bool>> — дерево
Где выполняется фильтрв памяти приложенияна сервере БД (в SQL)
Что летит по сетився выборкатолько отфильтрованное
Типичный источникList, массив, результат yieldDbSet, внешний 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, если нет.
Проверьте себя
1. IEnumerable<User> users = db.Users; затем users.Where(u => u.Age >= 18).ToList(). Какой SQL уйдёт в базу?
ASELECT ... FROM Users WHERE Age >= 18 — фильтр переведётся в SQL
BSELECT ... FROM Users — вся таблица выгрузится, а фильтрация произойдёт в памяти приложения
CНикакой: запрос не выполнится, потому что IEnumerable не умеет работать с DbSet
DSELECT COUNT(*) FROM Users, а затем построчная выборка
2. Зачем Queryable.Where принимает Expression<Func<T, bool>>, а не обычный Func<T, bool>?
AExpression работает быстрее, потому что не выделяет память в куче
BТак требует синтаксис лямбд в C#, разницы по сути нет
CExpression — это дерево выражений, которое провайдер может разобрать как данные и перевести в SQL; скомпилированный Func можно только вызвать
DExpression позволяет писать асинхронные лямбды