Чи потрібен збір сміття для забезпечення безпечного закриття?


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:

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:

- 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 це стає можливим шляхом вивезення сміття: закриття зберігає посилання на захоплене значення, яке згодом утилізується сміттєзбірником при знищенні закриття.

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

Або які інші механізми можуть забезпечити обґрунтованість закриття без вивезення сміття: Скопіюйте захоплені значення та зберігайте їх всередині закриття? Обмежте термін служби самого закриття, щоб його не можна було викликати після закінчення терміну дії захоплених змінних?

Які найпопулярніші підходи?

EDIT

Я не думаю, що наведений вище приклад не можна пояснити / реалізувати, скопіювавши захоплену змінну (и) у закриття. Загалом, захоплені змінні можуть бути будь-якого типу, наприклад, вони можуть бути прив'язані до дуже великого (незмінного) списку. Отже, при реалізації було б дуже неефективно копіювати ці значення.

Для повноти, ось ще один приклад використання посилань (та побічних ефектів):

(* 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:

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 реалізований для цілей цього питання.
Йорг W Міттаг

3
@btilly: Що означає "справжнє" збирання сміття? Зміна рахунків - це ще один спосіб впровадження GC. Трасування є більш популярним, напевно, через труднощі збору циклів із перерахунком. (Зазвичай у вас все одно виходить окремий трасування GC, тож навіщо турбуватися впроваджувати два GC, якщо ви можете обійтись одним.) Але є й інші способи поводження з циклами. 1) Просто заборони їх. 2) Просто ігноруйте їх. (Якщо ви робите реалізацію для швидких одноразових сценаріїв, чому б ні?) 3) Спробуйте чітко їх виявити. (Виявляється, наявність доступного знижки може пришвидшити це.)
Jörg W Mittag

1
Це залежить від того, чому ви хочете закриття в першу чергу. Якщо ви хочете реалізувати, скажімо, повну семантику обчислення лямбда, вам, безумовно, потрібен GC, період. Іншого шляху немає. Якщо ви хочете чогось, що віддалено нагадує закриття, але не дотримується точної семантики такого (наприклад, у C ++, Delphi, будь-що інше) - робіть все, що завгодно, використовуйте аналіз регіонів, використовуйте повністю ручне управління пам’яттю.
SK-логіка

2
@ Mason Wheeler: Закриття - це лише значення, загалом неможливо передбачити, як вони будуть переміщуватися під час виконання. У цьому сенсі вони нічого особливого, те саме було б справедливим для рядка, списку тощо.
Джорджіо

Відповіді:


14

Мова програмування Rust цікава в цьому аспекті.

Іржа - це системна мова, з необов'язковим GC, і розроблялася із закриттям з самого початку.

Як і інші змінні, кришки іржі бувають різних ароматів. Закривання штабелів , найпоширеніші, призначені для використання одним ударом. Вони живуть на стеці і можуть посилатися на що завгодно. Власні закриття беруть право власності на захоплені змінні. Я думаю, що вони живуть на так званій "обмінній купі", яка є глобальною купою. Їх тривалість життя залежить від того, хто їм належить. Керовані закриття живуть на локальній купі завдань і відстежуються за допомогою GC завдання. Я не впевнений у їхніх захопленнях.


1
Дуже цікаве посилання та посилання на мову Іржа. Спасибі. +1.
Джорджіо

1
Я багато думав, перш ніж прийняти відповідь, тому що вважаю відповідь Мейсона також дуже інформативною. Я вибрав цю, тому що вона є одночасно інформативною і цитує менш відому мову з оригінальним підходом до закриття.
Джорджіо

Дякую за це. Я дуже захоплений цією молодою мовою, і я радий поділитися своїм інтересом. Я не знав, що безпечніші закриття можливі без ГК, перш ніж я почув про Расту.
barjak

9

На жаль, починаючи з GC, ви стаєте жертвою синдрому XY:

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

Зауважте, однак, що ідея продовження терміну експлуатації змінної не потрібна для закриття; це просто привнесене GC; оригінальна заява про безпеку - це лише те, що закриті над змінними повинні жити до тих пір, як закриття (і навіть це хитко, ми могли б сказати, що вони повинні жити до останнього виклику закриття).

По суті, я бачу два підходи (і вони потенційно можуть поєднуватися):

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

Останнє - це просто симетричний підхід. Він не часто використовується, але якщо, як і у Руста, у вас система типів, відомих регіону, це, безумовно, можливо.


7

Збір сміття не потрібен для безпечного закриття під час фіксації змінних за значенням. Одним із визначних прикладів є C ++. C ++ не має стандартного збору сміття. Лямбди в C ++ 11 - це закриття (вони захоплюють локальні змінні з навколишнього простору). Кожна змінна, захоплена лямбда, може бути вказана для захоплення за значенням або посиланням. Якщо він зафіксований за посиланням, то можна сказати, що це не безпечно. Однак якщо змінна захоплена за значенням, то це безпечно, оскільки захоплена копія та оригінальна змінна є окремими і мають незалежний час життя.

