Перейти к содержимому
Теория · Лямбды и LINQ · урок 4 из 10

Замыкания

Представьте, что на карточке с рецептом написано не «две ложки сахара», а «сахар — из синей банки на полке». Повар подойдёт к банке, когда начнёт готовить, и возьмёт то, что лежит в ней в эту минуту. Если после того, как вы отдали карточку, в банку досыпали сахар, повар найдёт и его.

Лямбда умеет ссылаться так же: не на значение, а на переменную из окружающего кода. Посмотрим, что из этого следует:

C#
int limit = 3;
Func<int, bool> isBig = n => n > limit;
Console.WriteLine(isBig(5)); // True
limit = 10;
Console.WriteLine(isBig(5)); // False

Лямбда читает переменную

Давайте разберём программу по строкам:

  1. int limit = 3; объявляет обычную переменную.
  2. Лямбда n => n > limit использует limit, хотя это не её параметр. Она записана там же, где объявлена limit, поэтому видит эту переменную, как её видят соседние строки.
  3. isBig(5) сравнивает 5 > 3 и печатает True.
  4. limit = 10; меняет переменную уже после того, как лямбду создали.
  5. Второй вызов читает limit заново и сравнивает 5 > 10: печатается False.

Лямбду, которая использует переменную из окружающего кода, называют замыканием. С коротким замыканием && и || из темы об операторах это слово не связано: там правая часть условия пропускается, а здесь лямбда держит переменную. Замыкание хранит не значение переменной на момент создания, а саму переменную, как карточка хранит не сахар, а указание на банку. Поэтому оно видит каждое изменение.

Этот вопрос часто задают на собеседовании: что захватывает замыкание — значение или переменную? Ответ: переменную, поэтому вызов видит её текущее значение.

Лямбда меняет переменную

Связь работает и в обратную сторону: замыкание может записать в переменную новое значение, и его увидит весь окружающий код.

C#
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, хотя не получает её через параметр. В теме о методах связь с вызывающим кодом шла через параметры и результат. Но метод, объявленный среди инструкций верхнего уровня, формально считается локальной функцией и видит переменные вокруг себя так же, как лямбда. Это тоже замыкание.

Переменная живёт дольше метода

Замыкание держит переменную, пока живо само, даже если метод, в котором её объявили, уже завершился:

C#
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;
}

Разберём, что здесь происходит:

  1. Каждый вызов MakeCounter создаёт новую переменную n со значением 0 и возвращает лямбду, которая её увеличивает.
  2. MakeCounter завершается, но его n не исчезает: её держит возвращённая лямбда.
  3. a() дважды увеличивает свою n и возвращает 1, потом 2.
  4. b() получил от второго вызова MakeCounter другую n, поэтому начинает с 1.

Здесь аналогия с банкой заканчивается. Банка стоит на полке и без карточки, а переменная n живёт, пока её держит лямбда. Снаружи до неё не добраться: изменить счёт можно только вызовом a().

Частая ошибка

Лямбды создали в цикле for и ждали, что каждая запомнит свой номер:

C#
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 своя на каждый проход.