Замыкания
Представьте, что на карточке с рецептом написано не «две ложки сахара», а «сахар — из синей банки на полке». Повар подойдёт к банке, когда начнёт готовить, и возьмёт то, что лежит в ней в эту минуту. Если после того, как вы отдали карточку, в банку досыпали сахар, повар найдёт и его.
Лямбда умеет ссылаться так же: не на значение, а на переменную из окружающего кода. Посмотрим, что из этого следует:
int limit = 3;
Func<int, bool> isBig = n => n > limit;
Console.WriteLine(isBig(5)); // True
limit = 10;
Console.WriteLine(isBig(5)); // FalseЛямбда читает переменную
Давайте разберём программу по строкам:
int limit = 3;объявляет обычную переменную.- Лямбда
n => n > limitиспользуетlimit, хотя это не её параметр. Она записана там же, где объявленаlimit, поэтому видит эту переменную, как её видят соседние строки. isBig(5)сравнивает5 > 3и печатаетTrue.limit = 10;меняет переменную уже после того, как лямбду создали.- Второй вызов читает
limitзаново и сравнивает5 > 10: печатаетсяFalse.
Лямбду, которая использует переменную из окружающего кода, называют замыканием. С коротким замыканием && и || из темы об операторах это слово не связано: там правая часть условия пропускается, а здесь лямбда держит переменную. Замыкание хранит не значение переменной на момент создания, а саму переменную, как карточка хранит не сахар, а указание на банку. Поэтому оно видит каждое изменение.
Этот вопрос часто задают на собеседовании: что захватывает замыкание — значение или переменную? Ответ: переменную, поэтому вызов видит её текущее значение.
Лямбда меняет переменную
Связь работает и в обратную сторону: замыкание может записать в переменную новое значение, и его увидит весь окружающий код.
int total = 0;
Action<int> add = x => total += x;
add(5);
add(3);
Console.WriteLine(total); // 8
Reset();
Console.WriteLine(total); // 0
void Reset() => total = 0;Каждый вызов add прибавляет число к той же total, поэтому после двух вызовов в ней 8.
Метод Reset тоже меняет total, хотя не получает её через параметр. В теме о методах связь с вызывающим кодом шла через параметры и результат. Но метод, объявленный среди инструкций верхнего уровня, формально считается локальной функцией и видит переменные вокруг себя так же, как лямбда. Это тоже замыкание.
Переменная живёт дольше метода
Замыкание держит переменную, пока живо само, даже если метод, в котором её объявили, уже завершился:
Func<int> a = MakeCounter();
Func<int> b = MakeCounter();
Console.WriteLine(a()); // 1
Console.WriteLine(a()); // 2
Console.WriteLine(b()); // 1
Func<int> MakeCounter()
{
int n = 0;
return () => ++n;
}Разберём, что здесь происходит:
- Каждый вызов
MakeCounterсоздаёт новую переменнуюnсо значением0и возвращает лямбду, которая её увеличивает. MakeCounterзавершается, но егоnне исчезает: её держит возвращённая лямбда.a()дважды увеличивает своюnи возвращает1, потом2.b()получил от второго вызоваMakeCounterдругуюn, поэтому начинает с1.
Здесь аналогия с банкой заканчивается. Банка стоит на полке и без карточки, а переменная n живёт, пока её держит лямбда. Снаружи до неё не добраться: изменить счёт можно только вызовом a().
Частая ошибка
Лямбды создали в цикле for и ждали, что каждая запомнит свой номер:
List<Func<int>> getters = [];
for (int i = 0; i < 3; i++)
{
getters.Add(() => i); // ✗
}
foreach (Func<int> g in getters)
{
Console.WriteLine(g()); // 3
// 3
// 3
}Программа компилируется, но печатает 3 три раза, а не 0, 1 и 2. У цикла for одна переменная i на все проходы. Все три лямбды держат её, а не её значения. Когда их вызывают, цикл уже закончился. Имени i за циклом нет, но саму переменную держат лямбды, и в ней лежит 3.
Исправление — своя переменная на каждый проход. Внутри цикла скопируйте i: int copy = i;, а в лямбде используйте copy. Тогда программа напечатает 0, 1 и 2.
Цикл foreach делает такую копию сам: с C# 5 у каждого его прохода своя переменная. Поэтому лямбды из foreach по массиву [0, 1, 2] вернут 0, 1 и 2. О замыканиях в цикле тоже любят спрашивать на собеседовании.
Главное
- Замыкание — лямбда или локальная функция, которая использует переменную из окружающего кода.
- Замыкание захватывает саму переменную, а не её значение: оно видит каждое изменение и может само менять переменную.
- Захваченная переменная живёт, пока жива лямбда, даже после выхода из метода, где её объявили.
- У цикла
forодна переменная на все проходы, а уforeachсвоя на каждый проход.