Нужен ли сбор мусора для реализации безопасных замыканий?

14

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

Первый пример - это функция SML, которая создает список чисел от 1 до x, где x - это параметр функции:

fun countup_from1 (x: int) =
    let
        fun count (from: int) =
            if from = x
            then from :: []
            else from :: count (from + 1)
    in
        count 1
    end

В SML REPL:

val countup_from1 = fn : int -> int list
- countup_from1 5;
val it = [1,2,3,4,5] : int list

countup_from1Функция использует замыкание помощника , countкоторый собирает и использует переменную xиз контекста.

Во втором примере, когда я вызываю функцию create_multiplier t, я возвращаю функцию (фактически, замыкание), которая умножает свой аргумент на t:

fun create_multiplier t = fn x => x * t

В SML REPL:

- fun create_multiplier t = fn x => x * t;
val create_multiplier = fn : int -> int -> int
- val m = create_multiplier 10;
val m = fn : int -> int
- m 4;
val it = 40 : int
- m 2;
val it = 20 : int

Таким образом, переменная mсвязана с замыканием, возвращаемым вызовом функции, и теперь я могу использовать его по своему усмотрению.

Теперь, чтобы замыкание работало должным образом в течение всего его жизненного цикла, нам нужно продлить время жизни захваченной переменной t(в данном примере это целое число, но оно может быть значением любого типа). Насколько я знаю, в SML это стало возможным благодаря сборке мусора: замыкание сохраняет ссылку на захваченное значение, которое впоследствии удаляется сборщиком мусора при уничтожении замыкания.

Мой вопрос: в общем, является ли сборка мусора единственным возможным механизмом, обеспечивающим безопасность затворов (вызываемых в течение всей их жизни)?

Или каковы другие механизмы, которые могли бы обеспечить достоверность замыканий без сбора мусора: скопировать захваченные значения и сохранить их внутри замыкания? Ограничить время жизни самого замыкания, чтобы оно не могло быть вызвано после истечения его перехваченных переменных?

Каковы наиболее популярные подходы?

РЕДАКТИРОВАТЬ

Я не думаю, что приведенный выше пример можно объяснить / реализовать, скопировав захваченную переменную (и) в замыкание. В общем, захваченные переменные могут быть любого типа, например, они могут быть связаны с очень большим (неизменным) списком. Таким образом, при реализации было бы очень неэффективно копировать эти значения.

Для полноты картины приведу еще один пример использования ссылок (и побочных эффектов):

(* Returns a closure containing a counter that is initialized
   to 0 and is incremented by 1 each time the closure is invoked. *)
fun create_counter () =
    let
        (* Create a reference to an integer: allocate the integer
           and let the variable c point to it. *)
        val c = ref 0
    in
        fn () => (c := !c + 1; !c)
    end

(* Create a closure that contains c and increments the value
   referenced by it it each time it is called. *)
val m = create_counter ();

В SML REPL:

val create_counter = fn : unit -> unit -> int
val m = fn : unit -> int
- m ();
val it = 1 : int
- m ();
val it = 2 : int
- m ();
val it = 3 : int

Таким образом, переменные также могут быть захвачены по ссылке и остаются живыми после завершения вызова функции, создавшей их ( create_counter ()).

Джорджио
источник
2
Любые переменные, которые являются закрытыми, должны быть защищены от сборки мусора, а любые переменные, которые не являются закрытыми, должны иметь право на сборку мусора. Из этого следует, что любой механизм, который может надежно отслеживать, закрыта ли переменная или нет, также может надежно освободить память, занимаемую переменной.
Роберт Харви
3
@btilly: Пересчет - только одна из многих различных стратегий реализации для сборщика мусора. Неважно, как GC реализован для целей этого вопроса.
Йорг Миттаг
3
@btilly: Что означает «настоящая» сборка мусора? Пересчет - это еще один способ реализации GC. Трассировка более популярна, вероятно, из-за трудностей сбора циклов с пересчетом. (Как правило, в конечном итоге вы в конечном итоге получаете отдельный GC для трассировки, поэтому зачем использовать два GC, если вы можете обойтись одним.) Но существуют и другие способы работы с циклами. 1) Просто запретите им. 2) Просто игнорируйте их. (Если вы делаете реализацию для быстрых одноразовых скриптов, почему бы и нет?) 3) Попробуйте явно их обнаружить. (Оказывается, что наличие перерасчета может ускорить это.)
Йорг Миттаг
1
Это зависит от того, почему вы хотите закрытия в первую очередь. Если вы хотите реализовать, скажем, семантику полного лямбда-исчисления, вам определенно нужен GC, точка. Другого пути нет. Если вы хотите что-то, что отдаленно напоминает замыкания, но не соответствует точной семантике этого (как в C ++, Delphi и т. Д.) - делайте что хотите, используйте анализ области, используйте полностью ручное управление памятью.
SK-logic
2
@Mason Wheeler: Замыкания - это просто значения, в общем случае невозможно предсказать, как они будут перемещаться во время выполнения. В этом смысле они ничего особенного, то же самое будет справедливо для строки, списка и так далее.
Джорджио

