1. Что такое замыкания?
В программировании замыкание (closure) — это функция, которая захватывает переменные из внешнего контекста. Проще говоря, если лямбда-выражение или анонимный метод использует переменные, объявленные вне своего тела, такая функция превращается в замыкание. Она «помнит», какие значения были у этих переменных в момент создания.
Аналогия из жизни:
Представьте, что вы записали секретный рецепт на листке бумаги и спрятали его в конверт. Даже если сам листок потом потеряется или его нельзя будет найти (переменная станет недоступной напрямую), у кого-то с этим конвертом (лямбдой) всё ещё есть доступ к рецепту.
Простой пример:
int x = 42;
Func<int> getX = () => x;
Console.WriteLine(getX()); // 42
Здесь getX — это замыкание, потому что оно использует переменную x, объявленную вне себя.
2. Почему захват переменных — это важно?
В C# замыкания используются буквально на каждом углу:
- В коллекциях и LINQ-запросах
- Для передачи параметров в события или асинхронные методы
- При создании обработчиков событий внутри циклов
- Для хранения «контекста» между разными вызовами
Без замыканий многие стандартные практики C# были бы невозможны или выглядели бы крайне неудобно.
Пример из жизни
Давайте представим, что мы разрабатываем приложение-напоминалку: пользователь задает серию напоминаний, и когда-нибудь (через минуту, час, неделю...) оно должно выдать нужное сообщение. Легко передать в обработчик лямбду, которая «запомнила» что именно напоминать. Это и есть захват переменных — классика.
3. Как C# реализует захват переменных
За кулисами C# делает хитрый трюк: когда у вас есть лямбда-выражение, использующее внешние переменные, компилятор автоматически создает вспомогательный класс — display class. Все «захваченные» переменные становятся полями этого класса.
Схематично выглядит так:
Внешняя переменная ──► DisplayClass
▲
│
Замыкание (лямбда)
Иллюстрация в коде
Вот что происходит «под капотом»:
int x = 5;
Func<int> f = () => x;
// Здесь компилятор делает примерно так:
class DisplayClass
{
public int x;
public int Lambda() => x;
}
DisplayClass display = new DisplayClass();
display.x = 5;
Func<int> f = display.Lambda;
Это объясняет, почему замыкание продолжает видеть актуальное значение переменной, даже после выхода за пределы блока, где она объявлена.
4. Значение переменной «заморожено» или изменяется?
В C# переменные захватываются по ссылке, а не по значению. Это значит, если лямбда-выражение использует переменную, и эта переменная изменяется в другом месте — лямбда увидит новое значение.
Пример:
int x = 10;
Func<int> getX = () => x;
x = 20;
Console.WriteLine(getX()); // 20, а не 10!
Студенты часто ожидают, что getX() всегда вернет 10, потому что переменная «захвачена». Однако на самом деле лямбда читает переменную, которая ещё существует и которую можно менять.
Когда значение всё-таки фиксируется?
Если переменная объявлена в цикле с новой областью видимости, например с помощью foreach, и по каждой итерации создается новая переменная — лямбда «запомнит» текущее значение.
5. Примеры: замыкание в цикле — типичная ловушка
Часто встречаемая ошибка
Хотим создать массив делегатов, каждый из которых выводит свой собственный номер из цикла:
Action[] actions = new Action[5];
for (int i = 0; i < 5; i++)
{
actions[i] = () => Console.WriteLine(i);
}
foreach (var action in actions)
action();
Что выведет программа?
5
5
5
5
5
Ого! Почему не 0,1,2,3,4?
Причина:
Лямбда захватывает одну и ту же переменную i, которая продолжает изменяться по мере прохода цикла. Когда вы позже вызовёте делегаты, i уже равно 5.
Как правильно?
Нужно создать отдельную переменную в теле цикла:
Action[] actions = new Action[5];
for (int i = 0; i < 5; i++)
{
int index = i; // Новая переменная для каждой итерации!
actions[i] = () => Console.WriteLine(index);
}
foreach (var action in actions)
action();
Теперь программа выведет:
0
1
2
3
4
Как это связано с display class?
В первом варианте все делегаты «цепляются» к одному полю — вот почему результат одинаковый. Во втором случае создается новая локальная переменная для каждой итерации, а значит для каждого делегата формируется отдельный DisplayClass с уникальным значением.
6. Практические сценарии использования захвата переменных
Пример 1: Обработка событий с «контекстом»
Допустим, в нашем маленьком приложении есть список задач, и к каждой привязан обработчик для кнопки «выполнить». Нам нужно, чтобы лямбда внутри обработчика «помнила», какую задачу обработать:
foreach (var task in tasks)
{
button.Click += (sender, e) => CompleteTask(task);
}
Здесь переменная task захватывается на каждой итерации. Важно удостовериться, что она правильно объявлена внутри цикла, чтобы не попасть в ловушку, как в примере выше.
Пример 2: Асинхронные операции
Часто замыкания используют для передачи параметров в асинхронную логику — например, сохранение переменной в локальном «слоте» при запуске асинхронной задачи:
for (int i = 0; i < 3; i++)
{
int index = i; // Обязательно!
Task.Run(() => Console.WriteLine($"Task #{index}"));
}
Без локальной переменной все задачи напечатают один и тот же номер, что обычно не то, чего мы хотели.
Пример 3: LINQ-запросы
LINQ к коллекциям часто использует замыкания, чтобы фильтровать или преобразовывать элементы с учетом переменных из внешней области. Например:
string prefix = "Task";
var filtered = tasks.Where(t => t.Name.StartsWith(prefix));
Здесь лямбда в Where запомнила значение prefix и вызывает метод StartsWith.
7. Особенности, ограничения и типичные ошибки при работе с замыканиями
Ошибка №1: использование одной переменной в цикле для всех делегатов.
Если в цикле все делегаты ссылаются на одну и ту же переменную, результат будет неожиданным. Важно создавать новую локальную переменную внутри цикла для каждого делегата, чтобы избежать общей ссылки.
Ошибка №2: замыкания на переменные вне метода.
Если замыкание захватывает поле класса или переменную, объявленную вне текущего метода, оно будет удерживать ссылку на эту переменную. Это может привести к утечкам памяти, так как сборщик мусора не сможет освободить объект, пока на него есть ссылки из замыканий.
Ошибка №3: долгоживущие делегаты с замыканиями.
Если делегат с замыканием сохраняется на долгое время (например, в статическом поле), переменные, на которые он ссылается, тоже остаются в памяти дольше, чем ожидается. Это часто становится причиной скрытых утечек памяти и проблем с производительностью.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