А що з LISP, якщо що, полегшує впровадження макросистем?


21

Я вивчаю схему з SICP, і складаю враження, що велика частина того, що робить Схему, а тим більше, 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вираз і виклик, використовуючи зібрані нами зв'язки та тіло. Зворотний отвір називається "квазіцитатом", що означає трактувати все, що знаходиться всередині квазіцитату, як буквальні дані, за винятком бітів після коми ("котирування").
    • В (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-caseі ER, і я не бачив жодної, яка забезпечує і те й інше. Вони еквівалентні за потужністю.)
Chris Jester-Young

Чому макроси повинні змінювати AST? Чому вони не можуть працювати на вищому рівні?
Елліот Гороховський

1
Тож чому LISP краще? Що робить LISP особливим? Якщо ви можете реалізувати макроси в js, то, звичайно, можна реалізувати їх і на будь-якій іншій мові.
Елліот Гороховський

3
@ RenéG, як я вже сказав у своєму першому коментарі, великою перевагою є те, що ви все ще пишете тією ж мовою.
Флоріан Маргаїн

23

Думка, яка суперечить: гомоконічність Ліспа - це набагато не корисна річ, ніж більшість шанувальників Lisp вважають, що ви вірите.

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

Парсинг - це процес зчитування коду, його інтерпретація згідно з набором формальних правил та перетворення його в структуру дерева, загалом відому як AST (абстрактне синтаксичне дерево). Незважаючи на все різноманіття мов програмування, це одна надзвичайна спільність: по суті, кожна мова програмування загального призначення аналізує структуру дерева.

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

