Джонатан Бартлет
Оглавление
В статье "Искусство метапрограммирования, часть 1: Введение в метапрограммирование":
- Мы определили проблемы, которые лучше всего решаются при помощи генерирующих код программ, включая:
- Программы, нуждающиеся в предварительно сгенерированных таблицах данных
- Программы, имеющие много стереотипного кода, который нельзя абстрагировать в функции
- Программы, использующие технологии, которые слишком многословно выражаются на языке программы
- Затем мы рассмотрели несколько систем метапрограммирования и примеры их использования, включая:
- Системы текстовой подстановки общего назначения
- Предметно-ориентированные генераторы программ и функций
- Затем мы исследовали конкретный пример создания таблицы
- Мы написали генерирующую код программу построения статических таблиц в C
- Наконец, мы познакомились с Scheme и увидели, как он может решать проблемы, которые возникают в языке C, используя конструкции, являющиеся частью самого языка Scheme
В этой статье приводятся подробности программирования макросов Scheme и объясняется, как они могут значительно облегчить масштабные задачи программирования.
Хотя макросы syntax-case
не являются стандартной частью Scheme, они являются наиболее широко используемым типом макросов, позволяющим гигиенические и не гигиенические формы, и являются очень близкими к стандартным макросам syntax-rules
.
Макросы syntax-case
имеют следующий формат (листинг 1):
Листинг 1. Общий формат макросов syntax-case
(define-syntax macro-name
(lambda (x)
(syntax-case x (другие ключевые слова, если имеются)
(
;;Первый шаблон
(macro-name macro-arg1 macro-arg2)
;;Расширение макроса (одна или несколько форм)
;;(syntax - это зарезервированное слово)
(syntax (расширение макроса находится здесь))
)
(
;;Второй шаблон - версия с одним аргументом
(macro-name macro-arg1)
;;Расширение макроса
(syntax (расширение макроса находится здесь))
)
)))
|
Этот формат определяет macro-name
как ключевое слово, используемое для преобразования. lambda
- это функция, используемая для преобразования выражения x
в его расширение.
syntax-case
принимает выражение x
в качестве своего первого аргумента. Второй аргумент - это список ключевых слов, которые вставляются дословно в синтаксические шаблоны. Другие идентификаторы, используемые в шаблонах, будут работать как переменные шаблона. Затем в syntax-case
указывается последовательность комбинаций шаблон/преобразователь. syntax-case
обрабатывает каждую из них, пытаясь сопоставить входную форму шаблону и, при совпадении, выполняет соответствующее расширение.
Рассмотрим простой пример. Допустим, что мы хотим написать более подробную версию оператора if
, чем предлагаемую в Scheme. И допустим, что мы хотим найти и возвратить большее из двух чисел. Код будет выглядеть примерно так:
(if (> a b) a b)
Для программистов, не работавших с Scheme, будет странным не видеть текстовых указаний ветви "then" и ветви "else". Чтобы решить этот вопрос, мы можем создать нашу собственную версию оператора if
, в которой добавляются ключевые слова "then" и "else". Код будет выглядеть так:
(my-if (> a b) then a else b)
В листинге 2 приведен макрос для выполнения этой операции:
Листинг 2. Макрос для определения расширенной версии оператора if
;;определить my-if как макрос
(define-syntax my-if
(lambda (x)
;;установить, что "then" и "else" - это ключевые слова
(syntax-case x (then else)
(
;;шаблон для соответствия
(my-if condition then yes-result else no-result)
;;преобразователь
(syntax (if condition yes-result no-result))
)
)))
|
При выполнении этот макрос будет сопоставлять выражение my-if
с шаблоном следующим образом (другими словами, соответствие вызова макроса шаблону определения макроса):
(my-if (> a b) then a else b)
/ / / / / /
/ / / / / /
v v v v v v
(my-if condition then yes-result else no-result)
|
В преобразующем выражении везде, где встретится слово condition
, оно будет заменено на (> a b)
. Не имеет значения, что (> a b)
- это список. Это простой элемент, помещенный в список, и рассматривается он как отдельная сущность в шаблоне. Результирующее синтаксическое выражение просто переставляет каждую из этих частей в новом выражении.
Это преобразование происходит перед выполнением во время так называемого макрорасширения . Во многих основанных на компиляторах реализациях Scheme макрорасширение происходит во время компиляции. Это означает, что макросы выполняются только один раз, в начале программы или во время компиляции, и никогда не вычисляются повторно. Следовательно, наш оператор my-if
не вносит никаких накладных расходов - он преобразуется в простой if
во время исполнения.
В следующем примере мы выполним известный макрос swap!
. Это будет простой макрос, предназначенный для перестановки значений двух идентификаторов. В листинге 3 приведен пример использования макроса.
Листинг 3. Использование макроса swap! для перестановки значений идентификаторов
(define a 1)
(define b 2)
(swap! a b)
(display "a is now ")(display a)(newline)
(display "b is now ")(display b)(newline)
|
Следующий простой макрос (листинг 4) реализует перестановку путем определения новой временной переменной:
Листинг 4. Определение вашего собственного макроса swap!
;;Определить новый макрос
(define-syntax swap!
(lambda (x)
;;здесь мы не используем ключевых слов
(syntax-case x ()
(
(swap! a b)
(syntax
(let ((c a))
(set! a b)
(set! b c)))
)
)))
|
Здесь определяется новая переменная с именем с
. Но что произойдет, если один из переставляемых аргументов будет называться с
?
syntax-case
решает эту проблему, заменяя переменную с
уникальным, неиспользуемым именем переменной при расширении макроса. Следовательно, синтаксический преобразователь сам обо все побеспокоится.
Обратите внимание на то, что syntax-case
не заменяет let
, потому что let
- это определенный глобально идентификатор.
Техника замены имен переменных не конфликтующими именами называется гигиеной ; использующий эту технику макрос называется гигиеническим макросом . Гигиенические макросы могут безопасно использоваться везде без опасений конфликта с существующими именами переменных. Для широкого круга задач метапрограммирования эта возможность делает макросы более предсказуемыми и удобными в работе.
Хотя гигиенические макросы делают представляемые в макросе имена переменных безопасными, существуют ситуации, когда вы хотите, чтобы макрос был не гигиеническим. Например, допустим, что вы хотите создать макрос, определяющий переменную в области видимости, которая используется вызывающей макрос программой. Такой макрос должен быть не гигиеническим, поскольку он вмешивается в пространство имен пользовательского кода. Однако во многих случаях эта способность является полезной.
В качестве простого примера, допустим, что вы хотите написать макрос, определяющий несколько математических констант для использования внутри макроса (да, это можно было бы сделать с использованием других средств, но я делаю именно так для примера). Допустим, мы хотим определить числа pi
и e
, используя следующий вызов макроса (листинг 5):
Листинг 5. Вызов макроса определения математических констант
(with-math-defines
(* pi e))
|
Если бы мы попытались записать его как предыдущие макросы, он бы не работал:
Листинг 6. Не работающий макрос определения математических констант
(define-syntax with-math-defines
(lambda (x)
(syntax-rules x ()
(
(with-math-defines expression)
(syntax
(let ( (pi 3.14) (e 2.71828) )
expression))
)
)))
|
Этот фрагмент не работает. Причина этого, как упоминалось ранее, заключается в том, что Scheme будет переименовывать pi
и e
, для того чтобы они не конфликтовали с другими именами в окружающих или вложенных областях видимости. Следовательно, они получат новое имя и код (* pi e)
будет ссылаться на неопределенные переменные. Нам необходим способ записывать литеральные символы, которые могут использоваться разработчиком, вызывающим макрос.
Для записи в макросе кода, который бы не модифицировался автоматически (гигиена) системой Scheme, код должен быть преобразован из списка символов в синтаксический объект , который можно было бы затем присвоить переменной шаблона и вставить в преобразованное выражение. Для этого мы будем использовать with-syntax
, который по существу является оператором "let" для макросов. Он имеет аналогичный основной формат, но используется для присвоения синтаксических объектов переменным шаблона.
Для создания новой переменной шаблона вы должны быть способны транслировать символы и выражения в обоих направлениях между представлением в списке (способ записи синтаксиса) и более абстрактным представлением синтаксического объекта . Эти преобразования выполняют следующие функции:
datum->syntax-object
преобразует список в более абстрактное представление синтаксического объекта.
- Первым параметром этой функции обычно является
(syntax k)
- немного магическая формула, помогающая преобразователю синтаксиса получить корректный контекст.
- Вторым параметром является выражение, которое нужно преобразовать в синтаксический объект.
- Результат - это синтаксический объект, который может быть присвоен переменной шаблона с использованием
with-syntax
.
syntax-object->datum
обратная процедура datum->syntax-object
. Она получает синтаксический объект и преобразует его в выражение, с которым можно работать, используя обычные функции Scheme для обработки списков.
syntax
принимает выражение преобразования, содержащее переменные шаблона, и константное выражение, а возвращает полученный синтаксический объект.
В данном примере для получения литерального значения в переменной шаблона вы должны использовать комбинацию синтаксиса и syntax-object->datum
. Затем вы могли бы поработать с выражением и использовать datum->syntax-object
для получения его назад в виде синтаксического объекта, который можно присвоить переменной шаблона в with-syntax
. Затем в конечном выражении преобразования новая переменная шаблона может быть использована как любая другая переменная.
В сущности, вы преобразуете синтаксис Scheme в список, которым можно управлять, работаете с этим списком и преобразуете его назад в синтаксис выражения Scheme для вывода.
В листинге 7 приведено определение макроса, использующего эти функции для определения математических символов:
Листинг 7. Работающий макрос определения математических констант
(define-syntax with-math-defines
(lambda (x)
(syntax-case x ()
(
;;Шаблон
(with-math-defines expression)
;;with-syntax определяет новые переменные шаблона
(with-syntax
(
(expr ;;новая переменная шаблона
;;преобразовать выражение в синтаксический объект
(datum->syntax-object
;;syntax - местная магия
(syntax k)
;;выражение для преобразования
`(let ( (pi 3.14) (e 2.72))
;;Вставить код для переменной шаблона "expression"
;;сюда.
,(syntax-object->datum (syntax expression))))))
;;Использовать новую созданную переменную шаблона "expr"
;;как конечное выражение
(syntax expr))
)
)))
|
Если вы не знакомы с Scheme, обратная кавычка, называемая квазикавычкой , похожа на оператор "кавычка" за исключением того, что разрешает включать данные, не имеющие кавычек, если они начинаются с запятой (которая называется оператором закрытия кавычек ). Это позволяет нам соединить выражение с нашим фрагментом шаблонного кода, затем все это дело преобразовать обратно в синтаксический объект как конечное преобразование.
Поскольку мы явно соединили новые переменные с существующим синтаксическим объектом, они не могут быть переименованы. Также обратите внимание на то, что выражение (syntax k)
в datum->syntax-object
необходимо, но, по существу, бессмысленно. Оно используется для активизации маленькой "магии" в синтаксическом процессоре, для того чтобы функция datum->syntax-object
знала, какой контекст выражения должен быть в ней обработан. Всегда записывается как (syntax k)
.
Проблема негигиенических макросов заключается в том, что определяемые переменные могут перезаписать другие переменные и быть перезаписанными другими переменными в коде. Это делает смешивание негигиенических макросов особенно опасным, поскольку макросы не будут знать, какие переменные используют другие макросы, и могут поменять значения переменных друг друга. Следовательно, негигиенические макросы должны использоваться только тогда, когда нет другого способа решить задачу с использованием обычных функций или гигиенических макросов, прчем в таких ситуациях символические определения макросов должны быть тщательно документированы.
Значительный объем в больших приложениях занимает стереотипный код, написание которого является трудоемким процессом. Впоследствии, если в нем обнаружится ошибка, очень и очень трудно будет найти каждый экземпляр такого кода и исправить его. Это означает, что стереотипный код - это одно из немногих мест, где негигиенические макросы полезны.
Значительная часть стереотипного кода просто устанавливает переменные, которые затем будут использоваться в вашей функции. Следовательно, стереотипные макросы должны определять большой набор обычных присвоений, а также, возможно, и другие вспомогательные задачи.
Допустим, что мы создаем CGI-приложение, состоящее из нескольких независимых CGI-сценариев. В большинстве CGI-приложений большая часть переменных состояния хранится в базе данных, но только ID сессии передается в сценарий через куки.
Однако, практически в каждой странице мы должны знать другую стандартную информацию (например, имя пользователя, номер группы, текущую выполняемую пользователем задачу и любую другую относящуюся к делу информацию). Кроме того, мы должны перенаправлять пользователей, если они не имеют соответствующего куки. В листинге 8 приведен код, который мог бы быть стереотипным (функции гипотетического Web-сервера начинаются с webserver:
):
Листинг 8. Стереотипный код для Web-приложения
(define (handle-cgi-request req)
(let (
(session-id (webserver:cookie req "sessionid")))
(if (not (webserver:valid-session-id session-id))
(webserver:redirect-to-login-page)
(let (
(username (webserver:username-for-session session-id))
(group (webserver:group-for-user username))
(current-job (webserver:current-job-for-user username)))
;;Обрабатывающий код помещается сюда
))))
|
Хотя кое-что из этого может выполняться в процедуре, присвоения определенно нет. Однако мы можем записать большинство из них в макросе. Макрос может быть реализован так:
Листинг 9. Макрос со стереотипным кодом
(define-syntax cgi-boilerplate
(lambda (x)
(syntax-case x ()
(
(cgi-boilerplate expr)
(datum->syntax-object
(syntax k)
`(let (
(session-id (webserver:cookie req "sessionid")))
(if (not (webserver:valid-session-id session-id))
(webserver:redirect-to-login-page)
(let (
(username (webserver:username-for-session session-id))
(group (webserver:group-for-user username))
(current-job (webserver:current-job-for-user username)))
,(syntax-object->datum (syntax expr))))))
)
)))
|
Теперь мы можем создать новые формы, основанные на нашем стереотипном коде, следующим образом:
(define (handle-cgi-request req)
(cgi-boilerplate
(begin
;;Выполнить любые желаемые действия
)))
|
Кроме того, поскольку мы не определяем наши переменные явно, добавление новых определений переменных в наш стереотипный код не будет влиять на соглашения по его вызову, поэтому новые возможности могут быть добавлены без необходимости создавать новую функцию.
В любом большом проекте неизбежно существуют шаблоны, которые нельзя выделить в функции, главным образом из-за создаваемых присвоений. Использование стереотипных макросов может значительно облегчить обслуживание такого стереотипного кода.
Также могут быть созданы и другие стандартные макросы, использующие переменные, определенные в стереотипном коде. Использование подобных макросов значительно уменьшает работу по вводу кода, поскольку вам не нужно постоянно записывать и переписывать присвоения переменных, ветвления и передаваемые параметры. Также в таком коде уменьшается вероятность ошибок.
Но знайте, что стереотипные макросы не являются панацеей. Может возникнуть много проблем, включая следующие:
- Случайная перезапись присвоения из-за определения имени переменной, уже определенной в макросе.
- Проблемы сложности трассировки, поскольку ввод и вывод в макросах происходит неявно.
Этих проблем в основном можно избежать, выполнив несколько действий, относящихся к вашим стереотипным макросам:
- Следуйте соглашениям по именованию, которые явно маркируют макросы как таковые, а также указывают, что переменная пришла из стереотипного кода. Это можно сделать, например, добавляя в качестве суффикса
-m
для макросов и -b
для определенных в макросе переменных.
- Тщательно документируйте все стереотипные макросы, особенно определяемые присвоения переменных и все изменения между версиями.
- Используйте стереотипные макросы только тогда, когда выгоды от сокращения повторяемости кода превышают отрицательные стороны неявной функциональности.
В программировании действительно часто необходим маленький предметно-ориентированный язык. Существует много примеров таких используемых в настоящее время языков:
- Конфигурационные файлы
- Языки разметки для Web, например HTML
- Языки управления задачами
Эти языки не обязательно должны быть полными по Тюрингу (то есть, имеющими вычислительную мощность эквивалентную универсальной машине Тюринга - другими словами, система и универсальная машина Тюринга могут эмулировать друг друга). Их общим свойством является то, что все они имеют много неявных предположений и неявных состояний, которые выражались бы явно в языках программирования общего назначения. Scheme позволяет использовать лучшие качества обоих подходов, будучи способным определять макросы, работающие как специализированные предметно-ориентированные языки.
В качестве первого примера рассмотрим файл конфигурации безопасности, описывающий различные зоны безопасности, определенные в конфигурационном файле. Мы определим несколько различных зон безопасности, каждая из которых будет иметь различные права доступа и ограничения.
Многие системы уже имеют декларативную безопасность. В частности, J2EE имеет некоторые возможности декларативной безопасности, которые мы собираемся рассмотреть, например:
Листинг 10. Возможности декларативной безопасности в J2EE
<![CDATA[
<security-constraint>
<web-resource-collection>
<web-resource-name>Test Resource</web-resource-name>
<description>This is an example Resource</description>
<url-pattern>/Test</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>USERS</role-name>
</auth-constraint>
</security-constraint>
]]>
|
В этом коде мы ограничиваем доступ к определенному URL на основе заданной роли пользователя и указываем, какой механизм аутентификации использовать для незарегистрированного в системе пользователя. Это можно сделать подобным способом в макросе Scheme. Мы могли бы определить макрос, который позволил бы нам сделать что-то подобное следующему (макрос декларативной безопасности ):
(resource "Test Resource" "This is an example resource" "/Test"
(auth-constraints (role "USERS")))
|
В листинге 11 приведен пример определения макроса для предыдущего вызова (все функции, начинающиеся с префикса webserver:
, являются гипотетическими функциями, предоставляемыми Web-сервером):
Листинг 11. Макрос декларативной безопасности
;;Этот макрос создает выражения, проверяющие корректность
;;полномочий аутентификации в переменной "credentials",
;;выдающие отчет и перенаправляющие пользователей при неавторизованном доступе.
(define-syntax auth-constraints
(lambda (x)
(syntax-case x (auth-constraints time role)
(
;;Обработка ограничений по одному
;;в операторе (begin).
(auth-constraints constraint1 constraint2 ...)
(syntax
(begin
(auth-constraints constraint1)
(auth-constraints constraint2 ...)))
)
(
;;Расширение для механизма проверки роли
;;( "credentials" определены в макросе "resource" ниже)
(auth-constraints (role rolename ...))
(syntax
(if
(not
(webserver:is-in-role-list credentials (list rolename ...)))
(webserver:report-unauthorized)
#f))
)
(
;;Разрешает проверку на основе времени
(auth-constraints (time beginning ending))
(syntax
(let (
(now (webserver:getunixtime)))
(if
(or (< now beginning) (> now ending))
(webserver:report-unauthorized) #f)))
)
(
;;Неизвестный случай - предположим, что он закодирован или преобразован
;;другим макросом
(auth-constraints unknown)
(syntax unknown)
)
)))
;;Каждое определение ресурса расширяет функцию для проверки
;;полномочий. Оно располагается в виде ярусов над определенными выше макросами,
;;которые составляют тело функции проверки полномочий.
;;Настраивается параметр "credentials", который используется в
;;указанных выше выражениях
(define-syntax resource
(lambda (x)
(syntax-case x ()
(
(resource name description url security-features)
(with-syntax
(
;;Построение функции для проверки информации безопасности
(security-function
(datum->syntax-object
(syntax k)
`(lambda (credentials)
,@(syntax-object->daturm (syntax security-features))))
(syntax
(webserver:add-security-function
name description url security-function)))))))
|
Эти макросы требуют небольшого разъяснения. Прежде всего, присутствует новая конструкция - ...
. Это обозначение по существу означает "повторять предыдущее". Оно может быть использовано и в шаблоне макроса и в выражении.
Макрос resource
в основном создает функцию для обработки полномочий безопасности и затем передает их в качестве аргумента в webserver:add-security-function
. Это функция с одним аргументом, credentials
, который будет использоваться макросом auth-constraints
.
Макрос auth-constraints
немного более сложен. Он может иметь один из двух форматов - принимать либо одно ограничение для обработки, либо список ограничений. Первый раздел макроса разбивает список ограничений на несколько одиночных ограничений. Обозначение ...
используется для указания возможного продолжения аналогичных форматов. Мы пользуемся преимуществом, заключающимся в том, что после расширения макроса полученный макрос расширяется снова до тех пор, пока расширения не закончатся. Если вы проследите за итерационными расширениями auth-constraints
, то увидите, что он и в самом деле расширяется в список индивидуальных макросов auth-constraints
, которые затем обрабатываются по отдельности, используя оставшиеся форматы макроса.
auth-constraints
имеет две дополнительные функции, которые не используются в примере. Первая - это основанный на времени механизм авторизации, а вторая - способность к дальнейшему расширению другими макросами и кодом. Основанный на времени механизм авторизации - это попросту пример того, как несколько типов ограничений могут быть добавлены в этот механизм; функция расширения будет использована в следующем примере.
Эти макросы будут расширять наши объявления параметров безопасности в следующее (листинг 12):
Листинг 12. Расширение декларативной безопасности в Scheme
(webserver:add-security-function
"Test Resource"
"This is an example resource"
"/Test"
(lambda (credentials)
(begin
(if (not (webserver:is-in-role-list credentials (list "USERS")))
(webserver:report-unauthorized)
#f))))
|
Возникает два очевидных вопроса:
- Почему мы реализовали это в виде макроса?
- Что не так с XML-объявлениями, используемыми Java?
Есть две причины, которые делают использование макроса более предпочтительным, чем такой язык данных как XML, для файла декларативной безопасности:
- Декларативная информация преобразуется в императивную форму во время компиляции, а не каждый раз при запуске программы на выполнение, что приводит к более быстрому коду.
- Что более важно, если декларативный язык недостаточно выразителен для ваших потребностей, - вы также можете включить и императивные операторы в ваш файл, используя всю выразительную мощь языка программирования .
Если первая функция просто полезна, то вторая действительно стоит того, чтобы ею пользоваться. Поскольку макрос расширяется в обычный код в любом случае, вы всегда можете перейти назад к императивному программированию, если декларативный язык не подходит под ваши требования. Фактически, если преобразование хорошо документировано, вы даже можете смешивать декларативные и императивные операторы в вашей конфигурации.
Допустим, например, что вы хотите проверить наличие домена, с которого пришел пользователь, в списке запрещенных IP-адресов. Вот как мы можем это сделать, смешивая декларативные и императивные функции безопасности:
(resource "Test Resource" "This is an example resource" "/Test"
(auth-constraints
(role "USERS")
(if (rogue-ip-list:contains (webserver:ip-address credentials))
(webserver:report-unauthorized)
#f)))
|
Это дает неограниченную гибкость при программировании. Вы можете писать программу декларативно, используя предметно-ориентированное подмножество языка, и возвращаться к полнофункциональному языку программирования, если это подмножество не полностью подходит под ваши требования.
Метапрограммирование широко используется в программировании широкомасштабных проектов. В данной статье я коснулся инструментальных средств, необходимых для метапрограммирования на языке Scheme, а также привел несколько примеров. Технология метапрограммирования применялась в нескольких прикладных областях:
- Улучшение синтаксиса
- Автоматизация генерирования стереотипного кода
- Написание декларативных подпрограмм
В Scheme вы можете использовать возможности макроязыка для определения практически любого типа предметно-ориентированного языка. Средство для этого существует. Просто нужно решить, какие возможности реализуются более легко и более понятно при помощи макрорасширений, а какие - при помощи обычного кода.