Ответы:

14

Язык программирования Rust интересен в этом аспекте.

Rust является системным языком с необязательным GC и изначально был разработан с замыканиями .

Как и другие переменные, ржавчины закрываются различными вкусами. Закрытия стека , наиболее распространенные, предназначены для одноразового использования. Они живут в стеке и могут ссылаться на что угодно. Собственные замыкания становятся владельцами захваченных переменных. Я думаю, что они живут на так называемой «куче обмена», которая является глобальной кучей. Их продолжительность жизни зависит от того, кому они принадлежат. Управляемые замыкания находятся в локальной куче задачи и отслеживаются GC задачи. Я не уверен насчет их ограничений по захвату.

оборота барджак
источник
1
Очень интересная ссылка и ссылка на язык Rust. Благодарю. +1.
Джорджио
1
Я много думал, прежде чем принять ответ, потому что нахожу ответ Мейсона также очень информативным. Я выбрал этот, потому что он и информативный, и цитирует менее известный язык с оригинальным подходом к замыканиям.
Джорджио
Спасибо за это. Мне очень нравится этот молодой язык, и я рад поделиться своим интересом. Я не знал, возможны ли более безопасные закрытия без GC, прежде чем я услышал о Rust.
Барджак
9

К сожалению, начиная с GC, вы становитесь жертвой синдрома XY:

  • замыкания требуют, чтобы переменные, с которыми они закрывались, живут, пока замыкание делает (по соображениям безопасности)
  • используя GC, мы можем продлить время жизни этих переменных достаточно долго
  • Синдром XY: есть ли другие механизмы для продления жизни?

Отметьте, однако, что идея продления времени жизни переменной не является необходимой для замыкания; это просто перенесено GC; исходное утверждение безопасности - только закрытые переменные должны жить столько же времени, сколько и замыкание (и даже если это шатко, мы можем сказать, что они должны существовать до последнего вызова замыкания).

По сути, есть два подхода, которые я вижу (и они могут быть объединены):

  1. Продлить время жизни закрытых переменных (как, например, GC)
  2. Ограничить срок службы закрытия

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

Матье М.
источник
7

Сборка мусора не требуется для безопасного закрытия при захвате переменных по значению. Одним из ярких примеров является C ++. C ++ не имеет стандартной сборки мусора. Лямбды в C ++ 11 являются замыканиями (они захватывают локальные переменные из окружающей области видимости). Каждая переменная, захваченная лямбда-выражением, может быть указана для захвата по значению или по ссылке. Если он захвачен ссылкой, то вы можете сказать, что это небезопасно. Однако, если переменная захвачена значением, это безопасно, потому что захваченная копия и исходная переменная являются отдельными и имеют независимые времена жизни.

В приведенном вами примере SML это легко объяснить: переменные фиксируются по значению. Нет необходимости «продлевать время жизни» любой переменной, потому что вы можете просто скопировать ее значение в замыкание. Это возможно, потому что в ML переменные не могут быть назначены. Таким образом, нет разницы между одной копией и многими независимыми копиями. Хотя в SML есть сборщик мусора, он не связан с захватом переменных с помощью замыканий.

