А как насчет LISP, если что-нибудь облегчает внедрение макросистем?

21

Я изучаю Scheme из SICP, и у меня складывается впечатление, что большая часть того, что делает Scheme и, тем более, LISP особенным, - это макросистема. Но, поскольку макросы расширяются во время компиляции, почему люди не делают эквивалентные системы макросов для C / Python / Java / что угодно? Например, можно привязать pythonкоманду к expand-macros | pythonили как угодно. Код будет по-прежнему переносимым для людей, которые не используют систему макросов, можно просто развернуть макросы перед публикацией кода. Но я не знаю ничего подобного, кроме шаблонов в C ++ / Haskell, которые, как я понимаю, на самом деле не совпадают. А как насчет LISP, если что-нибудь облегчает внедрение макросистем?

Эллиот Гороховский
источник
3
«Код по-прежнему будет переносим для людей, которые не используют макросистему, можно просто развернуть макросы перед публикацией кода». - просто чтобы предупредить вас, это не очень хорошо работает. Эти другие люди смогут запускать код, но на практике макро-расширенный код часто трудно понять и, как правило, трудно изменить. По сути, он «плохо написан» в том смысле, что автор не адаптировал расширенный код для человеческих глаз, они разработал реальный источник. Попробуйте сообщить программисту на Java, что вы запускаете свой Java-код через препроцессор C, и посмотрите, какой цвет у них получается ;-)
Стив Джессоп,
1
Макросы должны выполняться, хотя в этот момент вы уже пишете переводчик для языка.
Мердад

Ответы:

29

Многие Lispers скажут вам, что особенность Lisp - это гомоконичность , что означает, что синтаксис кода представлен теми же структурами данных, что и другие данные. Например, вот простая функция (с использованием синтаксиса схемы) для вычисления гипотенузы прямоугольного треугольника с заданными длинами сторон:

(define (hypot x y)
  (sqrt (+ (square x) (square y))))

