Регистрация | Войти
Lisp — программируемый язык программирования
Предыдущая Оглавление Следующая

8. Макросы: Создание собственных макросов

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

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

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

История Мака: обычная такая история

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

В отчаянии большой босс нанял младшего (junior) программиста, Мака, чьей работой стал поиск комментариев, написание требуемого кода и вставка его в программу на место комментариев. Мак никогда не запускал программы, ведь они не были завершены и поэтому он попросту не мог этого сделать. Но даже если бы они были завершены, Мак не знал, какие данные необходимо подать на их вход. Поэтому он просто писал свой код, основываясь на содержимом комментариев, и посылал его назад создавшему комментарий программисту.

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

Однако программисты обнаружили, что чем точнее они пишут свои комментарии, тем больше вероятность того, что Мак вернет правильный код. Как-то раз один из программистов, встретив затруднение с описанием в словах нужного кода, включил в один из комментариев программу на Lisp, которая генерировала нужный код. Такой комментарий был удобен Маку: он просто запустил программу и послал результат компилятору.

Следующее новшество появилось, когда программист вставил в самый верх одной из своих программ комментарий, содержащий определение функции и пояснение, гласившее: "Мак, не пиши здесь никакого кода, но сохрани эту функцию на будущее; я собираюсь использовать ее в некоторых своих комментариях." Другие комментарии в этой программе гласили следующее: "Мак, замени этот комментарий на результат выполнения той функции с символами x и y как аргументами."

Этот метод распространился так быстро, что в течение нескольких дней большинство программ стало содержать дюжины комментариев с описанием функций, которые использовались только кодом в других комментариях. Чтобы облегчить Маку различение комментариев, содержащих только определения и не требующих немедленного ответа, программисты отмечали их стандартным предисловием: "Definition for Mac, Read Only" (Определение для Мака, только для чтения). Это (как мы помним, программисты были очень ленивы) быстро сократилось до "DEF. MAC. R/O", а потом до "DEFMACRO".

Очень скоро в комментариях для Мака вообще не осталось английского. Целыми днями он читал и отвечал на электронные письма от компилятора, содержащие DEFMACRO комментарии и вызывал функции, описанные в DEFMACRO. Так как Lisp программы в комментариях осуществляли всю реальную работу, то работа с электронными письмами перестала быть проблемой. У Мака внезапно стало много свободного времени, и он сидел в своем кабинете и грезил о белых песчаных пляжах, чистой голубой океанской воде и напитках с маленькими бумажными зонтиками.

Несколько месяцев спустя программисты осознали что Мака уже довольно давно никто не видел. Придя в его кабинет, они обнаружили, что все покрыто тонким слоем пыли, стол усыпан брошюрами о различных тропических местах, а компьютер выключен. Но компилятор продолжал работать! Как ему это удавалось? Выяснилось, что Мак сделал заключительное изменение в компиляторе: вместо отправки электронного письма с комментарием Маку компилятор теперь сохранял функции, описанные с помощью DEFMACRO комментариев, и запускал при вызове их из других комментариев. Программисты решили, что нет оснований говорить большим боссам, что Мак больше не приходит на работу. Так происходит и по сей день: Мак получает зарплату и время от времени шлет программистам открытки то из одной тропической страны, то из другой.

Время раскрытия макросов против времени выполнения

Ключом к пониманию макросов является полное понимание разницы между кодом, генерирующим код (макросами), и кодом, который в конечном счете выполняет программу (все остальное). Когда вы пишете макросы, вы пишете программы, которые будут использоваться компилятором для генерации кода, который затем будет скомпилирован. Только после того, как все макросы будут полностью раскрыты, а полученный код скомпилирован, программа сможет быть запущена. Время, когда выполняются макросы, называется временем раскрытия макросов; оно отлично от времени выполнения, когда выполняется обычный код, включая код, сгенерированный макросами.

Очень важно полностью понимать это различие, так как код, работающий во время раскрытия макросов, запускается в окружении, сильно отличающемся от окружения кода, работающего во время выполнения. А именно, во время раскрытия макросов не существует способа получить доступ к данным, которые будут существовать во время выполнения. Подобно Маку, который не мог запускать программы, над которыми он работал, так как не знал, что является корректным входом для них, код, работающий во время раскрытия макросов, может работать только с данными, являющимися неотъемлемой частью исходного кода. Для примера предположим, что следующий исходный код появляется где-то в программе:

(defun foo (x)
  (when (> x 10) (print 'big))
)

Обычно вы бы думали о x как о переменной, которая будет содержать аргумент, переданный при вызове foo. Но во время раскрытия макросов (например когда компилятор выполняет макрос WHEN) единственными доступными данными является исходный код. Так как программа пока не выполняется, нет вызова foo и, следовательно, нет значения, ассоциированного с x. Вместо этого, значения, которые компилятор передает в WHEN, являются списками Lisp, представляющими исходный код, а именно (> x 10) и (print 'big). Предположим, что WHEN определен, как вы видели в предыдущей главе, подобным образом:

(defmacro when (condition &rest body)
  `(if ,condition (progn ,@body))
)

При компиляции кода foo, макрос WHEN будет запущен с этими двумя формами в качестве аргументов. Параметр condition будет связан с формой (> x 10), а форма (print 'big) будет собрана (will be collected) в список (и будет его единственным элементом), который станет значением параметра &rest body. Выражение квазицитирования затем сгенерирует следующий код:

(if (> x 10) (progn (print 'big)))

подставляя значение condition, а также вклеивая значение body в PROGN.

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

DEFMACRO

Как вы видели в главе 3, макросы на самом деле определяются с помощью форм DEFMACRO, что означает, разумеется, "DEFine MACRO", а не "Definition for Mac". Базовый шаблон DEFMACRO очень похож на шаблон DEFUN.

(defmacro name (parameter*)
"Optional documentation string."
body-form*)

Подобно функциям, макрос состоит из имени, списка параметров, необязательной строки документации и тела, состоящего из выражений Lisp 1). Однако, как я только что говорил, работой макроса не является осуществление какого-то действия напрямую, — его работой является генерирование кода, который затем сделает то, что вам нужно.

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

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

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

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

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

Подводя итог можно сказать, что шаги по написанию макросов следующие:

1. Написание примера вызова макроса, а затем кода, в который он должен быть раскрыт (или в обратном порядке).

2. Написание кода, генерирующего написанный вручную код раскрытия по аргументам в примере вызова.

3. Проверка того, что предоставляемая макросом абстракция не "протекает".

Пример макроса: do-primes

Для того, чтобы увидеть, как этот трёхшаговый процесс осуществляется, вы напишете макрос do-primes, который предоставляет конструкцию итерирования, подобную DOTIMES и DOLIST, за исключением того, что вместо итерирования по целым числам или элементам списка итерирование будет производиться по последовательным простым числам. Этот пример не является примером чрезвычайно полезного макроса, он — всего лишь средство демонстрации вышеописанного процесса.

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

(defun primep (number)
  (when (> number 1)
    (loop for fac from 2 to (isqrt number) never (zerop (mod number fac)))
)
)


(defun next-prime (number)
  (loop for n from number when (primep n) return n)
)

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

(do-primes (p 0 19)
  (format t "~d " p)
)

для выражения цикла, который выполняет тело для каждого простого числа, большего либо равного 0 и меньшего либо равного 19, используя переменную p для хранения очередного простого числа. Имеет смысл смоделировать этот макрос с помощью стандартных макросов DOLIST и DOTIMES; макрос, следующий образцу существующих макросов, легче понять и использовать, нежели макросы, которые вводят неоправданно новый синтаксис.

Без использования макроса do-primes вы можете написать такой цикл путем использования DO (и двух вспомогательных функций, определенных ранее) следующим образом:

(do ((p (next-prime 0) (next-prime (1+ p))))
    ((> p 19))
  (format t "~d " p)
)

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

Макропараметры

Так как аргументы, передаваемые в макрос, являются объектами Lisp, представляющими исходный код вызова макроса, первым шагом любого макроса является извлечение тех частей этих объектов, которые нужны для вычисления раскрытия. Для макросов, которые просто подставляют свои аргументы напрямую в шаблон, этот шаг тривиален: подходит простое определение правильных параметров для захвата нужных аргументов.

Но, кажется, такого подхода недостаточно для do-primes. Первый аргумент вызова do-primes является списком, содержащим имя переменной цикла, p; нижнюю границу, 0; верхнюю границу, 19. Но, если вы посмотрите на раскрытие, список, как целое, не встречается в нем: эти три элемента разделены и вставлены в различные места.

Вы можете определить do-primes с двумя параметрами, первый для захвата этого списка и параметр &rest для захвата форм тела цикла, а затем разобрать первый список вручную подобным образом:

(defmacro do-primes (var-and-range &rest body)
  (let ((var (first var-and-range))
        (start (second var-and-range))
        (end (third var-and-range))
)

    `(do ((,var (next-prime ,start) (next-prime (1+ ,var))))
         ((> ,var ,end))
       ,@body
)
)
)

Очень скоро я объясню как тело макроса генерирует правильное раскрытие; сейчас же вам следует отметить, что переменные var, start и end, каждая содержит значение, извлеченное из var-and-range, и эти значения затем подставляются в выражение квазицитирования, генерирующее раскрытие do-primes.

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

Внутри списка деструктурируемых параметров простое имя параметра может быть заменено вложенным списком параметров. Параметры в таком списке будут получать свои значения из элементов выражения, которое было бы связано с параметром, замененным этим списком. Например, вы можете заменить var-and-range списком (var start end) и три элемента списка будут автоматически деструктурированы в эти три параметра.

Другой особенностью списка параметров макросов является то, что вы можете использовать &body как синоним &rest. Семантически &body и &rest эквиваленты, но множество сред разработки будут использовать факт наличия параметра &body для изменения того, как они будут выравнивать код использования макроса, поэтому обычно параметры &body используются для захвата списка форм, которые составляют тело макроса.

Таким образом, вы можете улучшить определение макроса do-primes и дать подсказку (как людям, читающим ваш код, так и вашим инструментам разработки) об его предназначении:

(defmacro do-primes ((var start end) &body body)
  `(do ((,var (next-prime ,start) (next-prime (1+ ,var))))
       ((> ,var ,end))
     ,@body
)
)

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

(do-primes var-and-range &rest body)

С новым же описанием она сможет указать вам, что вызов должен выглядеть следующим образом:

(do-primes (var start end) &body body)

Списки деструктурируемых параметров могут содержать параметры &optional, &key и &rest, а также вложенные деструктурируемые списки. Однако все эти возможности не нужны вам для написания do-primes.

Генерация раскрытия

Так как do-primes является довольно простым макросом, после деструктурирования аргументов, всё что вам остаётся сделать — это подставить их в шаблон для получения раскрытия.

Для простых макросов, наподобие do-primes, лучшим вариантом является использование специального синтаксиса квазицитирования. Коротко говоря, выражения квазицитирования подобны выражениям цитирования, за исключением того, что вы можете "раскавычить" определенные подвыражения, предваряя их запятой, за которой возможно следует знак "at" (@). Без этого знака "at" запятая вызывает включение как есть значения следующего за ней подвыражения. Со знаком "at" значение, которое должно быть списком, "вклеивается" в окружающий список.

Другой пригодный способ думать о синтаксисе квазицитирования как об очень кратком способе написания кода, генерирующего списки. Такое представление о нем имеет преимущество, так как является очень близким к тому, что на самом деле происходит "под капотом": когда процедура чтения считывает выражение квазицитирования, она преобразует его в код, который генерирует соответствующую списковую структуру. Например, `(,a b) вероятно будет прочитано как (list a 'b). Стандарт языка не указывает, какой в точности код процедура чтения должна выдавать, пока она генерирует правильные списковые структуры.

Таблица 8-1 показывает некоторые примеры выражений квазицитирования вместе с эквивалентным создающим списки кодом, а также результаты, которые вы получите при вычислении как выражений квазицитирования, так и эквивалентного кода2):

Таблица 8-1. Примеры квазицитирования
Синтаксис квазицитирования Эквивалентный создающий списки код Результат
`(a (+ 1 2) c) (list 'a '(+ 1 2) 'c) (a (+ 1 2) c)
`(a ,(+ 1 2) c) (list 'a (+ 1 2) 'c) (a 3 c)
`(a (list 1 2) c) (list 'a '(list 1 2) 'c) (a (list 1 2) c)
`(a ,(list 1 2) c) (list 'a (list 1 2) 'c) (a (1 2) c)
`(a ,@(list 1 2) c) (append (list 'a) (list 1 2) (list 'c)) (a 1 2 c)

Важно заметить, что нотация квазицитирования является просто удобством. Но это большое удобство. Для оценки того, насколько оно велико, сравните версию do-primes с квазицитированием со следующей версией, которая явно использует создающий списки код:

(defmacro do-primes-a ((var start end) &body body)
  (append '(do)
          (list (list (list var
                            (list 'next-prime start)
                            (list 'next-prime (list '1+ var))
)
)
)

          (list (list (list '> var end)))
          body
)
)

Как вы очень скоро увидите, текущая реализация do-primes не обрабатывает корректно некоторые граничные случаи. Но первое, что вы должны проверить, — это то, что она по крайней мере работает для исходного примера. Вы можете сделать это двумя способами. Во-первых, вы можете косвенно протестировать свою реализацию просто воспользовавшись ею (подразумевая, что если итоговое поведение корректно, то и раскрытие также корректно). Например, вы можете напечатать исходный пример использования do-primes в REPL и увидеть, что он и в самом деле напечатает правильную последовательность простых чисел.

CL-USER> (do-primes (p 0 19) (format t "~d " p))
2 3 5 7 11 13 17 19
NIL

Или же вы можете проверить макрос напрямую, посмотрев на раскрытие определенного вызова. Функция MACROEXPAND-1 получает любое выражение Lisp в качестве аргумента и возвращает результат осуществления одного шага раскрытия макроса3). Так как MACROEXPAND-1 является функцией, для дословной передачи ей формы макроса вы должны зацитировать эту форму. Теперь вы можете воспользоваться MACROEXPAND-1 для просмотра раскрытия предыдущего вызова4).

CL-USER> (macroexpand-1 '(do-primes (p 0 19) (format t "~d " p)))
(DO ((P (NEXT-PRIME 0) (NEXT-PRIME (1+ P))))
((> P 19))
(FORMAT T "~d " P))
T

Также, для большего удобства, в SLIME вы можете проверить раскрытие макроса поместив курсор на открывающую скобку формы макроса в вашем исходном коде и набрав C-c RET для вызова функции Emacs slime-macroexpand-1, которая передаст форму макроса в MACROEXPAND-1 и напечатает результат во временном буфере.

Теперь вы можете видеть, что результат раскрытия макроса совпадает с исходным (написанным вручную) раскрытием, и поэтому кажется, что do-primes работает.

Устранение протечек

В своем эссе "Закон дырявых абстракций" Джоэл Спольски придумал термин "дырявой абстракции" для описания такой абстракции, через которую "протекают" детали, абстрагирование от которых предполагается. Так как написание макроса — это способ создания абстракции, вам следует убедиться, что ваш макрос излишне не "протекает"5)

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

Текущее определение страдает от одной из трех возможных "протечек" макросов, а именно, оно вычисляет подформу end слишком много раз. Предположим, что вы вызвали do-primes с таким выражением, как (random 100), на месте параметра end вместо использования числового литерала, такого, как 19.

(do-primes (p 0 (random 100))
  (format t "~d " p)
)

Предполагаемым поведением здесь является итерирование по простым числам от нуля до какого-то случайного простого числа, возвращенного (random 100). Однако, это не то, что делает текущая реализация, как это показывает MACROEXPAND-1.

CL-USER> (macroexpand-1 '(do-primes (p 0 (random 100)) (format t "~d " p)))
(DO ((P (NEXT-PRIME 0) (NEXT-PRIME (1+ P))))
    ((> P (RANDOM 100)))
  (FORMAT T "~d " P)
)

T

При запуске кода раскрытия RANDOM будет вызываться при каждой проверке условия окончания цикла. Таким образом, вместо итерирования, пока p не станет больше, чем изначально выбранное случайное число, этот цикл будет осуществляться пока не случится, что выбранное в очередной раз случайное число окажется меньше текущего значения p. Хотя общее число итераций по прежнему случайно, оно будет подчиняться вероятностному распределению, отличному от равномерного распределения результатов RANDOM.

Это является "протечкой" абстракции, так как для корректного использования макроса, его пользователь должен быть осведомлен о том, что форма end будет вычислять более одного раза. Одним из способов устранения этой "протечки" является простое специфицирование ее как поведения do-primes. Но это не достаточно удовлетворительно: при реализации макросов вам следует пытаться соблюдать Правило Наименьшего Удивления. К тому же программисты обычно ожидают, что формы, которые они передают макросам, будут вычисляться не большее число раз, чем это действительно необходимо6). Более того, так как do-primes построена на основе модели стандартных макросов DOTIMES и DOLIST, которые вычисляют однократно все свои формы, кроме форм тела, то большинство программистов будут ожидать от do-primes подобного поведения.

Вы можете исправить множественное вычисление достаточно легко: вам просто следует сгенерировать код, который вычисляет end однократно и сохраняет результат в переменную для дальнейшего использования. Вспомним, что в цикле DO переменные с формой инициализации и без формы вычисления последующих значений не изменяются от итерации к итерации. Поэтому вы можете исправить проблему множественных вычислений следующим определением:

(defmacro do-primes ((var start end) &body body)
  `(do ((ending-value ,end)
        (,var (next-prime ,start) (next-prime (1+ ,var)))
)

       ((> ,var ending-value))
     ,@body
)
)

К сожалению данное исправление вводит две новые "протечки" в предоставляемую нашим макросом абстракцию.

Одна из этих "протечек" подобна проблеме множественных вычислений, которую мы только что исправили. Так как формы инициализации переменных цикла DO вычисляются в том порядке, в каком переменные определены, то когда раскрытие макроса вычисляется, выражение, переданное как end, будет вычислено перед выражением, переданным как start, то есть в обратном порядке от того, как они идут в вызове макроса. Эта "протечка" не вызывает никаких проблем пока start и end являются литералами вроде 0 и 19. Но, если они являются формами, которые могут иметь побочные эффекты, вычисление их в неправильном порядке снова нарушает Правило Наименьшего Удивления.

Эта "протечка" устраняется тривиально путем изменения порядка определения двух переменных.

(defmacro do-primes ((var start end) &body body)
  `(do ((,var (next-prime ,start) (next-prime (1+ ,var)))
        (ending-value ,end)
)

       ((> ,var ending-value))
     ,@body
)
)

Последняя "протечка", которую нам нужно устранить, была создана использованием имени переменной ending-value. Проблема заключается в том, что имя, которое должно быть полностью внутренней деталью реализации макроса, может вступить во взаимодействие с кодом, переданным макросу, или с контекстом, в котором макрос вызывается. Следующий, кажущийся вполне допустимым, вызов do-primes не работает корректно из-за данной "протечки":

(do-primes (ending-value 0 10)
  (print ending-value)
)

То же касается и следующего вызова:

(let ((ending-value 0))
  (do-primes (p 0 10)
    (incf ending-value p)
)

  ending-value
)

И снова MACROEXPAND-1 может вам показать, в чем проблема. Первый вызов расширяется в следующее:

(do ((ending-value (next-prime 0) (next-prime (1+ ending-value)))
     (ending-value 10)
)

    ((> ending-value ending-value))
  (print ending-value)
)

Некоторые реализации Lisp могут отвергуть такой код из-за того, что ending-value используется дважды в качестве имен переменных одного и того-же цикла DO. Если же этого не произойдет, то код зациклится, так как ending-value никогда не станет больше себя самого.

Второй проблемный вызов расширяется следующим образом:

(let ((ending-value 0))
  (do ((p (next-prime 0) (next-prime (1+ p)))
       (ending-value 10)
)

      ((> p ending-value))
    (incf ending-value p)
)

  ending-value
)

В этом случае сгенерированный код полностью допустим, но его поведение совсем не то, что нужно вам. Так как привязка ending-value, установленная с помощью LET снаружи цикла перекрывается переменной с таким же именем внутри DO, то форма (incf ending-value p) увеличивает переменную цикла ending-value вместо внешней переменной с таким же именем, создавая другой вечный цикл7).

Очевидно, что то, что нам нужно для устранения этой "протечки" — это символ, который никогда не будет использоваться снаружи кода, сгенерированного макросом. Вы можете попытаться использовать действительно маловероятный символ, но это все равно не даст вам никаких гарантий. Вы можете также защитить себя в некоторой степени путем использования пакетов, описанных в главе 21. Но существует лучшее решение.

Функция GENSYM возвращает уникальный символ при каждом своем вызове. Такой символ никогда до этого не был прочитан процедурой чтения Lisp и, так как он не интернирован (isn't interned) ни в один пакет, никогда не будет прочитан ею. Поэтому, вместо использования литеральных имен наподобие ending-value, вы можете генерировать новый символ при каждом раскрытии do-primes.

(defmacro do-primes ((var start end) &body body)
  (let ((ending-value-name (gensym)))
    `(do ((,var (next-prime ,start) (next-prime (1+ ,var)))
          (,ending-value-name ,end)
)

         ((> ,var ,ending-value-name))
       ,@body
)
)
)

Обратите внимание, что код, вызывающий GENSYM не является частью раскрытия; он запускается как часть процедуры раскрытия макроса и поэтому создает новый символ при каждом раскрытии макроса. Это может казаться несколько странным сначала: ending-value-name является переменной, чье значение является именем другой переменной. Но на самом деле тут нет никаких отличий от параметра var, чье значение также является именем переменной. Единственная разница состоит в том, что значение var было создано процедурой чтения, когда форма макроса была прочитана, а значение ending-value-name было сгенерированно программно при запуске кода макроса.

С таким определением две ранее проблемные формы расширяются в код, который работает так, как вам нужно. Первая форма:

(do-primes (ending-value 0 10)
  (print ending-value)
)

расширяется в следующее:

(do ((ending-value (next-prime 0) (next-prime (1+ ending-value)))
     (#:g2141 10)
)

    ((> ending-value #:g2141))
  (print ending-value)
)

Теперь переменная, используемая для хранения конечного значения является сгенерированным функцией gensym символом, #:g2141. Имя идентификатора, G2141, было сгенерировано с помощью GENSYM, но важно не это; важно то, что идентификатор хранит значение объекта. Сгенерированные таким образом символы печатаются в обычном синтаксисе для неинтернированных символов: с начальным #:.

Вторая ранее проблемная форма:

(let ((ending-value 0))
  (do-primes (p 0 10)
    (incf ending-value p)
)

  ending-value
)

после замены do-primes его раскрытием будет выглядеть подобным образом:

(let ((ending-value 0))
  (do ((p (next-prime 0) (next-prime (1+ p)))
       (#:g2140 10)
)

      ((> p #:g2140))
    (incf ending-value p)
)

  ending-value
)

И снова, тут нет никакой "протечки", так как переменная ending-value, связанная окружающей цикл do-primes формой LET, больше не перекрывается никакими переменными, вводимыми в коде раскрытия.

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

Этим исправлением мы устранили все "протечки" в реализации do-primes. После получения некоторого опыта в написании макросов, вы научитесь писать макросы с заранее устраненными "протечками" такого рода. На самом деле это довольно просто, если вы будете следовать следующим правилам:

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

Макросы, создающие макросы

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

На самом деле, вы уже видели один такой образец: многие макросы, как и последняя версия do-primes, начинаются с LET, который вводит несколько переменных, содержащих сгенерированные символы для использовании в раскрытии макроса. Так как это общий образец, почему бы нам не абстрагировать его с помощью его собственного макроса?

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

Предположим, вы хотите иметь возможность написать подобное:

(defmacro do-primes ((var start end) &body body)
  (with-gensyms (ending-value-name)
    `(do ((,var (next-prime ,start) (next-prime (1+ ,var)))
          (,ending-value-name ,end)
)

         ((> ,var ,ending-value-name))
       ,@body
)
)
)

и получить do-primes, эквивалентный его предыдущей версии. Другими словами, with-gensyms должен раскрываться в LET, которая связывает каждую перечисленную переменную, ending-value-name в данном случае, со сгенерированным символом. Достаточно просто написать это с помощью простого шаблона-квазитирования.

(defmacro with-gensyms ((&rest names) &body body)
  `(let ,(loop for n in names collect `(,n (gensym)))
     ,@body
)
)

Обратите внимание, как мы можем использовать запятую для подстановки значения выражения LOOP. Этот цикл генерирует список связывающих форм, каждая из которых состоит из списка, содержащего одно из переданных with-gensyms имен, а также литеральный код (gensym). Вы можете проверить, какой код сгенерирует выражение LOOP в REPL, заменив names списком символов.

CL-USER> (loop for n in '(a b c) collect `(,n (gensym)))
((A (GENSYM)) (B (GENSYM)) (C (GENSYM)))

После списка связывающих форм в качестве тела LET вклеивается аргумент body with-gensyms. Таким образом, из кода, который вы оборачиваете в with-gensyms, вы можете ссылаться на любое из имен переменных из списка переменных, переданного with-gensyms.

Если вы воспользуетесь macro-expand для формы with-gensyms в новом определении do-primes, то вы получите подобное:

(let ((ending-value-name (gensym)))
  `(do ((,var (next-prime ,start) (next-prime (1+ ,var)))
        (,ending-value-name ,end)
)

       ((> ,var ,ending-value-name))
     ,@body
)
)

Выглядит неплохо. Хотя этот макрос довольно прост, очень важно ясно понимать то, когда различные макросы раскрываются: когда вы компилируете DEFMACRO do-primes, форма with-gensyms раскрывается в код, который вы только что видели. Таким образом, скомпилированная версия do-primes в точности такая же, как если бы вы написали внешний LET вручную. Когда вы компилируете функцию, которая использует do-primes, то для генерации расширения do-primes запускается код, сгенерированный with-gensyms, но сам with-gensyms при компиляции формы do-primes не нужен, так как он уже был раскрыт при компиляции do-primes.

Другой классический макрос, создающий макросы: ONCE-ONLY

Другим классическим макросом, создающим макросы, является once-only, который используется для генерации кода, вычисляющего определенные аргументы макроса только единожды и в определенном порядке. Используя once-only вы можете написать do-primes почти таким же простым способом, как исходную "протекающую" версию, следующим образом:

(defmacro do-primes ((var start end) &body body)
  (once-only (start end)
    `(do ((,var (next-prime ,start) (next-prime (1+ ,var))))
         ((> ,var ,end))
       ,@body
)
)
)

Однако, реализация once-only несколько запутанна для обычного пошагового объяснения, так как зависит от множества уровней квазицитирования и "раскавычивания". Если вы действительно хотите попрактиковаться в понимании макросов, вы можете попытаться разобраться, как он работает. Макрос выглядит следующим образом:

(defmacro once-only ((&rest names) &body body)
  (let ((gensyms (loop for n in names collect (gensym))))
    `(let (,@(loop for g in gensyms collect `(,g (gensym))))
      `(let (,,@(loop for g in gensyms for n in names collect ``(,,g ,,n)))
        ,(let (,@(loop for n in names for g in gensyms collect `(,n ,g)))
           ,@body
)
)
)
)
)

Не только простые макросы

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

1)Подобно функциям, макрос также может содержать объявления, но сейчас вам не стоит беспокоиться об этом.
2)APPEND, который я ранее не упоминал, является функцией, которая получает произвольное число аргументов-списков и возвращает в качестве результата единственный список, полученный склейкой их вместе.
3)Другая функция, MACROEXPAND, продолжает раскрытие результата пока первый элемент получаемого раскрытия является именем макроса. Однако, это часто показывает вам гораздо более низкоуровневое представление о том, что делает код, чем вам нужно, так как базовые структуры контроля, такие как DO, также реализованы в виде макросов. Другими словами, в то время как в учебных целях может быть полезно посмотреть, во что в конечном счете расширяется ваш макрос, это не очень полезно для просмотра того, во что расширяется именно ваш макрос.
4)Если все раскрытие макроса отображается в одну строку, возможой причиной является то, что переменная *PRINT-PRETTY* установлена в NIL. Если это так, вычисление (setf *print-pretty* t) должно сделать раскрытия макросов более легкими для чтения.
5)Этот закон, описанный в книге Джоэла Спольски "Джоэл о программировании", доступен также по адресу http://www.joelonsoftware.com/articles/LeakyAbstractions.html. Точка зрения Спольски, выраженная в эссе, заключается в том, что все абстракции содержат "течи" в той или иной степени, то есть не существует идеальных абстракций. Но это не значит, что вы должны допускать "течи", которые легко устранить.
6)Конечно, для определенных форм, таких как формы тела цикла do-primes, предполагается именно вычисление более одного раза.
7)Может быть не очень очевидным, что этот цикл обязательно бесконечен, учитывая неравномерное распределение простых чисел. Начальной точкой доказательства, что он на самом деле бесконечен, является постулат Бертрана, который говорит, что для любого n > 1, существует простое число p такое, что n < p < 2n. Отсюда вы можете доказать, что для любого простого числа P, меньшего чем сумма предыдущих простых чисел, следующее простое число P' также меньше чем исходная сумма плюс P.
Предыдущая Оглавление Следующая
@2009-2013 lisper.ru