Сборка мусора также не требуется для безопасного закрытия при захвате переменных по ссылке (вид). Одним из примеров является расширение Apple Blocks для языков C, C ++, Objective-C и Objective-C ++. В C и C ++ нет стандартной сборки мусора. Блоки захватывают переменные по значению по умолчанию. Однако, если локальная переменная объявлена ​​с помощью __block, тогда блоки захватывают их, по-видимому, «по ссылке», и они безопасны - их можно использовать даже после области, в которой был определен блок. В данном случае __blockпеременные на самом деле являются специальная структура внизу, и когда блоки копируются (блоки должны быть скопированы, чтобы использовать их вне области видимости в первую очередь), они «перемещают» структуру для__block переменная в кучу, а блок управляет своей памятью, я считаю путем подсчета ссылок.

user102008
источник
4
«Сборка мусора не требуется для замыканий.»: Вопрос в том, нужен ли он для того, чтобы язык мог обеспечивать безопасные замыкания. Я знаю, что могу писать безопасные замыкания на C ++, но язык их не применяет. Заметки, которые продлевают время жизни захваченных переменных, см. В редактировании моего вопроса.
Джорджио
1
Я полагаю, что вопрос можно перефразировать в: для безопасного закрытия .
Матье М.
1
Название содержит термин «безопасное закрытие», как вы думаете, я мог бы сформулировать это лучше?
Джорджио
1
Можете ли вы исправить второй абзац? В SML замыкания продлевают время жизни данных, на которые ссылаются захваченные переменные. Кроме того, это правда, что вы не можете назначать переменные (изменять их привязку), но у вас есть изменяемые данные (через ref). Итак, хорошо, можно спорить, связана ли реализация замыканий со сборкой мусора, но вышеприведенные утверждения следует исправить.
Джорджио
1
@ Джорджио: Как насчет сейчас? Кроме того, в каком смысле вы находите мое утверждение о том, что замыканиям не нужно продлевать время жизни захваченной переменной неправильно? Когда вы говорите об изменчивых данных, вы говорите о ссылочных типах ( refs, массивах и т. Д.), Которые указывают на структуру. Но ценность - это сама ссылка, а не то, на что она указывает. Если у вас есть, var a = ref 1вы делаете копию var b = aи используете b, значит ли это, что вы все еще используете a? Нет. У вас есть доступ к той же структуре, на которую указывает a? Да. Именно так эти типы работают в SML и не имеют ничего общего с замыканиями
user102008
6

Сборка мусора не требуется для реализации замыканий. В 2008 году язык Delphi, который не является сборщиком мусора, добавил реализацию замыканий. Это работает так:

Компилятор создает объект функтора под капотом, который реализует интерфейс, представляющий замыкание. Все закрытые локальные переменные заменяются с локальных для процедуры включения на поля объекта функтора. Это гарантирует, что состояние сохраняется до тех пор, пока есть функтор.

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

На функтор ссылается ссылка замыкания, использующая синтаксический сахар, чтобы заставить разработчика выглядеть как указатель на функцию вместо интерфейса. Он использует систему подсчета ссылок Delphi для интерфейсов, чтобы гарантировать, что объект функтора (и все его состояние) остается «живым» столько, сколько ему нужно, а затем он освобождается, когда refcount падает до 0.

Мейсон Уилер
источник
1
Ах, так что возможно захватить только локальную переменную, а не аргументы! Это кажется разумным и умным компромиссом! +1
Джорджио
1
@ Джорджио: Он может захватывать аргументы, но не те, которые являются параметрами var .
Мейсон Уилер
2
Вы также теряете возможность иметь 2 замыкания, которые общаются через общее частное состояние. Вы не столкнетесь с этим в основных случаях использования, но это ограничивает вашу способность делать сложные вещи. Еще отличный пример того, что возможно!
Btilly
3
@btilly: На самом деле, если вы поместите 2 замыкания в одну и ту же функцию, это совершенно законно. В конечном итоге они совместно используют один и тот же объект функтора, и если они изменяют одно и то же состояние, изменения одного из них будут отражены в другом.
Мейсон Уилер
2
@MasonWheeler: "Нет. Сборка мусора недетерминирована по своей природе; нет никакой гарантии, что какой-либо данный объект будет когда-либо собран, не говоря уже о том, когда это произойдет. Но подсчет ссылок является детерминированным: компилятор гарантирует, что объект будет освобожден сразу после того, как счетчик упадет до 0. " Если бы у меня было десять центов за каждый раз, я слышал, что этот миф увековечен. OCaml имеет детерминированный GC. Потокобезопасность C ++ shared_ptrнедетерминирована, потому что деструкторы стремятся к нулю.
Джон Харроп