У прикладі SML, який ви навели, пояснити просто: змінні фіксуються за значенням. Не потрібно "продовжувати термін служби" будь-якої змінної, оскільки ви можете просто скопіювати її значення в закриття. Це можливо тому, що в ML не можна присвоювати змінні. Тож різниці між однією копією та багатьма незалежними копіями немає. Хоча SML має збір сміття, він не пов'язаний із захопленням змінних шляхом закриття.

Збір сміття також не потрібен для безпечного закриття при захопленні змінних за посиланням (видом). Одним із прикладів є розширення Apple Blocks на мови C, C ++, Objective-C і Objective-C ++. У C та C ++ немає стандартного збору сміття. За замовчуванням блокує захоплення змінних за значенням. Однак якщо локальна змінна оголошена за допомогою __block, то блоки захоплюють їх, здавалося б, "посиланням", і вони безпечні - їх можна використовувати навіть після області, в якій був визначений блок. Що відбувається тут, це те, що __blockзмінні насправді є спеціальна структура внизу, і коли блоки копіюються (блоки повинні бути скопійовані для того, щоб в першу чергу використовувати їх поза рамками), вони "переміщують" структуру для__block Перемінна в купу, і блок управляє своєю пам'яттю, я вважаю, через підрахунок посилань.


4
"Збір сміття не потрібен для закриття.": Питання в тому, чи потрібно воно, щоб мова могла забезпечити безпечне закриття. Я знаю, що я можу писати безпечні закриття в C ++, але мова їх не виконує. Про закриття, що продовжують термін служби захоплених змінних, див. Редагування мого питання.
Джорджіо

1
Я припускаю, що питання можна переробити: для безпечного закриття .
Маттьє М.

1
Заголовок містить термін "безпечне закриття", ви думаєте, я міг би сформулювати це кращим чином?
Джорджіо

1
Чи можете ви виправити другий абзац? У SML закриття даних продовжує термін служби даних, на які посилаються захоплені змінні. Також вірно, що ви не можете призначити змінні (змінити їх прив'язку), але у вас є змінні дані (через ref's). Отже, гаразд, можна обговорити, чи пов’язана реалізація закриттів із вивезенням сміття чи ні, але вищезазначені твердження слід виправити.
Джорджіо

1
@Giorgio: А як зараз? Крім того, в якому сенсі ви вважаєте моє твердження про те, що закриттю не потрібно продовжувати термін служби захопленої змінної неправильно? Коли ви говорите про змінні дані, ви говорите про типи посилань ( refs, масиви тощо), які вказують на структуру. Але цінність - сама посилання, а не те, на що вона вказує. Якщо у вас є var a = ref 1і ви робите копію var b = a, і ви використовуєте b, це означає, що ви все ще використовуєте a? Ви маєте доступ до тієї самої структури, на яку вказує a? Так. Ось так працюють ці типи в SML і не мають нічого спільного з закриттями
user102008

6

Збір сміття не потрібен для того, щоб здійснити закриття. У 2008 році мова Delphi, яка не збирається сміттям, додала реалізацію закриття. Це працює так:

Компілятор створює об'єкт функтора під кришкою, який реалізує інтерфейс, що представляє собою закриття. Всі закриті локальні змінні змінюються від локальних для процедури додавання до полів об'єкта функтора. Це забезпечує збереження стану до тих пір, поки є функтор.

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

Функтор посилається на посилання на закриття, використовуючи синтаксичний цукор, щоб він виглядав перед розробником як функціональний покажчик замість інтерфейсу. Він використовує систему підрахунку довідок Delphi для інтерфейсів для того, щоб об’єкт функтора (і весь стан, який він утримує) залишався "живим" стільки, скільки потрібно, і тоді він звільняється, коли знижка знижується до 0.


1
Ах, так можна лише захопити локальну змінну, а не аргументи! Це здається розумним і розумним компромісом! +1
Джорджіо

1
@Giorgio: Він може захоплювати аргументи, тільки не ті, які є параметрами var .
Мейсон Уілер

2
Ви також втрачаєте можливість мати 2 закриття, які спілкуються через спільний приватний стан. Ви не зіткнетеся з цим у основних випадках використання, але це обмежує вашу здатність робити складні речі. Ще чудовий приклад того, що можливо!
btilly

3
@btilly: Насправді, якщо ви помістите 2 закриття в одну і ту ж функцію, що додає, це абсолютно законно. Вони в кінцевому підсумку обмінюються одним і тим же об'єктом функтора, і якщо вони модифікують той самий стан, що і один одного, зміни в одному відобразяться в іншому.
Мейсон Уілер

2
@MasonWheeler: "Ні. Збір сміття не є детермінованим за своєю суттю; немає гарантії, що будь-який даний об'єкт коли-небудь буде зібраний, не кажучи вже про те, коли це станеться. Але підрахунок посилань детермінований: ви гарантуєте компілятором, що об'єкт буде звільнено одразу після того, як підрахунок впаде до 0. ". Якби я мав копейку кожен раз, коли чув, що цей міф увічнюється. OCaml має детермінований GC. C ++ безпека shared_ptrдля потоків не є детермінованою, оскільки деструктори переходять на зниження до нуля.
Джон Харроп
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.