Что такое lambda expressions в C# и как работают closures?
Lambda — анонимная функция, компилируемая в делегат или дерево выражений. Closure захватывает переменную по ссылке через сгенерированный Display Class, поэтому изменения переменной после создания лямбды отражаются при её вызове.
Lambda-выражения в C#
Lambda-выражение — анонимная функция, синтаксис которой короче обычного делегата. Компилятор преобразует её либо в делегат (Action, Func<>), либо в дерево выражений (Expression<Func<>>) в зависимости от контекста.
// Лямбда как Func
Func<int, int, int> add = (a, b) => a + b;
Console.WriteLine(add(3, 4)); // 7
// Лямбда как Action
Action<string> print = msg => Console.WriteLine(msg);
print("hello");
// Блочная лямбда
Func<int, string> classify = n =>
{
if (n < 0) return "negative";
if (n == 0) return "zero";
return "positive";
};
// Как дерево выражений (для IQueryable / EF Core)
Expression<Func<User, bool>> expr = u => u.IsActive && u.Age > 18;
Closures (замыкания)
Замыкание возникает, когда лямбда захватывает переменную из внешней области видимости. Компилятор генерирует вспомогательный класс (Display Class), который хранит захваченные переменные как поля. Лямбда и внешний код работают с одним и тем же объектом — изменение переменной снаружи отражается внутри лямбды, и наоборот.
// Простое замыкание
int multiplier = 3;
Func<int, int> triple = x => x * multiplier;
Console.WriteLine(triple(5)); // 15
multiplier = 10;
Console.WriteLine(triple(5)); // 50 — захвачена ССЫЛКА, не значение!
// Классический баг с циклом
var actions = new List<Action>();
for (int i = 0; i < 5; i++)
{
actions.Add(() => Console.Write(i + " "));
}
actions.ForEach(a => a()); // 5 5 5 5 5 — все захватили одну переменную i
// Исправление: локальная копия
for (int i = 0; i < 5; i++)
{
int copy = i; // новая переменная на каждой итерации
actions.Add(() => Console.Write(copy + " "));
}
actions.ForEach(a => a()); // 0 1 2 3 4
// foreach не имеет этой проблемы начиная с C# 5
var items = new[] { "a", "b", "c" };
var prints = items.Select(item => (Action)(() => Console.Write(item)));
prints.ToList().ForEach(a => a()); // a b c — корректно
Как компилятор реализует замыкания
Компилятор создаёт приватный класс, например <>c__DisplayClass0_0, с полями для каждой захваченной переменной. Метод, содержащий лямбду, инстанцирует этот класс и записывает захваченные переменные в его поля. Лямбда становится методом этого класса. Поэтому все лямбды, захватывающие одну переменную из одного блока, читают из одного поля одного экземпляра.
// Примерно так выглядит результат компиляции:
// private sealed class DisplayClass
// {
// public int multiplier;
// public int Lambda(int x) => x * multiplier;
// }
// Захват нескольких переменных
string prefix = "[INFO]";
int counter = 0;
Action<string> log = msg =>
{
counter++;
Console.WriteLine($"{prefix} #{counter}: {msg}");
};
log("start"); // [INFO] #1: start
log("stop"); // [INFO] #2: stop
Console.WriteLine(counter); // 2 — внешняя переменная изменена!
Подводные камни
- Захват переменной цикла (
forдо C# 5,forво всех версиях) — все итерации видят конечное значение; решение — локальная копия внутри тела цикла. - Захват ресурса (например,
DbContext) в лямбду, которая живёт дольше ресурса — вызов упадёт сObjectDisposedException. - Замыкание удерживает ссылку на весь захваченный объект, не только на используемое поле — это предотвращает GC-сборку объекта, пока жива лямбда.
- Лямбда в статическом поле или событии удерживает сильную ссылку на экземпляр через замыкание — классическая утечка памяти (например, подписка на события без отписки).
- Попытка передать лямбду с замыканием как
Expression<Func<>>в EF Core: замыкание с локальными переменными .NET обычно транслируется корректно (как параметры SQL), но вызов произвольных методов — нет. - Производительность: каждое создание замыкания — это аллокация Display Class на куче. В горячем пути используйте статические лямбды (
static x => x * 2, C# 9) или кэшированные делегаты. staticлямбда (C# 9) явно запрещает захват внешних переменных — компилятор выдаст ошибку, что помогает контролировать аллокации.
Common mistakes
- Путать lambda expressions и замыкания с похожим механизмом из другой версии или платформы.
- Игнорировать runtime-границы C#: lifecycle, DI scope, SQL translation, UI thread или platform API.
- Не обсуждать null/empty/error cases и поведение под нагрузкой.
What the interviewer is testing
- Кандидат объясняет lambda expressions и замыкания на конкретном примере, а не только определением.
- Указывает последствия для производительности, тестируемости и поддержки.
- Различает документированное поведение текущего стека и устаревшие практики.