Що слід пам’ятати про Лісп, це те, що він дуже, дуже старий. Серед мов програмування лише FORTRAN старший від Lisp. Знову назад, розбір (повільна частина складання) вважався темним таємничим мистецтвом. Оригінальні статті Джона Маккарті з теорії Ліспа (тоді, коли це була лише ідея, яку він ніколи не думав, реально реалізувати як справжню мову комп'ютерного програмування) описують дещо складніший і виразніший синтаксис, ніж сучасні "S-вирази скрізь для всього" "позначення. Це сталося пізніше, коли люди намагалися реально це здійснити. Оскільки синтаксичний розбір ще тоді не був добре зрозумілий, вони в основному карали його і скидали синтаксис у гомоніконічну структуру дерева, щоб зробити роботу аналізатора абсолютно тривіальною. Кінцевим результатом є те, що ви (розробник) повинні виконати багато аналізу " працюйте над цим, записуючи формальний AST безпосередньо у свій код. Гомоїконічність не "робить макроси набагато простішими" настільки ж, що робить писати все інше набагато складніше!

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

Теорія компілятора пройшла довгий шлях з 1960-х років, коли був винайдений Лісп, і хоча речі, які він здійснив, були вражаючими за день, зараз вони виглядають досить примітивно. Для прикладу сучасної системи метапрограмування подивіться на (на жаль недооцінену) мову Бу. Boo є статично типовим, об'єктно-орієнтованим та відкритим кодом, тому кожен вузол AST має тип із чітко визначеною структурою, до якого розробник макрокоманду може прочитати код. Мова має відносно простий синтаксис, натхненний Python, з різними ключовими словами, які надають внутрішньосемантичного значення деревним структурам, побудованим з них, а його метапрограмування має інтуїтивний квазіцитативний синтаксис для спрощення створення нових вузлів AST.

Ось макрос, який я створив вчора, коли зрозумів, що я застосовую один і той же візерунок до купи різних місць у коді графічного інтерфейсу, де я б закликав BeginUpdate()елемент управління інтерфейсом, здійснив оновлення в 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вірно для a , компілятор впіймає це відразу і повідомляє про помилку замість вас, не знаючи, поки щось не підірветься на вас у час виконання.

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


4
Радість читати, дякую! Чи розтягуються неліпські макроси на зміну синтаксису? Оскільки одна з сильних сторін Ліспа - це синтаксис все той же, тому легко додати функцію, умовне висловлювання, що б там не було, тому що вони однакові. Хоча для мов, які не є Lisp, одна річ відрізняється від іншої - if...не схожа на виклик функції, наприклад. Я не знаю Boo, але уявіть, що Boo не мав відповідності шаблону, чи можете ви представити його зі своїм синтаксисом як макросом? Моя думка - будь-який новий макрос в Ліспі відчуває себе на 100% природним, іншими мовами вони працюють, але ви можете бачити шви.
greenoldman

4
Історія, як я завжди її читала, трохи інша. Планувався альтернативний синтаксис s-виразу, але робота над ним затягувалася, оскільки програмісти вже почали використовувати s-вирази і вважали їх зручними. Тож робота над новим синтаксисом була врешті забута. Чи можете ви навести джерело, яке вказує на недоліки теорії компілятора як причину використання s-виразів? Також родина Лісп протягом багатьох десятиліть продовжувала розвиватися (схема, звичайний лісп, кложур), і більшість діалектів вирішили дотримуватися s-виразів.
Джорджіо

5
"простіший та інтуїтивніший": вибачте, але я не бачу як. "Оновлення.Аргументи [0]" не має сенсу, я б скоріше мав іменний аргумент і дозволю компілятору перевірити себе, якщо кількість аргументів збігається: pastebin.com/YtUf1FpG
coredump

8
"З точки зору продуктивності, це набагато простіше завдання; багато компіляторів мови високого рівня витрачають 75% або більше свого часу на аналіз." Я би сподівався шукати та застосовувати оптимізацію, щоб зайняти більшу частину часу (але я ніколи не писав справжнього компілятора). Я чогось тут пропускаю?
Doval

5
На жаль, ваш приклад цього не показує. Це примітивно реалізувати в будь-якому Lisp за допомогою макросів. Насправді це один із найпримітивніших макросів для впровадження. Це змушує мене підозрювати, що ви мало знаєте про макроси в Ліспі. "Синтаксис Ліспа застряг у 1960-х": насправді макросистеми в Ліспі досягли значного прогресу з 1960 року (У 1960 році Лісп навіть не макросів!).
Rainer Joswig

3

Я вивчаю схему з SICP, і складаю враження, що велика частина того, що робить Схему, а тим більше, LISP особливою є макросистема.

Як так? Весь код у SICP написаний у макро-вільному стилі. У SICP немає макросів. Лише у виносці на сторінці 373 макроси коли-небудь згадуються.

Але, оскільки макроси розгортаються під час компіляції

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

Перевіримо, що за допомогою SBCL є загальною реалізацією 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.

Чому в Ліспі макроси прості? Що ж, вони насправді непрості. Тільки в Lisps, і їх багато, у яких вбудована підтримка макросів. Оскільки багато Lisps оснащені широкою технікою для макросів, схоже, це легко. Але макро механізми можуть бути надзвичайно складними.


Я багато читав про схему в Інтернеті, а також читав SICP. Також, чи не складаються вирази Lisp перед їх інтерпретацією? Вони принаймні повинні бути розібрані. Тому я здогадуюсь, що "час компіляції" має бути "час розбору".
Елліот Гороховський

@ Я вважаю, Рейнджер Райнер, якщо ви evalабо кодуєте loadбудь-якою мовою Lisp, макроси в них також будуть оброблені. Тоді якщо ви використовуєте систему препроцесора, як запропоновано у вашому запитанні, evalтощо, не матиме користі від розширення макросів.
Кріс Єстер-Янг

@ RenéG Крім того, readв Lisp називається "розбір" . Ця відмінність є важливою, оскільки 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-виразів або гомоіконічності можуть зіткнутися з проблемою впровадження синтаксичних макросів, хоча це все одно можна зробити. Проект Kepler намагається ввести їх, наприклад, у Scala.

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

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.