Expression<Func<T>> vs Func<T>: секрет оптимизации в C#

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Expression

Представьте: вы пишете C# код. Нужно передать условие фильтрации в метод. Что выбрать: Func<User, bool> или Expression<Func<User, bool>>? Вроде бы разница минимальна — оба принимают пользователя и возвращают bool. Но за кулисами скрывается целая вселенная различий.

На Stack Overflow этот вопрос набрал тысячи просмотров — и не зря. Сегодня мы разберемся, почему Expression<Func<T>> может быть мощнее, быстрее и безопаснее, чем обычный Func<T>. И самое главное — когда применять каждый из них, чтобы не стрелять себе в ногу.

Краткий ликбез: что такое Func и Expression?

Func<T> — это просто делегат. Готовая к выполнению функция, скомпилированный машинный код. Вы передаете ей данные — она их обрабатывает. Быстро, эффективно, но… непрозрачно.

Func<User, bool> isAdult = user => user.Age >= 18;
bool result = isAdult(someUser); // выполняется мгновенно

Expression<Func<T>> — это дерево выражений. Не код, а его описание. Вы не можете просто вызвать его — нужно либо скомпилировать в делегат (через .Compile()), либо проанализировать его структуру.

Expression<Func<User, bool>> expr = user => user.Age >= 18;
// Нельзя просто вызвать: expr(someUser) — ошибка компиляции
var compiled = expr.Compile();
bool result = compiled(someUser); // теперь можно

Казалось бы, зачем лишние телодвижения? Но именно эта «описательность» открывает дверь к магии.

Сценарий 1: LINQ to Objects (работа в памяти)

Когда вы работаете с коллекциями в памяти (например, List<T>), LINQ to Objects использует Func<T>. Деревья выражений здесь не нужны — все выполняется в том же процессе.

var users = new List<User>();
var adults = users.Where(u => u.Age >= 18).ToList(); // Func<User, bool>

Производительность отличная: никаких накладных расходов на разбор дерева. Если вы работаете только с локальными данными — смело используйте Func. Он быстрее и проще.

Сценарий 2: LINQ to SQL / Entity Framework (работа с БД)

А вот здесь начинается магия. Когда вы пишете:

var adults = dbContext.Users.Where(u => u.Age >= 18).ToList();

Entity Framework не выполняет ваш код на C#. Вместо этого он анализирует переданное дерево выражений и преобразует его в SQL-запрос:

SELECT * FROM Users WHERE Age >= 18

Если бы вы передали Func, EF бы не смог «заглянуть внутрь» — он бы просто выгрузил ВСЕХ пользователей из таблицы и выполнил фильтрацию в памяти. Представьте: таблица на 10 миллионов записей — и все они летят по сети в ваше приложение, чтобы отфильтроваться на клиенте.

Expression<Func<T>> позволяет EF (или любому другому IQueryable-провайдеру) прочитать ваше условие, перевести его на язык базы данных (SQL, MongoDB query, OData и т.д.) и выполнить фильтрацию на сервере.

Производительность: бенчмарки и реальные цифры

Давайте посмотрим на цифры. Возьмем простой пример: фильтрация 100 000 пользователей по возрасту.

// Вариант 1: Func — загружаем всех, фильтруем в памяти
var allUsers = dbContext.Users.ToList(); // SELECT * FROM Users
var adults1 = allUsers.Where(u => u.Age >= 18).ToList();

// Вариант 2: Expression — фильтруем на сервере
var adults2 = dbContext.Users.Where(u => u.Age >= 18).ToList(); // SELECT * FROM Users WHERE Age >= 18

Результаты (примерные, но показательные):

  • Func: передано 100 000 записей (≈ 10-20 МБ данных) → время: ~500 мс
  • Expression: передано 5 000 записей (≈ 1 МБ) → время: ~50 мс

Разница в 10 раз только на передаче данных. А если у вас сложные JOIN, агрегации или сортировки — преимущество деревьев выражений становится еще более очевидным.

Когда Expression может быть медленнее?

Честность превыше всего. Expression<Func<T>> имеет накладные расходы:

  • Построение дерева выражений (если вы создаете его динамически через Expression.AndAlso() и т.д.)
  • Компиляция через .Compile() — это дорогая операция (но выполняется один раз)
  • Разбор дерева провайдером (например, EF анализирует Expression в SQL)

Если вы работаете только с локальными данными и не используете IQueryable — Func всегда будет быстрее. Не усложняйте там, где это не нужно.

Продвинутый сценарий: динамические запросы

Деревья выражений позволяют строить запросы на лету. Например, вы хотите дать пользователю возможность фильтровать по любым полям:

public IQueryable<User> FilterUsers(string propertyName, object value)
{
    var param = Expression.Parameter(typeof(User), "u");
    var property = Expression.Property(param, propertyName);
    var constant = Expression.Constant(value);
    var equals = Expression.Equal(property, constant);
    var lambda = Expression.Lambda<Func<User, bool>>(equals, param);

    return dbContext.Users.Where(lambda);
}

// Использование:
var result = FilterUsers("Email", "test@example.com").ToList();

С Func такое сделать невозможно — вы не можете «разобрать» делегат на составные части. А с Expression — легко.

А как же производительность компиляции?

Многие разработчики боятся .Compile() на деревьях выражений. Да, это не бесплатно. Но:

  • Компиляция происходит один раз — результат кешируется
  • После компиляции Expression работает с той же скоростью, что и обычный Func
  • В реальных приложениях (EF, LINQ to SQL) компиляцию выполняет провайдер, а не вы

Если вы пишете свою библиотеку для работы с данными — используйте кеширование скомпилированных делегатов:

Блоги

Рекомендаций по книгам