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

Как вы компилируете макросы в компиляторе Lisp?

В интерпретаторе Lisp может легко быть ветвь в eval, которая может развернуть макрос и в процессе его расширения вызвать функции для создания расширенного выражения. Я сделал это, прежде чем использовать макросы низкого уровня, и это легко получить.

Но в компиляторе нет никаких функций для вызова расширенного кода: проблему можно увидеть довольно просто в следующем примере:

(defmacro cube (n)
    (let ((x (gensym)))
      `(let ((,x ,n))
          (* ,x ,x ,x))))

Когда макрос расшифровывается интерпретатором, он вызывает gensym и делает то, что вы ожидаете. При расширении компилятором вы создадите код для let, который связывает x с (gensym), но символ gensymmed необходим только для того, чтобы компилятор делал правильные действия. А поскольку gensym фактически не вызывается перед компиляцией макроса, это не очень полезно.

Это становится еще более странным для меня, когда макрос создает список, который будет использоваться в качестве расширения с помощью map или filter.

Итак, как это работает? Конечно, скомпилированный код не скомпилирован в (eval *macro-code*), потому что это было бы ужасно неэффективно. Есть ли хорошо написанный компилятор Lisp, где это ясно?

4b9b3361

Ответ 1

Как это работает, очень разные в диалектах Lisp. Для Common Lisp он стандартизован в стандарте ANSI Common Lisp, а различные реализации Common Lisp отличаются в основном тем, используют ли они компилятор, интерпретатор или и то, и другое.

Следующее предполагает Common Lisp.

EVAL не является интерпретатором. EVAL может быть реализован с помощью компилятора. Некоторые общие реализации Lisp даже не имеют интерпретатора. Затем EVAL - это вызов компилятору для компиляции кода, а затем вызывает скомпилированный код. В этих реализациях используется инкрементный компилятор, который может компилировать также простые выражения, такие как 2, (+ 2 3), (gensym) и т.д.

Макроопределение выполняется с помощью функций MACROEXPAND и MACROEXPAND-1.

Макрос в Common Lisp - это функция, которая ожидает некоторые формы и возвращает другую форму. DEFMACRO регистрирует эту функцию как макрос.

Ваш макрос

(defmacro cube (n)
  (let ((x (gensym)))
    `(let ((,x ,n))
        (* ,x ,x ,x))))

- это не что иное, как функция Lisp, которая зарегистрирована как макрос.

Эффект подобен этому:

