Подтвердить что ты не робот

Как закрытие работает за кулисами? (С#)

Я чувствую, что у меня довольно приличное понимание закрытий, как их использовать, и когда они могут быть полезны. Но я не понимаю, как они на самом деле работают за кулисами в памяти. Пример кода:

public Action Counter()
{
    int count = 0;
    Action counter = () =>
    {
        count++;
    };

    return counter;
}

Обычно, если {count} не был захвачен закрытием, его жизненный цикл будет привязан к методу Counter(), и после его завершения он исчезнет с остальной частью распределения стека для Counter(). Что происходит, когда оно закрывается? Охватывает ли все распределение стека для этого вызова Counter()? Копирует ли он в кучу? Он никогда не получает выделение в стеке, но распознается компилятором как закрытое и поэтому всегда живет в куче?

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

4b9b3361

Ответ 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), как если бы он был создан в куче, и указатель ссылки, хранящийся в локальной переменной в вызывающем, для использования во время итерации.