Какой может быть отрицательный эффект от замыкания?
Что проверяют
Понимаете ли вы, во что компилятор превращает лямбду с захватом и чем за это платит программа. Следом часто просят разобрать пример с циклом или убрать аллокации в горячем пути.
Как отвечать
Начните с механики. Замыкание захватывает переменную, а не её значение. Компилятор переносит захваченные локальные переменные в поля сгенерированного класса (в IL это что-то вроде <>c__DisplayClass0_0), создаёт его экземпляр в куче, а лямбда становится делегатом на его метод. Отсюда минусы:
- аллокации: захватывающая лямбда стоит объекта замыкания и делегата на каждый вызов метода. Лямбда без захвата кешируется в статическом поле. В LINQ и горячих обработчиках это заметная нагрузка на GC;
- продление жизни: пока жив делегат, живы все захваченные переменные. Лямбда, подписанная на событие долгоживущего объекта и захватившая
this, держит весь экземпляр, а анонимную лямбду без сохранённой ссылки не отписать; - общий класс замыкания: лямбды одной области видимости делят один объект, и короткая может удерживать массив, захваченный соседней долгоживущей;
- разделяемое состояние: захваченную переменную меняют из разных мест, а в
Task.Runэто ещё и гонка.
Лечение: static лямбды (C# 9), в которых захват запрещён компилятором; перегрузки с аргументом состояния, например GetOrAdd(key, factory, arg) у ConcurrentDictionary; явная отписка через сохранённый делегат.
Про циклы: в for переменная одна на весь цикл, и все лямбды вернут последнее значение. В foreach начиная с C# 5 переменная своя на каждой итерации. Для for помогает копия var copy = i; внутри тела.
Где спотыкаются
Говорят, что foreach до сих пор ломает замыкания. Это поведение исправлено больше десяти лет назад.
Называют замыкание утечкой памяти по определению. Утечка возникает, только когда делегат живёт дольше нужного, например висит в подписке на статическое событие.
Считают, что лямбда копирует значение в момент создания. Отсюда баги, когда переменную меняют после создания делегата и ждут старого значения.