Open Mazdaywik opened 4 years ago
Варианты с запретом на сравнение замыканий и нарушением аксиоматики отбрасываем. Рассмотрим приемлемые варианты.
Тут возможны два пути: сужения и расширения.
Проблема возникает, когда переменная с замыканием размножается в правой части прогоняемой или специализируемой функции. Следовательно, чтобы сохранять семантику, надо запрещать прогонку или встраивание в таких случаях.
Нечто похожее уже делается — в прогонке запрещены сужения в замыкания, а в специализации построенные функции проверяются на «осмысленность». Цель одна и та же — не дать замыканиям просочиться в образец, что синтаксически некорректно.
К сожалению, такой консервативный подход лишит возможности оптимизировать многие полезные функции, вроде, Map
. А ведь древесные оптимизации создавались фактически ради них.
Требуется, чтобы при размножении в правых частях замыканий они всё равно считались равными. Значит, им нужно добавить некий «ключ», равенство которого соответствует семантическому равенству двух объектов.
Правая часть вида
<F {{ &G\1 (e.X) }}> <H {{ &G\2 (e.Y) }}>
будет неявно преобразовываться к виду
$genkey : s.k1, $genkey : s.k2, <F {{ &G\1 s.k1 (e.X) }}> <H {{ &G\2 s.k2 (e.Y) }}>
Здесь $genkey
генератор ключа, ключи добавляются в объекты замыканий и используются для сравнения на равенство. Теперь, если функции F
и H
имеют вид
$INLINE F { s.X = s.X '+' s.X }
$SPEC H s.STAT;
H { s.X = s.X '*' s.X }
то правая часть преобразуется в
$genkey : s.k1,
$genkey : s.k2,
{{ &G\1 s.k1 (e.X) }} '+' {{ &G\1 s.k1 (e.X) }} <H@1 s.k2 e.Y>
…
H@1 {
s.k2 e.Y = {{ &G\2 s.k2 (e.Y) }} '*' {{ &G\2 s.k2 (e.Y) }};
}
Конструкторы замыканий размножились, но разные их экземпляры имеют равные ключи, а значит, они будут считаться равными.
Заметим, что в этом случае оптимизация сохраняет поведение и для «зеркального» случая: когда в разные моменты времени создаются два замыкания с одинаковым контекстом.
Пример:
F { e.X = <G e.X> <G e.X> }
$INLINE G { e.X = { = e.X } }
После рассахаривания получим:
F { e.X = <G e.X> <G e.X> }
$INLINE G {
e.X = $genkey : s.k, {{ &G\1 s.k (e.X) }};
}
G\1 { s.k (e.X) = e.X }
После встраивания получим:
F {
e.X = $genkey : s.k1, $genkey : s.k2, {{ &G\1 s.k1 (e.X) }} {{ &G\1 s.k2 (e.X) }}
}
Ключи будут разные, а значит, объекты замыканий также будут не равны.
Причём ключом может являться сам указатель на объект замыкания. Операция $genkey
создаёт «пустое» замыкание. Операция создания замыкания смотрит, пустое ли оно. Если пустое, ему присваивается контекст. Если не пустое — то ничего не делается. Фактически, на замыкание можно смотреть как на динамический ящик, который создаётся пустым, и которому можно присвоить только один раз.
Сейчас Рефал-5λ поддерживает только безымянные вложенные функции. Предлагаемая реализация с разделением создания замыкания на две фазы позволяет реализовать и именованные взаимно-рекурсивные вложенные функции.
Самый простой в реализации подход к решению этой проблемы. Минимальные правки рантайма.
Недостаток — быстродействие. Но, с другой стороны, пока на Рефале-5λ никто не использует стиль программирования, требующий сравнения замыканий на равенство. Приравниваются обычно пассивные значения (или глобальные функции как метки АТД-скобок), функции обычно только вызываются.
Почему замыкания представляются s-переменными? Потому что так было в Рефале-7 s-переменная — это значение, которое невозможно при помощи сопоставления с образцом разбить на составные части.
Замыкание состоит из указателя на глобальную функцию и контекста в виде объектного выражения определённого формата. При этом глобальная функция ожидает именно такого формата от контекста. Если дать возможность пользователю манипулировать содержимым замыкания, то он может нарушить инварианты. Поэтому для скрытия реализации замыкания считаются s-переменными. В общем, как-то так это рационализируются. На самом деле, я списал из Рефала-7, когда делал вложенные функции в Простом Рефале.
Но Рефал-5λ допускает и другую форму инкапсуляции — АТД-скобки. Получить доступ к содержимому можно только записав имя функции после открывающей квадратной скобки.
Если имя АТД-терма не будет доступно пользователю (вроде F\1
), то инкапсуляция также будет обеспечена. Замыкания будут просто скобочными термами, поэтому приравниваться по значению будут совершенно естественным образом. Копироваться они будут за линейное время, при этом явно. Вызов будет осуществляться за константное время.
Также потребуется определить семантику для вызова АТД-термов. Тут возможны два варианта:
<[Func e.X] e.Y> → <Func e.X e.Y>
<[Func e.X] e.Y> → <Func [Func e.X] e.Y>
Какой из них предпочесть, пока неясно.
Преимуществом подхода является упрощение и языка, и компилятора. В языке получается на один конструкт меньше (объекты-замыкания), АТД-термы становятся богаче по возможностям (не только инкапсуляция). Компилятор и рантайм станут немного проще.
Недостаток — существенная переделка языка и компилятора.
Не знаю.
В задаче #160 рассматривается вариант, когда после специализации замыкания у него не остаётся контекста (https://github.com/bmstu-iu9/refal-5-lambda/issues/160#issuecomment-623350268). Для такого замыкания можно или строить вырожденный объект замыкания, или заменять на указатель на функцию.
В случае замены на указатель на функцию в https://github.com/bmstu-iu9/refal-5-lambda/issues/160#issuecomment-623350268 рассматривается слабое изменение поведения программы — функция Type
возвращает не тип замыкания, а тип указателя на функцию. Но есть и более сложная проблема в контексте настоящей задачи.
Рассмотрим программу:
$ENTRY Test {
e.X
= { e.Y = e.X e.Y } : s.Func
= <Eq s.Func <S s.Func e.X>>;
}
$SPEC S s.FUNC e.ARG;
S {
s.F e.X = s.F <D e.X>;
}
$DRIVE D;
D {
'abc' = ;
'a' e._ = ;
e._ = ;
}
Eq {
s.Eq s.Eq = True;
s._ s._ = False;
}
После специализации, прогонки и специализации замыкания мы получим следующую функцию S
:
S@1 {
'abc' = &Test=1\1@1; /* № 1 */
'a' e.X = {{ &Test=1\1@2 (e.X) }}; /* № 2 */
e.X = {{ &Test=1\1 (e.X) }}; /* № 3 */
}
* Неспециализированная функция
Test=1\1 {
(e.X) e.Y = e.X e.Y;
}
Test=1\1@1 {
e.Y = 'abc' e.Y;
}
Test=1\1@2 {
(e.X) e.Y = 'a' e.X e.Y;
}
В неоптимизированной программе замыкание формируется один раз — в функции Test
, затем оно «просеивается» через S
, откуда возвращается неизменным. Функция Eq
вернёт True
.
В оптимизированной программе присваивание встроится — конструкторы одного и того же замыкания будут находиться в аргументах Eq
и S
. Функция S
проспециализируется, конструктор упадёт в неё. А там размножится в результате прогонки.
В трёх ветвях функции S@1
мы будем строить для замыкания:
Из подходов, предложенных в комментарии выше, только первый (запретить дублирование замыканий) решает проблему. Если строить вырожденное замыкание вместо указателя, то проблему решает только хранение «ключей» в замыканиях.
Когда я писал про подход с хранением «ключей», я неявно подразумевал, что ключами будут указатели на динамические ящики, а операция создания замыкания просто присваивает ящику новое значение (отбрасывая предыдущее). Но в случае специализации замыкания этот подход выглядит некрасивым: можно перезаписать более специализированное значение менее специализированным. Так что лучше оставить прямую генерацию ключей.
Запрет на дублирование замыканий слишком жёсткий — не даёт специализировать такие функции, как Map
. Можно придумать и более сложный анализ, который позволял бы и Map
оптимизировать, и сохранять семантику. Для этого нужно при специализации функции передавать в неё и переменные из контекста, и указатель на замыкание, создаваемый вовне. При этом внутри функции нужно помнить, что эта s-переменная хранит уже известное значение. Для таких «заимствованных» замыканий специализация замыканий работать уже не должна. Но всё это дико сложно, особенно в рамках текущего формализма, поэтому рассматривать дальше этот вариант не будем.
Возможно, наилучший вариант — отказаться от инварианта, что все копии всегда равны. Для пассивных данных — равны. Если вступают в действия указатели на функции и агрессивные оптимизации — уже не определено. При этом при прогонке нужно подразумевать, что все копии всегда равны, семантика, вроде как, должна оставаться корректной.
Проблема в том, что замыкания — ссылочные данные, а древесные оптимизации умеют работать только со значениями.
В последнее время я склоняюсь к мысли реализовать замыкания как квадратные скобки, как в Модульном Рефале:
Преимущества и недостатки:
✔ Основная проблема была в том, что замыкание — ссылочный тип, создаваемый встроенной конструкцией языка. Из-за чего ссылочные типы входили в ядро языка и поэтому должны были учитываться при преобразованиях программы.
Теперь ссылочные типы (в актуальной реализации их нет, кроме замыканий) могут создаваться только внешними функциями на Си, а проблем для значений, созданных внешними функциями, быть не должно (#230). Результаты вызовов двух функций всегда считаются независимыми, даже если у них аргументы одинаковы.
Поэтому, даже если в языке появятся динамические ящики, с ними проблем не будет.
<(&F e.1) e.2>
на уровне среды выполнения.✔ В актуальной версии специализатора есть проблема, которая решается костылём.
А именно, значениями статических параметров могут быть замыкания, сами статические параметры могут входить в динамические части образцов, а также в образцы условий. В результате может получиться синтаксически некорректная функция, у которой в образцах окажутся замыкания.
Костыль заключается в том, что построенная функция проверяется на осмысленность — если в образцах оказались замыкания, то данный вызов не специализируется.
При замене замыкания на АТД-терм проблема растворяется сама собой: в образцах допустимы и естественны квадратные скобки.
Также потребуется определить семантику для вызова АТД-термов. Тут возможны два варианта:
<[Func e.X] e.Y> → <Func e.X e.Y>
<[Func e.X] e.Y> → <Func [Func e.X] e.Y>
Можно добавить и третий вариант:
<[Func e.X] e.Y> → <Func (e.X) e.Y>
Его преимущества: по сравнению с 1 не сливается содержимое терма с аргументом, по сравнению с 2 не требуется дополнительных аллокаций памяти.
Если «наивно» заменить (ClosureBrackets …)
на (ADT-Brackets …)
, то специализация замыканий как таковая станет невозможной. Ведь квадратные скобки могут использоваться не только для представления замыканий компилятором, их может использовать программист для создания собственных типов данных. В качестве тега инкапсулирующих скобок может быть использована не только пустая функция, но и вообще любая локальная (а почему бы и нет?). Такие скобочные термы, очевидно, специализировать нельзя, т.к. пользователь будет немало удивлён.
Можно придумать и более изощрённые варианты, например, угадывать специализируемые квадратные скобки по суффиксу. Имена без суффиксов или с суффиксом ~N
оптимизировать нельзя, остальные — можно.
Я пока затрудняюсь исчерпывающе описать ситуации, когда специализировать замыкания безопасно. Однако, можно выделить ситуацию, когда специализировать их опасно. Рассмотрим сначала примеры:
Следующий пример, описания очевидных функций опущены:
F { e.X = { = e.X } }
$SPEC F s.STAT;
G {
e.X = <Eq <F 'abc'> <F <gen_e__ 'abc'>>>
}
В случае актуальной реализации замыканий (символы-ссылки) замыкания будут неравны в неоптимизированной программе, в оптимизированной программе они будут иметь разные типы, т.к. первое замыкание будет без контекста.
В предполагаемой реализации замыканий как квадратных скобок, защищённых невидимым именем безымянной функции, в неоптимизированной программе оба замыкания будут равны (замыкают равный контекст, построены из одного блока кода), в оптимизированной… А как они будут в оптимизированной? Наивный вариант, как мы поняли, не работает. Более изощрённый вариант проспециализирует и они станут не равны, в том числе по типам.
Из приведённых примеров можно сделать такой вывод: нельзя специализировать замыкания в экземплярах специализированных функций. Пример из https://github.com/bmstu-iu9/refal-5-lambda/issues/276#issuecomment-623372133 демонстрирует специализацию «чужого» замыкания, который попал внутрь экземпляра из сигнатуры. Второй пример демонстрирует специализацию «своего» замыкания (замыкания функции F\1
).
$ENTRY F {
(e.X) (e.Y) = <{ s.F = <Eq <s.F e.X> <s.F e.Y>> } { e.A = { = e.A } }> <D e.X>
}
$DRIVE D, Eq;
D {
'abc' = 1;
'a' e._ = 2;
e._ = 3;
}
Eq {
t.X t.X = True;
t._ t._ = False;
}
$EXTERN F;
$ENTRY Go {
/* пусто */
= <Prout
<F ('abc') ('abc')>
<F ('abcd') ('abcd')>
<F ('bcd') ('bcd')>
>
}
Актуальная реализация в неоптимизированном варианте напечатает False 1 False 2 False 3
, в оптимизированном — False 1 False 2 True 3
. Предполагаемая реализация с абстрактными скобками без оптимизации выдаст True 1 True 2 True 3
с оптимизацией — False 1 False 2 True 3
. Предполагаемая реализация с предполагаемой оптимизацией #322 может выдать и True 1 True 2 True 3
. Но в более хитрых примерах и #322 не спасёт.
Но и в этом примере можно сказать, что выполняется специализация «чужого» замыкания — функции F\2\1
внутри функции F
. «Родной» функцией для F\2\1
является функция F\2
.
Со «своими» замыканиями всё проще:
$ENTRY F {
e.X = { = e.X } <D e.X>
}
$DRIVE D;
D {
'abc' = 1;
'a' e._ = 2;
e._ = 3;
}
Неоптимизированная версия будет возвращать результаты в соответствии с ожиданиями: для замыканий с разным контекстом будут строиться неравные термы. Актуальная реализация всегда будет порождать неравные термы, предполагаемая для термов с равным контекстом будет возвращать равные термы.
В оптимизированной версии инварианты сохранятся. Актуальная реализация для <F 'abc'>
будет возвращать один и тот же указатель на глобальную функцию, но это никто не запрещает. У предполагаемой реализации ссылочная прозрачность (в определении Андрея Климова) тоже сохранится.
Вызов <Type <F 'abc'>>
в актуальной реализации меняет свой результат при оптимизации. В предполагаемой будет менять, если построение [F\1@1]
будет сокращаться до &F\1@1
.
Так что следует запрещать специализацию тех замыканий, стирание одного суффикса имени которого даёт имя, отличное от имени функции, внутри которой находится замыкание. Отдельный вопрос с замыканиями в условиях — тут мне пока не очевидно.
Однако обратное неверно. Можно привести пример, когда специализация «своего» замыкания опасна. (Это не комментарий сам себе противоречит, это моя мысль развивается в процессе написания.) Это слегка модифицированный первый пример:
$ENTRY F {
e.X = <G { = e.X }> <D e.X>
}
$DRIVE G, D, Eq;
G {
s.F = <Eq <S s.F> s.F>
}
D {
'abc' = 1;
'a' e._ = 2;
e._ = 3;
}
Eq {
t.X t.X = True;
t._ t._ = False;
}
$SPEC S t.STAT;
S { t.F = t.F }
Вызов G
раскроется, получится <Eq <S {{ &F\1 e.X }}> {{ &F\1 e.X }}> <D e.X>
. Потом прогонится вызов D
, сужения для e.X
горизонтально распространятся. Прогонять нечего. Выполнится проход специализации. Сначала он построит экземпляры для S
, а потом для замыкания F\1
.
Если в S
проспециализировать экземпляр тоже, то для АТД-замыканий семантика программы сохранится. Но мы выше решили, что специализировать замыкания в экземплярах нельзя, но тогда семантика будет нарушаться.
Специализация замыканий — очень непредсказуемая оптимизация в языке, где экземпляры вложенных функций могут сравниваться на равенство. Она коварна и в актуальной реализации, и в предполагаемой, где экземпляры функций будут типами-значениями. Возможно, наилучший вариант — запретить эту оптимизацию.
Возможно, единственный вариант сохранить и семантику, и эту оптимизацию — хранить в замыканиях уникальные идентификаторы (см. $genkey
в https://github.com/bmstu-iu9/refal-5-lambda/issues/276#issuecomment-612976116) и при сравнении на равенство использовать их, а не ссылку на объект (как сейчас) или содержимое (как предполагается).
Но такой вариант выглядит скорее костылём, положенным между двумя стульями. Да, сидеть на нём можно, фактически занимать два стула тоже можно, но это всё равно костыль. Реализация замыканий как квадратных скобок более естественно решает проблемы семантики, за исключением специализации замыканий.
Рассмотрим случай, когда замыкания всё-таки станут типами-значениями. Семантика их вызова будет выглядеть так:
<[F e.X] e.Y> → <F e.X e.Y>
(поведение по аналогии с Apply
)
Тогда квадратные скобки можно назвать каррирующими скобками. Ведь они действительно строят объект, который можно вызвать и связывают префикс входного формата — несколько входных параметров.
В этом дискурсе замыкание в квадратных скобках становится простым синтаксическим сахаром — компилятор неявно генерирует функцию и связывает часть её формата со значениями переменных контекста.
Однако, инкапсуляция при помощи квадратных скобок становится наоборот хаком, а синтаксические ограничения на квадратные скобки повисают в воздухе. Во-первых, неочевидным становится потребность разбирать каррированное значение в образце. Во-вторых, почему, если в переменной s.f
или t.f
находится функция, нельзя писать в результатном выражении [s.f e.a]
или [t.f e.a]
? В третьих, почему нельзя писать образцы вида [s.f e.a]
, почему известное имя функции обязательно?
Можно сказать, что одна конструкция языка программирования переиспользуется в двух контекстах. Это скобки инкапсуляции, но наделены свойствами, позволяющими через них реализовать замыкание. Это скобки каррирования, но их можно использовать и для сокрытия данных. Такая, своего рода, ложка-вилка, которая и как ложка не очень удобна, и как вилка. Вещь, хорошая для походных условий, но не для языка программирования.
Сегодня в ИПМ имени М.В. Келдыша онлайн проходил семинар — я читал доклад о проблеме и предложил два варианта решения:
В ходе семинара мы так и не выбрали способ решения проблемы, сошлись на том, что ни один из них не доминирует над другим. Но зато с интересом обсудили саму проблему.
Введение. Гарантии для равенства замыканий
Сравнение на равенство является фундаментальной операцией Рефала. Синтаксис образцов допускает кратные вхождения переменных, значения которых должны быть равны. Следовательно, в ядре языка должна быть определена операция сравнения на равенство для любых типов данных.
Одна из аксиом языка (если можно так выразиться) — значения, полученные путём копирования (кратные вхождения в результатное выражение) должны быть равны в смысле сопоставления с кратными переменными образца.
Например:
Функция
F
всегда должна возвращатьTrue
.Ещё есть неявная аксиома — повторные s-переменные должны сопоставляться за константное время. В учебнике Турчина говорится, что открытые переменные, повторные t- и e-переменные скрывают за собой рекурсию, следовательно, виды сопоставлений рекурсию не скрывают, т.е. сопоставляются за константное время.
Рефал-5λ поддерживает вложенные функции в результатных выражениях. Во время выполнения для вложенных функций порождаются замыкания — значения типа «символ» (сопоставимые с s-переменными), которые можно вызывать как функции. Замыкания могут захватывать контекст.
Возникает вопрос: как сравниваются на равенство два замыкания? Для Простого Рефала в
manul.pdf
были определены следующие ограничения:Примечание. Следует уточнить понятие текстуально разные. Текстуально разными считаются не только блоки в разных позициях в одном файле, но и один и тот же блок в заголовочном файле, включённый в разные единицы трансляции.
То, что не описано выше, намеренно не определено. В частности, если замыкание с одним и тем же контекстом из одного и того же текстуально блока создаётся в разные моменты времени, то равенство не определено. Простейший пример:
Функция
F1
создаёт замыкание с пустым контекстом, а для таких случаев вместо объекта замыкания создаётся просто указатель на неявную глобальную функцию (&F1\1
). Поэтому первый вызов распечатаетTrue
. Так было сделано с самой первой реализации вложенных функций вSimple Refal.004
.Второй вызов по умолчанию распечатает
False
, поскольку два вызова<F2 e.X>
создадут два объекта замыкания, которые сравнятся по ссылке. Но если функцииF2
иEq
прогнать (или даже встроить), то мы получим<Prout True>
. Реализованный алгоритм обобщённого сопоставления с образцом допускает повторные переменные любого типа, причём сопоставление считается успешным, если значения этих переменных текстуально совпадают (если не совпадают и тип переменной не s — результат не определён). Поэтому здесь сопоставление будет успешным.Далее, мы рассмотрим три примера. Один с мнимым нарушением семантики, два других — с реальным.
Воспроизведение ошибки
Пример 1. Мнимое нарушение семантики при прогонке
Скачать: closures-neq-drive.ref.
Это случай неопределённого поведения, когда сравниваются два замыкания из одного и того же блока, с равными контекстами, но созданные в разное время.
Пример 2. Реальное нарушение семантики при прогонке
Скачать: closures-eq-drive.ref.
Здесь встраивается вызов
Dup
, в результате чегоEq
вызывается с аргументомВо время выполнения создаются два одинаковых объекта замыкания. Но, поскольку замыкания сравниваются по ссылке, они оказываются не равны.
Пример 3. Реальное нарушение семантики при специализации
Скачать: closures-neq-spec.ref.txt.
Причина похожа на предыдущую — строятся два одинаковых экземпляра замыкания вместо копирования одного. Только теперь путём размножения значения статической переменной. Специализированная функция
S@1
выглядит так:Решение
А вот однозначного решения тут пока не видно — везде есть компромиссы.
Приемлемыми мне видятся только последние три варианта. А может даже, последние два.
Буду думать летом после завершения #260, #256, #252, #253.