Как закрытие работает за кулисами? (С#)
Я чувствую, что у меня довольно приличное понимание закрытий, как их использовать, и когда они могут быть полезны. Но я не понимаю, как они на самом деле работают за кулисами в памяти. Пример кода:
public Action Counter()
{
int count = 0;
Action counter = () =>
{
count++;
};
return counter;
}
Обычно, если {count} не был захвачен закрытием, его жизненный цикл будет привязан к методу Counter(), и после его завершения он исчезнет с остальной частью распределения стека для Counter(). Что происходит, когда оно закрывается? Охватывает ли все распределение стека для этого вызова Counter()? Копирует ли он в кучу? Он никогда не получает выделение в стеке, но распознается компилятором как закрытое и поэтому всегда живет в куче?
В этом конкретном вопросе меня в первую очередь интересует, как это работает в С#, но не будет против сравнения с другими языками, поддерживающими закрытие.
Ответы
Ответ 1
Компилятор (в отличие от среды выполнения) создает другой класс/тип. Функция с вашим закрытием и любые переменные, которые вы закрыли поверх/подняли/захватили, переписываются на протяжении всего кода в качестве членов этого класса. Закрытие в .Net реализовано как один экземпляр этого скрытого класса.
Это означает, что ваша переменная count является членом другого класса целиком, а время жизни этого класса работает как любой другой объект clr; он не имеет права на сбор мусора, пока он больше не будет укоренен. Это означает, что до тех пор, пока у вас есть вызываемая ссылка на метод, он никуда не денется.
Ответ 2
Ваше третье предположение верно. Компилятор будет генерировать код следующим образом:
private class Locals
{
public int count;
public void Anonymous()
{
this.count++;
}
}
public Action Counter()
{
Locals locals = new Locals();
locals.count = 0;
Action counter = new Action(locals.Anonymous);
return counter;
}
Имеют смысл?
Кроме того, вы просили сравнения. VB и JScript оба создают закрытие почти таким же образом.
Ответ 3
Спасибо @HenkHolterman. Поскольку это уже объяснялось Эриком, я добавил ссылку, чтобы показать, какой фактический класс генерирует компилятор для закрытия. Я хотел бы добавить, что создание классов отображения с помощью компилятора С# может привести к утечке памяти. Например, внутри функции есть переменная int, которая захватывается лямбда-выражением, и есть еще одна локальная переменная, которая просто содержит ссылку на большой массив байтов. Компилятор создаст один экземпляр класса отображения, который будет содержать ссылки на переменные i.e. int и массив байтов. Но массив байтов не будет собирать мусор до тех пор, пока не будет указана лямбда.
Ответ 4
Ответ Эрика Липперта действительно поражает точку. Однако было бы неплохо создать картину того, как работают фреймы и кадры стека. Для этого это помогает взглянуть на несколько более сложный пример.
Вот код захвата:
public class Scorekeeper {
int swish = 7;
public Action Counter(int start)
{
int count = 0;
Action counter = () => { count += start + swish; }
return counter;
}
}
И вот что я думаю, что эквивалент был бы (если нам повезет, Эрик Липперт прокомментирует, действительно ли это правильно или нет):
private class Locals
{
public Locals( Scorekeeper sk, int st)
{
this.scorekeeper = sk;
this.start = st;
}
private Scorekeeper scorekeeper;
private int start;
public int count;
public void Anonymous()
{
this.count += start + scorekeeper.swish;
}
}
public class Scorekeeper {
int swish = 7;
public Action Counter(int start)
{
Locals locals = new Locals(this, start);
locals.count = 0;
Action counter = new Action(locals.Anonymous);
return counter;
}
}
Дело в том, что локальный класс заменяет весь кадр стека и инициализируется соответственно каждый раз, когда вызывается метод Counter. Обычно кадр стека включает ссылку на 'this', плюс аргументы метода, а также локальные переменные. (Фрейм стека также действует при вводе блока управления.)
Следовательно, у нас нет только одного объекта, соответствующего захваченному контексту, вместо этого у нас фактически есть один объект на каждый захваченный фрейм стека.
Исходя из этого, мы можем использовать следующую ментальную модель: кадры стека сохраняются в куче (а не в стеке), тогда как сам стек содержит указатели на фреймы стека, которые находятся в куче. Лямбда-методы содержат указатель на стек стека. Это делается с использованием управляемой памяти, поэтому рамка накладывается на кучу, пока она больше не понадобится.
Очевидно, что компилятор может реализовать это, используя только кучу, когда объект кучи требуется для поддержки лямбда-закрытия.
Что мне нравится в этой модели, так это интегрированная картина для "yield return". Мы можем думать о методе итератора (используя return return), как если бы он был создан в куче, и указатель ссылки, хранящийся в локальной переменной в вызывающем, для использования во время итерации.