Как затворы работают за кулисами? (С #) - PullRequest
45 голосов
/ 18 декабря 2009

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

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

    return counter;
}

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

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

Ответы [ 4 ]

46 голосов
/ 18 декабря 2009

Ваше третье предположение верно. Компилятор сгенерирует код так:

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 создают замыкания практически одинаково.

33 голосов
/ 18 декабря 2009

Компилятор (в отличие от среды выполнения) создает другой класс / тип. Функция с вашим замыканием и любые переменные, которые вы закрыли / подняли / перехватили, переписываются по всему вашему коду как члены этого класса. Закрытие в .Net реализовано как один экземпляр этого скрытого класса.

Это означает, что ваша переменная count полностью принадлежит другому классу, и время жизни этого класса работает как любой другой объект clr; он не имеет права на сборку мусора до тех пор, пока не перестанет укореняться. Это означает, что если у вас есть вызываемая ссылка на метод, он никуда не денется.

0 голосов
/ 25 октября 2017

Ответ Эрика Липперта действительно суть. Однако было бы неплохо составить представление о том, как стеки кадров и захваты работают в целом. Для этого полезно взглянуть на чуть более сложный пример.

Вот код захвата:

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

0 голосов
/ 05 июля 2014

Спасибо @HenkHolterman. Поскольку Эрик уже объяснил, я добавил ссылку, чтобы показать, какой фактический класс генерирует компилятор для закрытия. Я хотел бы добавить, что создание классов отображения компилятором C # может привести к утечкам памяти. Например, внутри функции есть переменная int, захваченная лямбда-выражением, и другая локальная переменная, которая просто содержит ссылку на большой байтовый массив. Компилятор создаст один экземпляр класса отображения, который будет содержать ссылки как на переменные, т. Е. На int, так и на байтовый массив. Но байтовый массив не будет собираться мусором до тех пор, пока не будет получена ссылка на лямбду.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...