(defun cube-internal (form environment)
  (destructuring-bind (name n) form   ; the name would be CUBE
    (let ((x (gensym)))
      `(let ((,x ,n))
         (* ,x ,x ,x)))))

(setf (macro-function 'my-cube) #'cube-internal)

В реальной реализации CL DEFMACRO расширяется по-разному и не использует имя типа CUBE-INTERNAL. Но концептуально он определяет макрофункцию и регистрирует ее.

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

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

Когда компилятор теперь видит какой-то код, который использует макрос (cube 10), тогда компилятор просто вызывает макрофункцию, которая хранится в текущей среде под именем CUBE, вызывает эту макрофункцию, которая 10 как аргумент, а затем скомпилирует сгенерированную форму. Как упоминалось выше, это делается не напрямую, а через функции MACROEXPAND.

Вот определение макроса:

CL-USER 5 > (defmacro cube (n)
              (let ((x (gensym)))
                `(let ((,x ,n))
                   (* ,x ,x ,x))))
CUBE

Скомпилируем макрос:

CL-USER 6 > (compile 'cube)
CUBE
NIL
NIL

MACRO-FUNCTION возвращает функцию макроса. Мы можем назвать это как любую другую функцию с FUNCALL. Он ожидает два аргумента: целую форму типа (cube 10) и среду (здесь NIL).

CL-USER 7 > (funcall (macro-function 'cube) '(cube 10) nil)
(LET ((#:G2251 10)) (* #:G2251 #:G2251 #:G2251))

Также можно взять функцию (которая принимает два аргумента: форма и среда) и сохраняет ее с помощью SETF в качестве макрофункции.

Резюме

Когда выполняется компилятор Common Lisp, он просто знает макрофункции и вызывает их при необходимости для расширения кода с помощью встроенного расширителя макросов. Макро-функции - это просто код Lisp. Когда компилятор Lisp видит определение макроса, он компилирует макрофункцию, сохраняет ее в текущей среде и использует ее для последующего использования макроса.

Примечание. Это делает необходимым в Common Lisp, что макрос определен до того, как он может быть использован компилятором.

Ответ 2

Есть много подходов к этому. Одна из крайностей - это что-то, называемое "FEXPER", которое представляет собой макроподобные вещи, которые по существу перераспределяются при каждой оценке. В какой-то момент они вызвали много шума, но почти полностью исчезли. (Есть несколько человек, которые все еще делают подобные вещи, но, вероятно, самый популярный пример - newlisp.)

Итак, FEXPERs были сброшены в пользу макросов, которые более "хорошо себя ведут". Вы в основном делаете макроразложение один раз и компилируете полученный код. Как обычно, здесь есть несколько стратегий, которые могут привести к разным результатам. Например, "развернуть один раз" не указывается при расширении. Это может произойти, как только код будет прочитан, или (обычно), когда он будет скомпилирован, или даже просто при первом запуске.

Другой вопрос здесь - и то, что по существу, где вы стоите, - это то, в какой среде вы оцениваете макрокод. В большинстве Lisps все происходит в той же счастливой глобальной среде. Макрос может свободно обращаться к функциям, что может привести к некоторым тонким проблемам. Одним из результатов этого является то, что многие коммерческие реализации Common Lisp дают вам среду разработки, в которой вы выполняете большую часть своей работы и компилируете вещи - это делает ту же среду доступной на обоих уровнях. (На самом деле, поскольку макросы могут использовать макросы, здесь имеется произвольное количество уровней.) Для развертывания приложения вы получаете ограниченную среду, в которой нет, например, компилятора (т.е. Функции compile), поскольку если вы развертываете код, который использует это, ваш код по существу является компилятором CL. Поэтому идея состоит в том, что вы компилируете код в своей полной реализации и расширяете все макросы, а это значит, что скомпилированный код не имеет дополнительных применений макросов.

Но, конечно, это может привести к тем тонким проблемам, о которых я говорил. Например, некоторые побочные эффекты могут привести к беспорядку при загрузке, где вам нужно загрузить код в определенном порядке. Хуже того, вы можете попасть в ловушку, где код работает для вас одним способом, а другой - при компиляции - поскольку скомпилированный код уже имел все макросы (и сделанные им вызовы), был расширен заранее. Для них есть некоторые хакерские решения, такие как eval-when, который определяет определенные условия для оценки некоторого кода. Есть также несколько систем пакетов для CL, где вы указываете такие вещи, как порядок загрузки (например, asdf). Тем не менее, там нет реального надежного решения, и вы все равно можете попасть в эти ловушки (см., Например, этот расширенный опрос).

Есть альтернативы, конечно. В частности, Racket использует свою модульную систему. Модуль может быть "экземплярным" несколько раз, и состояние уникально для каждого экземпляра. Теперь, когда какой-либо модуль используется как в макросах, так и во время выполнения, два экземпляра этих модулей отличаются друг от друга, а это значит, что компиляция всегда надежна, и нет ни одной из вышеупомянутых головных болей. В мире Scheme это известно как "отдельные фазы", ​​где каждая фаза (время выполнения, время компиляции и более высокие уровни с макросами) использует отдельные экземпляры модулей. Для хорошего ознакомления с этим и подробным объяснением прочитайте Matthew Flatt Композитные и Компилируемые макросы. Вы также можете просто посмотреть Документы для Racket, например Скомпилировать и Раздел "Время выполнения" .

Ответ 3

В макросах нет ничего особенного.

На высоком уровне они просто функции. Функции, возвращающие формы S-Exprs для Lisp. "Время выполнения" для макроса доступно в функции macroexpand, которая, как вы уже знаете, расширяет макросы.

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

Как правило, в макросах есть много цитат и сплайсинга и других операций с списками, которые облегчают их запись, подобно системе шаблонов. Но эти конструкции не требуются. Вы можете вернуть S-Expr, но хотите. Итак, посмотрев на это, вы можете видеть, что они по сути своей являются просто простыми функциями, оцененными во время компиляции.

Ответ 4

Вы нашли одно из основных отличий между Lisp и другими языками.

В Lisp выполнение динамически созданного кода имеет важное значение и, например, необходимо для расширения макросов.

При написании компилятора Lisp to C я обнаружил эту очевидную вещь сам и пришел к выводу, что если вы хотите написать компилятор Lisp, то есть только два решения:

  • Вы пишете BOTH компилятор и интерпретатор, чтобы вы могли вызвать интерпретатор для расширения макросов во время компиляции.

  • Вы должны иметь возможность динамически компилировать код и вызывать его (или использовать худшие "трюки", такие как компиляция динамически загружаемого модуля, а затем его загрузка).

Если вы работаете над компилятором для C, то можно использовать Fabrice Bellard TCC library, которая позволяет напрямую компилировать код C в память буфер.

Я пишу компилятор Lisp в Javascript, и в этом случае, конечно, нет проблем, потому что "оборудование" может справиться с этим хорошо, и вы можете попросить Javascript оценить, например, строку "function(...){...}", а затем вызвать результирующий объект. Использование Javascript также решает, что является IMO одной из самых сложных проблем для ядра Lisp, которая является правильной реализацией лексических замыканий.

Действительно, в моем javascript компиляторе eval больше или меньше

(defun eval (x)
    (funcall (js-eval (js-compile x))))

где js-compile является основным интерфейсом компилятора, и с учетом формы Lisp будет возвращена строка, содержащая код javascript, который при оценке (с eval javascript, который я экспортировал на уровень Lisp, как js-eval) выполняет код. Интересно также, что eval никогда не используется (с единственным незаменимым исключением из удобного макроса, в котором я должен выполнить пользовательский код во время расширения макроса).

Важно помнить, что в то время как Common Lisp имеет своего рода разделение между "временем чтения", "временем компиляции" и "временем выполнения", это разделение логичнее, чем физическое, поскольку текущий код всегда Lisp. Компиляция в Lisp - это просто вызов функции. Даже фаза "синтаксического анализа" - это просто обычная функция Lisp, выполняющая... it Lisp до конца:-)

Ссылки на мой компилятор Lisp → Js