Теперь гомоконичность говорит о том, что приведенный выше код фактически представлен в виде структуры данных (в частности, списков списков) в коде на Лиспе. Итак, рассмотрим следующие списки и посмотрим, как они «склеиваются» вместе:

  1. (define #2# #3#)
  2. (hypot x y)
  3. (sqrt #4#)
  4. (+ #5# #6#)
  5. (square x)
  6. (square y)

Макросы позволяют вам обращаться с исходным кодом как со списком вещей. Каждый из этих 6 «подсписков» содержат либо указатели на другие списки, или символы (в данном примере: define, hypot, x, y, sqrt, +, square).


Итак, как мы можем использовать гомойконичность, чтобы «отделить» синтаксис и создать макросы? Вот простой пример. Давайте переопределим letмакрос, который мы назовем my-let. Как напоминание,

(my-let ((foo 1)
         (bar 2))
  (+ foo bar))

должен расширяться в

((lambda (foo bar)
   (+ foo bar))
 1 2)

Вот реализация, использующая схему «явное переименование» макросов :

(define-syntax my-let
  (er-macro-transformer
    (lambda (form rename compare)
      (define bindings (cadr form))
      (define body (cddr form))
      `((,(rename 'lambda) ,(map car bindings)
          ,@body)
        ,@(map cadr bindings)))))

formПараметр связан с фактической формой, так что для нашего примера, это будет (my-let ((foo 1) (bar 2)) (+ foo bar)). Итак, давайте проработаем пример:

  1. Сначала мы извлекаем привязки из формы. cadrхватает ((foo 1) (bar 2))часть формы.
  2. Затем мы извлекаем тело из формы. cddrхватает ((+ foo bar))часть формы. (Обратите внимание, что это предназначено для захвата всех подчиненных форм после привязки; поэтому, если форма была

    (my-let ((foo 1)
             (bar 2))
      (debug foo)
      (debug bar)
      (+ foo bar))
    

    тогда тело будет ((debug foo) (debug bar) (+ foo bar)).)

  3. Теперь мы фактически строим результирующее lambdaвыражение и вызываем, используя привязки и тело, которые мы собрали. Обратный удар называется «квазицитатой», что означает трактовать все, что находится внутри квазицитаты, как буквальные данные, кроме битов после запятых («unquote»).
    • В (rename 'lambda)средства использовать lambdaсвязывание в силу , когда этот макрос определен , а не то , что lambdaсвязывание может быть вокруг , когда этот макрос используется . (Это называется гигиеной .)
    • (map car bindings)возвращает (foo bar): первый элемент в каждой из привязок.
    • (map cadr bindings)возвращает (1 2): второй элемент в каждой из привязок.
    • ,@ делает «соединение», которое используется для выражений, которые возвращают список: это заставляет элементы списка вставляться в результат, а не сам список.
  4. Собрав все это вместе, мы получаем, в результате, список (($lambda (foo bar) (+ foo bar)) 1 2), где $lambdaздесь упоминается переименованный lambda.

Прямо, верно? ;-) (Если это не так просто для вас, просто представьте, насколько сложно было бы внедрить систему макросов для других языков.)


Таким образом, у вас могут быть макросистемы для других языков, если у вас есть возможность «разобрать» исходный код без лишних сложностей. Есть несколько попыток в этом. Например, sweet.js делает это для JavaScript.

† Для опытных Schemers, читающих это, я намеренно решил использовать явное переименование макросов в качестве среднего компромисса между defmacros, используемыми другими диалектами Lisp, и syntax-rules(что будет стандартным способом реализации такого макроса в Scheme). Я не хочу писать на других диалектах Лисп, но я не хочу отталкивать не-шемеров, которые не привыкли syntax-rules.

Для справки вот my-letмакрос, который использует syntax-rules:

(define-syntax my-let
  (syntax-rules ()
    ((my-let ((id val) ...)
       body ...)
     ((lambda (id ...)
        body ...)
      val ...))))

Соответствующая syntax-caseверсия выглядит очень похоже:

(define-syntax my-let
  (lambda (stx)
    (syntax-case stx ()
      ((_ ((id val) ...)
         body ...)
       #'((lambda (id ...)
            body ...)
          val ...)))))

Разница между ними заключается в том, что ко всему внутри syntax-rulesприменяется неявное #'применение, поэтому вы можете использовать только пары шаблон / шаблон syntax-rules, поэтому оно полностью декларативно. Напротив syntax-case, бит после шаблона является действительным кодом, который, в конце концов, должен возвращать объект синтаксиса ( #'(...)), но может содержать и другой код.

Крис Шут-Янг
источник
2
Преимущество, которое вы не упомянули: да, есть попытки на других языках, таких как sweet.js для JS. Однако в lisps написание макроса выполняется на том же языке, что и написание функции.
Флориан Маргейн
Правильно, вы можете писать процедурные (а не декларативные) макросы на языках Lisp, что позволяет вам делать действительно сложные вещи. Кстати, это то, что мне нравится в макросистемах Scheme: есть несколько вариантов на выбор. Я использую простые макросы, syntax-rulesкоторые являются чисто декларативными. Я могу использовать для сложных макросов, syntax-caseкоторый является частично декларативным и частично процедурным. И затем есть явное переименование, которое является чисто процедурным. (Большинство реализаций Схемы будут предоставлять либо syntax-caseER, либо ER. Я не видел ни одного, который обеспечивал бы оба. Они эквивалентны по силе.)
Крис Джестер-Янг
Почему макросы должны модифицировать AST? Почему они не могут работать на более высоком уровне?
Эллиот Гороховский
1
Так почему же LISP лучше? Что делает LISP особенным? Если кто-то может реализовать макросы в js, то, конечно, можно реализовать их и на любом другом языке.
Эллиот Гороховский
3
@ RenéG, как я уже говорил в своем первом комментарии, большое преимущество в том, что вы все еще пишете на одном языке.
Флориан Маргейн
23

Особое мнение: гомоконичность Лиспа гораздо менее полезная вещь, чем большинство фанатов Лиспа заставили бы вас поверить.

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

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

Генерация кода принимает AST анализатора в качестве входных данных и преобразует его в исполняемый код посредством применения формальных правил. С точки зрения производительности это гораздо более простая задача; многие высокоуровневые языковые компиляторы проводят 75% или больше своего времени на разборе.

Что нужно помнить о Лиспе, так это то, что он очень, очень старый, Среди языков программирования только FORTRAN старше, чем Lisp. Еще в те дни разбор (медленная часть компиляции) считался мрачным и загадочным искусством. В оригинальных работах Джона Маккарти по теории Лиспа (тогда, когда это была просто идея, которую он никогда не думал, что она может быть реализована как настоящий язык программирования), описывается несколько более сложный и выразительный синтаксис, чем современные «S-выражения везде и везде» обозначение Это произошло позже, когда люди пытались это осуществить. Поскольку синтаксический анализ в то время не был хорошо понят, они в основном наказывали его и сводили синтаксис к гомоиконичной древовидной структуре, чтобы сделать работу синтаксического анализатора совершенно тривиальной. Конечным результатом является то, что вы (разработчик) должны сделать много парсера Работайте для этого, записывая формальный AST прямо в ваш код. Homoiconicity не «делает макросы намного проще», а делает написание всего остального намного сложнее!

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

Теория компиляторов прошла долгий путь с 1960-х годов, когда был изобретен Lisp, и хотя достигнутые результаты были впечатляющими для своего времени, сейчас они выглядят довольно примитивно. Для примера современной системы метапрограммирования, взгляните на (к сожалению недооцененный) язык Boo. Boo является статически типизированным, объектно-ориентированным и открытым исходным кодом, поэтому каждый узел AST имеет тип с четко определенной структурой, в которую разработчик макросов может читать код. Язык имеет относительно простой синтаксис, вдохновленный Python, с различными ключевыми словами, которые придают внутренним семантическим значениям встроенные древовидные структуры, а его метапрограммирование имеет интуитивно понятный синтаксис квазиквот для упрощения создания новых узлов AST.

Вот макрос, который я создал вчера, когда понял, что применяю один и тот же шаблон к множеству разных мест в коде GUI, где я вызываю BeginUpdate()элемент управления UI, выполняю обновление в tryблоке и затем вызываю EndUpdate():

macro UIUpdate(value as Expression):
    return [|
        $value.BeginUpdate()
        try:
            $(UIUpdate.Body)
        ensure:
            $value.EndUpdate()
    |]

На macroсамом деле команда - это сам макрос , который принимает тело макроса в качестве входных данных и генерирует класс для обработки макроса. Имя макроса используется в качестве переменной, которая MacroStatementзаменяет узел AST, представляющий вызов макроса. [| ... |] является блоком квазицитатов, генерирующим AST, который соответствует коду внутри, а внутри блока квазиквот символ $ обеспечивает функцию «unquote», подставляя в узел, как указано.

С этим можно написать:

UIUpdate myComboBox:
   LoadDataInto(myComboBox)
   myComboBox.SelectedIndex = 0

и расширить его до:

myComboBox.BeginUpdate()
try:
   LoadDataInto(myComboBox)
   myComboBox.SelectedIndex = 0
ensure:
   myComboBox.EndUpdate()

Выражая макрос таким образом , является более простым и понятным , чем это было бы в макросе Lisp, потому что разработчик знает структуру MacroStatementи знает , как Argumentsи Bodyсвойства работы, и что присущая знания могут быть использованы для выражения понятий , связанных с очень интуитивным путь. Это также безопаснее, потому что компилятор знает структуру MacroStatement, и если вы попытаетесь закодировать что-то, что недопустимо для MacroStatement, компилятор сразу же поймает это и сообщит об ошибке, а вы не узнаете, пока что-то не взорвется во время выполнения.

Привить макросы на Haskell, Python, Java, Scala и т. Д. Несложно, поскольку эти языки не гомоичны; это сложно, потому что языки не предназначены для них, и это работает лучше всего, когда иерархия AST вашего языка разработана с нуля для изучения и манипулирования макросистемой. Когда вы работаете с языком, который изначально разрабатывался с учетом метапрограммирования, с макросами работать намного проще и проще!

Мейсон Уилер
источник
4
Рад читать, спасибо! Макросы, не относящиеся к Lisp, растягиваются до изменения синтаксиса? Поскольку одной из сильных сторон Lisp является то, что синтаксис одинаков, поэтому легко добавить функцию, условное выражение, что угодно, потому что они все одинаковы. В то время как с не-Лисп языками одно отличается от другого - if...не похоже, например, на вызов функции. Я не знаю Boo, но представьте, что у Boo не было сопоставления с шаблоном, не могли бы вы представить его с собственным синтаксисом в качестве макроса? Я хочу сказать, что любой новый макрос в Лиспе кажется на 100% естественным, на других языках он работает, но вы можете увидеть стежки.
Гринольдман
4
История, которую я всегда читал, немного другая. Был запланирован альтернативный синтаксис s-выражению, но работа над ним была отложена, потому что программисты уже начали использовать s-выражения и сочли их удобными. Так что работа над новым синтаксисом была в итоге забыта. Не могли бы вы привести источник, который указывает на недостатки теории компилятора в качестве причины для использования s-выражений? Кроме того, семья Lisp продолжала развиваться в течение многих десятилетий (Scheme, Common Lisp, Clojure), и большинство диалектов решили придерживаться s-выражений.
Джорджио
5
«проще и понятнее»: извините, но я не понимаю, как это сделать. «Updating.Arguments [0]» не имеет смысла, я бы предпочел иметь именованный аргумент и позволить компилятору проверить себя, если количество аргументов совпадает: pastebin.com/YtUf1FpG
coredump
8
«С точки зрения производительности это гораздо более простая задача; многие компиляторы языка высокого уровня тратят 75% или больше своего времени на синтаксический анализ». Я бы ожидал, что поиск и применение оптимизаций займет большую часть времени (но я никогда не писал настоящий компилятор). Я что-то здесь упускаю?
Доваль
5
К сожалению, ваш пример не показывает это. Примитивно для реализации в любом Лиспе с макросами. На самом деле это один из самых примитивных макросов для реализации. Это заставляет меня подозревать, что вы не знаете много о макросах в Лиспе. «Синтаксис Лиспа застрял в 1960-х годах»: на самом деле макросистемы в Лиспе достигли большого прогресса с 1960 года (в 1960 году Лисп даже не имел макросов!).
Райнер Йосвиг
3

Я изучаю Scheme из SICP, и у меня складывается впечатление, что большая часть того, что делает Scheme и, тем более, LISP особенным, - это макросистема.

Как же так? Весь код в SICP написан в стиле без макросов. В SICP нет макросов. Только в сноске на странице 373 упоминаются макросы.

Но, поскольку макросы раскрываются во время компиляции

Они не обязательно. Lisp предоставляет макросы как в интерпретаторах, так и в компиляторах. Таким образом, может не быть времени компиляции. Если у вас есть интерпретатор Lisp, макросы раскрываются во время выполнения. Поскольку многие системы Lisp имеют встроенный компилятор, можно генерировать код и компилировать его во время выполнения.

Давайте проверим это, используя SBCL, реализацию Common Lisp.

Давайте переключим SBCL на интерпретатора:

* (setf sb-ext:*evaluator-mode* :interpret)

:INTERPRET

Теперь мы определим макрос. Макрос печатает что-то, когда он вызывается для расширенного кода. Сгенерированный код не печатается.

* (defmacro my-and (a b)
    (print "macro my-and used")
    `(if ,a
         (if ,b t nil)
         nil))

Теперь давайте используем макрос:

MY-AND
* (defun foo (a b) (my-and a b))

FOO

Видеть. В приведенном выше случае Лисп ничего не делает. Макрос не раскрывается во время определения.

* (foo t nil)

"macro my-and used"
NIL

Но во время выполнения, когда код используется, макрос раскрывается.

* (foo t t)

"macro my-and used"
T

Опять же, во время выполнения, когда используется код, макрос раскрывается.

Обратите внимание, что SBCL будет расширяться только один раз при использовании компилятора. Но различные реализации Lisp также предоставляют интерпретаторы, такие как SBCL.

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

Райнер Йосвиг
источник
Я много читал о Scheme в Интернете, а также читал SICP. Кроме того, выражения Lisp не скомпилированы прежде, чем они будут интерпретированы? Их как минимум надо разобрать. Поэтому я предполагаю, что «время компиляции» должно быть «время разбора».
Эллиот Гороховский
@ RenéG Райнер считает, что если вы evalили loadпрограммируете на любом языке Lisp, макросы в них тоже будут обрабатываться. Принимая во внимание, что если вы используете систему препроцессора, как предложено в вашем вопросе, evalи тому подобное, вы не выиграете от расширения макроса.
Крис Джестер-Янг
@ RenéG Также readв Lisp вызывается parse . Это различие важно, потому что evalработает с фактическими структурами данных списка (как упомянуто в моем ответе), а не с текстовой формой. Таким образом, вы можете использовать (eval '(+ 1 1))и вернуть 2, но если вы (eval "(+ 1 1)"), вы вернетесь "(+ 1 1)"(строка). Вы используете readдля перехода от "(+ 1 1)"(строка из 7 символов) к (+ 1 1)(список с одним символом и двумя фиксированными числами).
Крис Шутер-Янг
@ RenéG При таком понимании макросы не работают readодновременно. Они работают во время компиляции в том смысле, что если у вас есть подобный код (and (test1) (test2)), он будет расширен (if (test1) (test2) #f)(в схеме) только один раз, когда код загружен, а не каждый раз, когда код выполняется, но если вы делаете что-то вроде (eval '(and (test1) (test2))), который скомпилирует (и расширит макрос) это выражение соответствующим образом во время выполнения.
Крис Шутер-Янг
@ RenéG Homoiconicity - это то, что позволяет языкам Lisp оценивать структуры списков вместо текстовой формы и преобразовывать эти структуры списков (с помощью макросов) перед выполнением. Большинство языков evalработают только над текстовыми строками, и их возможности для модификации синтаксиса намного более тусклы и / или громоздки.
Крис Шестер-Янг
1

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

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

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

Мировой инженер
источник