Готовый перевод Let Over Lambda - 50 Years of Lisp / Let Over Lambda - 50 лет Lisp-у: Глава 3 - Основы Макросов

3.1 Итеративная Разработка

Лисп помог целому ряду одарённых людей размышлять таким образом, какой ранее не был им доступен.
- Эдсгер Дейкстра

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

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

В этой главе мы напишем несколько базовых макросов и ознакомимся с двумя основными концепциями макросов: предметно-ориентированные языки (domain specific languages) и структуры управления (control structures). После того, как мы изучим эти основные понятия о макросах, мы вернёмся назад и обсудим процесс создания макросов с помощью макросов. На протяжении всей книги мы будем использовать такие техники как захват переменной и введение свободной переменной, кроме этого мы ознакомимся с новым, более удобным синтаксисом определения Лисп макросов.

3.2 Предметно-Ориентированные Языки

Common Lisp, наряду с большинством программных сред, предоставляет функцию sleep, которая приостанавливает исполнение процесса на n секунд, где n - не отрицательный, не комплексный, числовой аргумент. Например, мы можем захотеть уснуть на 3 минуты (180 секунд), в этом случае мы можем вычислить эту форму:

(sleep 180)

Или, если мы предпочитаем думать о засыпании в терминах минут, то взамен последнего выражения мы можем использовать

(sleep (* 3 60))

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

(defun sleep-minutes (m)
(sleep (* m 60)))

Подход с определением новой функции для каждой единицы времени оказывается неуклюжим и неудобным. Что нам нужно - так это некая абстракция, которая позволяла бы использовать единицу времени вместе со значением. Нам нужен предметно-ориентированный язык (domain specific language).

Решение, которое мы можем использовать в Лиспе может быть таким же, как и в остальных языках программирования: создать функцию, которая будет принимать значение и единицу времени и возвращать значение умноженное на некоторую константу, относящуюся к конкретной единице времени. И тут проявляются удобства, предоставляемые Лиспом при реализации единицы времени. В языках, подобных C принято использовать нижележащие типы данных (например: int) и присваивать произвольные значения, относящиеся к различным единицам времени:

#define UNIT_SECONDS 1
#define UNIT_MINUTES 2
#define UNIT_HOURS 3
int sleep_units(int value, int unit) {
switch(value) {
case UNIT_SECONDS: return value;
case UNIT_MINUTES: return value*60;
case UNIT_HOURS: return value*3600;
}
}

Листинг 3.1: SLEEP-UNITS-1
(defun sleep-units% (value unit)
(sleep
(* value
(case unit
((3) 1)
((m) 60)
((h) 3600)
((d) 86400)
((ms) 1/1000)
((us) 1/1000000)))))

Но в Лиспе самый очевидный путь обозначения желаемой единицы - это использование символа. Символ в Лиспе - это нечто, не eq'альное другим символам. Eq - это самый быстрый оператор сравнения в Лиспе и примерно соответствует сравнению указателей. Поскольку сравнение указателей производится очень быстро, то символы предоставляют нам очень быстрый и удобный способ определения равенства двух и более различных Лисп выражений. В Лиспе мы должны определить функцию sleep-units%, после этого мы можем использовать единицы времени в наших формах:

(sleep-units% 2 ’m)
(sleep-units% 500 ’us)

Поскольку сравнение символов требует сравнения только одного указателя, sleep-units% будет скомпилирован в очень быструю диспетчеризацию во времени выполнения:

...
524: CMP ESI, [#x586FC4D0] ; ’S
52A: JEQ L11
530: CMP ESI, [#x586FC4D4] ; ’M
536: JEQ L10
538: CMP ESI, [#x586FC4D8] ; ’H
53E: JEQ L9
540: CMP ESI, [#x586FC4DC] ; ’D
546:
...

Заметьте, как должна закавычиваться единица времени, указанная в sleep-units%. Это происходит потому, что Лисп начинает вычисление функции с вычисления всех аргументов функции и привязывает результаты к переменным, использующимся внутри функции. Числа, строки и некоторые другие примитивы вычисляются в самих себя, поэтому нам не нужно закавычивать числовые значения, переданные в sleep-units%. Если же вы хотите чтобы и эти значения не вычислялись, то вы можете закавычить и их:

(sleep-units% ’.5 ’h)

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

1 Как правило, нет правил без исключения. Некоторые символы вычисляются в самих себя, например: t, nil и ключевые слова.

Если мы хотим избежать закавычивания единицы времени, то нам нужно использовать макрос. В отличие от функции макрос не вычисляет свои аргументы. Для того, чтобы воспользоваться этим фактом, мы заменим функцию sleep-units% макросом sleep-units. Теперь нам не нужно закавычивать единицу времени:

Листинг 3.2: SLEEP-UNITS
(defmacro sleep-units (value unit)
‘(sleep
(* ,value
,(case unit
((3) 1)
((m) 60)
((h) 3600)
((d) 86400)
((ms) 1/1000)
((us) 1/1000000)))))

(sleep-units .5 h)

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

(sleep-units 1 (if super-slow-mode ’d ’h))

Это не сработает, поскольку sleep-units ожидает что второй аргумент будет одним из символов, в нашем случае вторым аргументом выступает список в котором первым элементом является if.

Напомним, что большинство макросов писались для того, чтобы создать более удобные и полезные программные абстракции, и не предназначались для улучшения эффективности нижележащего кода. Возможно ли извлечь некоторые идиомы из этого кода, с тем, чтобы сделать его более полезным для остальной части нашей программы (и, возможно, для наших будущих программ)? Уже сейчас можно предсказать, что мы можем захотеть выполнять разные операции с временными значениями, чем простой вызов sleep. Макрос unit-of-time абстрагирует функциональность из макроса sleep-units, возвращая значение вместо вызова sleep. Параметр value может определяться во время выполнения программы, поскольку он является вычисляемым, но unit не является вычисляемым, поскольку нам нужна информация во время компиляции, также, как и при sleep-units. Вот пример:

* (unit-of-time 1 d)
86400

Листинг 3.3: UNIT-OF-TIME
(defmacro unit-of-time (value unit)
‘(* ,value
,(case unit
((5) 1)
((m) 60)
((h) 3600)
(Cd) 86400)
((ms) 1/1000)
((us) 1/1000000))))

Такой простой макрос как unit-of-time даёт нам более лучший синтаксис для решения конкретных областей некоторых проблем и может значительно улучшить продуктивность работы и корректность решения задачи. В разделе 5.2, Программирование Сверху-Вниз мы продолжим разработку языка единиц. В отличие от большинства языков программирования, в Лиспе вам доступны те же инструменты, что и у людей создавших вашу программную среду. Для того, чтобы реализовать язык Common Lisp, достаточно использовать макросы, этих же макросов достаточно, чтобы реализовать ваш собственный предметно-ориентированный язык.

3.3 Управляющие Структуры

Хотя эта книга ориентирована на Common Lisp, идеи, описанные здесь можно применить и для языка программирования Scheme. Scheme - это замечательный язык, и хотя в Scheme отсутствуют многие особенности воспринимаемые Лисп программистами как должное, в нём есть достаточно гибкое ядро, которое при необходимости могут расширить профессиональные Лисп программисты 2. Аналогичным образом, Scheme программисты приводят, в качестве преимущества, особенности на которых не делается ставка в Common Lisp. Но, сравнение особенностей всех языков, кроме некоторых особенностей, бессмысленно. Часто мы в состоянии перебросить мост между двумя языками. Само собой разумеется, что в роли моста, с помощью которых мы объединяем два языка выступают макросы.

2 В основном, Scheme и Common Lisp различаются в своих сообществах. Scheme программисты любят говорить о том, как здорово иметь короткую спецификацию языка; Common Lisp программисты любят писать программы.

Листинг 3.4: NLET
(defmacro nlet (n letargs &rest body)
‘(labels ((,n ,(mapcar #’car letargs)
,@body))
(,n ,@(mapcar #’cadr letargs))))

Scheme'овская форма let является более мощной чем её коллега в Common Lisp. Scheme'овская форма let поддерживает нечто, под названием именованный let (named let). В Scheme вы можете вставить символ перед списком привязок let формы и Scheme привяжет функцию под указанным символом вокруг тела let 3. Эта функция принимает новые аргументы для значений, указанных в привязках let, предоставляя очень удобный способ выражения циклов.

3 В Scheme есть только одно пространство имён, поэтому функция привязывается именно здесь.

К счастью, мы можем построить мост между Scheme и Common Lisp с помощью макроса nlet. Nlet даёт нам возможность писать код в Scheme стиле эмулируя Scheme'овские именованные let'ы. В nlet-fact, nlet используется для определения функции факториала с помощью именованного let:

(defun nlet-fact (n)
(nlet fact ((n n))
(if (zerop n)
1
(* n (fact (- n 1))))))

Поскольку nlet - это наш первый макрос, сделаем паузу и изучим этот макрос глубже. Иногда для того чтобы понять макрос полезно применить macroexpand к примеру, показывающему использование этого макроса 4. Для этого функции macroexpand мы передадим список, представляющий вызов макроса. Учтите, что macroexpand будет расширять макросы, в которых первый символ списка является макрос символом и не расширяет вложенные макро вызовы 5. Теперь мы скопируем вызов nlet прямо из nlet-fact, закавычим его и передадим в macroexpand:

4 Терминология расширения на самом деле довольно неудачна. Никто не говорит что при макрорасширении чего-либо результатом будет больший, расширенный код. Иногда формы расширяются даже в ничто (т.е. nil).

5 Но, macroexpand будет продолжать расширение макроса до тех пор, пока первый элемент не будет представлять макрос. Macroexpand-1 полезен для наблюдения первого шага в этом процессе.

* (macroexpand
’(nlet fact ((n n))
(if (zerop n)
1
(* n (fact (- n 1))))))
(LABELS ((FACT (N)
(IF (ZEROP N)
1
(* N (FACT (- N 1))))))
(FACT N))
T

Расширение использует специальную форму labels, предназначенную для привязки функции вокруг указанного тела. Именованная функция соответствует символу, используемому в форме именованного let. Он получает аргументы значений, привязанных с помощью nlet, в данному случае только n. Поскольку эта функция может быть рекурсивной, nlet реализует полезную итерационную конструкцию.

В простых макросах обычно достаточно использовать обратные кавычки, но более сложные макросы должны использовать хотя бы Лисповские функции обработки списков. Mapcar, применяет функцию к каждому элементу списка и возвращает список из получившихся значений, особенно часто появляется в макросах. Что характерно, mapcar довольно часто появляется и в обычных Лисп программах. Лисп можно настроить так, чтобы его можно было по максимуму использовать для обработки списков. Во всех разновидностях Лисп программирования мы занимаемся тем, что объединяем, разделяем, уменьшаем, отображаем и фильтруем списки. Единственная разница в том, что при программировании макросов вывод непосредственно переходит к компилятору или интерпретатору. Программирование макросов в Лиспе - это, по сути, то же самое что и обычное Лисп программирование.

Но что даёт нам право сказать что nlet - это новая управляющая структура? Управляющая структура - это всего лишь забавный способ определения некоторой конструкции, которая не следует поведению функции. Функция вычисляет каждый аргумент слева направо, привязывает результаты в среде и выполняет машинный код, определённый в некоторой lambda форме. Поскольку nlet не вычисляет напрямую свои аргументы, а вместо этого объединяет их в некоторый кусок Лисп кода, мы изменили выполнение вычисления для nlet форм и тем самым создали новую управляющую структуру.

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

Common Lisp очень удобен для создания макросов, макрос nlet показывает одно из этих преимуществ. В таких формах привязки как let, существует общее соглашение, которое гласит: если переменной не привязано какое-либо значение, то эта переменная будет привязана к nil. Другими словами результатом вычисления (let ((a)) a) будет nil 6. В Scheme, а этот язык менее дружественный к созданию макросов, этот случай должен проверяться как частный случай при итерации с подобными привязками, поскольку (car nil) и (cdr nil) вернут ошибки типа. В Common Lisp (car nil), (cdr nil) и, следовательно, (car (cdr nil)) и (cadr nil) вернут nil, позволяя второму mapcar в nlet продолжать работу даже если используется соглашение о пустом let значении. Эта особенность Common Lisp взята из Interlisp [INTERLISP].

6 Common Lisp позволяет нам писать даже так: (let (a) a) что в итоге даст все тот-же эффект.

Наш макрос nlet отличается от именованного let в Scheme очень маленькой деталью. В этом случае интерфейс макроса является приемлемым, но расширения может и не быть. В общем случае, при программировании через несколько уровней, наша ментальная модель кода может немного отличаться от реальности. В Scheme, при выполнении хвостовой рекурсии именованного let гарантируется неупотребление дополнительного стекового пространства, поскольку Scheme гарантирует, согласно стандарту, выполнение специальной оптимизации. Это не тот случай в Common Lisp, поэтому не исключается переполнение стека в Common Lisp'овской версии nlet, чего не произойдёт с именованным let в Scheme. В разделе 5.4, Проход по Коду с Macrolet мы рассмотрим как написать версию nlet с идентичным интерфейсом но, с потенциально более эффективным расширением 7.

7 На практике, обычно бывает достаточно и этой версии nlet, поскольку компилятор Common Lisp почти наверняка оптимизирует хвостовые вызовы в скомпилированном коде.

3.4 Свободные Переменные

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

(+ 1 x)

В следующем примере мы создаём привязку вокруг формы, эта привязка захватывает (captures) переменную x, лишая её свободы:

(let ((x 1))
(+ 1 x))

На первый взгляд терминология свободы и захвата может показаться странной. В конце концов, свобода подразумевает сознательность и возможность принятия решений - очевидно, что выразить всё простым выражением не получится. Но свобода не относится к тому, что может выполнять выражение, скорее, это относится к тому, что мы, как программист, можем выполнять с этим выражением. Например, мы можем взять выражение (+ 1 x) и встроить его куда-либо ещё, позволяя нашему выражению получить доступ к x, привязанному к окружающему коду. Затем мы можем сказать что код захватил (captured) нашу свободную переменную. После того, как свободные переменные в выражении были захвачены, как в предыдущей форме let, другой окружающий код не может захватить нашу переменную x. Наша в прошлом свободная переменная уже была захвачена. Теперь вполне однозначно ясно куда ссылается x. По этой причине понятно почему Лиспу не надо хранить значение, на которое ссылается символ x на протяжении всего кода. Как мы описали в деталях в разделе 2.3, Лексическая и Динамическая Области Видимости, Лисп компиляторы забывают символы, которые были использованы для представления лексических переменных.

Возможности макросов означают что свободные переменные в Лиспе гораздо более полезны чем в других языках, хотя в любом, поддерживающим выражения, языке может быть код со свободными переменными. В большинстве языках мы вынуждены подчиняться ссылочной прозрачности (referential transparency). Если в Блаб языке не объявлена глобальная или объектная переменная x, то следующий код безусловно неверен:

some_function_or_method() {
anything(1 + x);
}

Нет никакого способа, с помощью которого some_function_or_method мог бы создать неявную привязку к x. В Блабе любое использование переменной должно быть текстово очевидным определением 8. Языки с примитивной макро системой (такие как C) могут реализовывать некоторые приёмы, но, реализация этих приёмов весьма ограничена. Создание макросов в C непрактично или невозможно, также, как и частные случаи использования свободных переменных.

8 Или, иногда, в объектно-ориентированном Блабе, определения класса или объекта.

В Лиспе мы можем оперировать выражениями со свободными переменными так, как нам заблагорассудится и либо объединить их в новое выражение, захватываемое окружающим кодом, или определить специальные глобальные переменные для последующего захвата. Также мы можем написать макрос, который модифицирует и освобождает переменные в выражении или переписывает выражение с целью уменьшения количества свободных переменных (например, как представлено выше, оборачивая выражение в let форму) или модифицируя выражение с целью добавления новых свободных переменных. Такое добавление свободных переменных является противоположностью захвата переменных и называется инъекцией свободной переменной (free variable injection).

Простейшей инъекцией свободной переменной является макрос, расширяющийся в ссылку на символ:

(defmacro x-injector ()
'x)

Поскольку макрос - это та же функция, то он исполняет своё тело как обычную Лисп форму. Вышеприведённый макрос - инжектор вычисляет закавыченный символ и, конечно, возвращает символ - свободную переменную - которая будет объединена с любым, использующим макрос x-injector, выражением. Обсуждая такие инъекции свободных переменных в On Lisp, Пол Грэм написал

Такая разновидность лексических взаимодействий обычно рассматривается как источник проблем, нежели как источник удовольствия. Обычно написание макросов в таком ключе является плохим стилем. Из всех макросов в этой книге, только [два изолированных случая] используют вызов среды подобным способом.

В противоположность On Lisp в этой книге получают большое удовольствие от таких разновидностей лексических взаимодействий. Инъекция свободной переменной - это создание макроса с полным знанием лексической среды, в которую он будет расширен - это всего лишь другой подход к программированию Лисп макросов, который особенно полезен при наличии нескольких немного отличающихся лексических контекстов, в которых вы можете захотеть написать почти идентичный код. Хотя главным преимуществом вызова функций является вынос за пределы функции вашей лексической среды, иногда, для Лисп программистов, это всего лишь руководство к действию, которое может быть проигнорировано через использование макросов. Фактически, только привыкнув к этому, некоторые Лисп программисты по возможности всегда стараются писать макросы, расширяя лексический контекст настолько, насколько это возможно, и используют функции только когда им нужно вычислить аргументы или просто для того, чтобы поджать хвост при желании нового лексического контекста. В разделе 3.6, Только Один Раз мы рассмотрим способ не допустить вывода наружу вашей лексической среды при вычислении аргументов. Сохранение окружающей лексической среды до тех пор, пока это возможно, позволяет нам выполнять очень интересные комбинации (combinations) макросов, где макрос добавляет окружающий лексический контекст для использования несколькими другим макросами. Расширения в коде, которые очень сильно используют макросы будут определены как частный случай комбинации макросов и будут рассмотрены в разделе 5.5, Рекурсивные Расширения.

Кратчайший путь между двумя точками - это прямая линия. Свободные переменные и, более глобально, расширенные лексические контексты - это наиболее простейший способ программного построения программ. Здесь применение макросов может показаться хаком и нежелательным со стилистической точки зрения, но даже здесь использование макросов удобно и надёжно. Особенно после того, как мы рассмотрим macrolet в разделе 5.4, Проход по Коду с помощью Macrolet, этот стиль программирования - комбинирование макросов - покажется вам более комфортабельным. Только помните, что программирование макросов - это не вопрос стиля; это вопрос мощи. Макросы дают нам возможность выполнять такие вещи, реализация которых невозможна в других языках. Одна из них - это инъекция свободной переменной.

3.5 Нежелательный Захват

Существуют две точки зрения на захват переменной. Захват переменной - это источник некоторых непредсказуемых багов, но, при правильном использовании может быть весьма желаемой особенностью макросов. Начнём рассмотрение захвата переменной с простого макроса, определённого Грэмом в On Lisp: nif. Nif - это числовой if, который имеет четыре обязательных пункта, по сравнению с обычным if, который имеет два обязательных пункта и необязательный третий пункт. Nif, или вернее код, в который расширяется nif, вычисляет первый пункт и предполагает, что результат будет не комплексным числом. Далее число вычисляется в одно из трёх пунктов, которое в зависимости от результата может быть положительным (plusp), нулём (zerop) или отрицательным (3-й пункт). Мы можем использовать его для проверки переменной x, следующим способом:

(nif x "positive" "zero" "negative")

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

(defmacro nif-buggy (expr pos zero neg)
‘(let ((obscure-name ,expr))
(cond ((plusp obscure-name) ,pos)
((zerop obscure-name) ,zero)
(t ,neg))))

Niff-buggy расширяется в маленький кусок кода, который использует let для привязки результата вычисления предоставленной пользователем формы expr. Мы делаем это потому, что вычисление expr может привести к побочным эффектам (side-effects), а полученное значение нужно для двух раздельных операций: передача значения в plusp и передача значения в zerop. Но зачем мы вызываем эту временную привязку? Маленькая ошибка появляется из-за введения постороннего символа, obscure-name. Если никто не будет смотреть на расширение макроса, то никто не заметит это имя, вроде бы это не проблема, не так-ли?

В большинстве случаев nif-buggy будет работать также, как и nif. Если символ obscure-name не будет использоваться в формах, передаваемых в nif-buggy 9, то не будет и нежелательного захвата переменной. Но что произойдёт если obscure-name появится в переданных формах? В большинстве случаев, баг по прежнему не будет проявляться:

9 Или в макро расширениях переданного кода. Смотрите сублексическую область видимости.

(nif-buggy
x
(let ((obscure-name ’pos))
obscure-name)
’zero
’neg)

Даже если x будет положительным, и даже если присутствует инъецированный запрещённый символ в макро расширении nif-buggy, этот код будет работать так, как и было задумано. Если созданы новые привязки, а код внутри привязки всегда ссылается на созданную привязку, то в этом случае нежелательный захват переменной не возникает. Проблема возникнет только тогда, когда использование obscure-name пересечёт своё использование в расширении. Вот пример нежелательного захвата переменной:

(let ((obscure-name ’pos))
(nif-buggy
x
obscure-name
’zero
’neg))

В этом случае, obscure-name будет привязываться к результату вычисления x, поэтому, вопреки задумке, символ pos не будет возвращаться 10. Происходит это по причине того, что наше использование символа пересекается с невидимым использованием привязки. Иногда упоминая код с такими невидимыми привязками, говорят что он не ссылочно прозрачен.

10 Конечно, на самом деле это неправильное поведение было намеренным. Проблемы с захватом переменных очень редко бывают такими очевидными и запланированными. Гораздо чаще они бывают менее очевидными и неожиданными.

Но разве это не академический вопрос? Конечно, мы можем подумать о достаточно редких именах, таких, при использовании которых эта проблема никогда не появится. Да, во многих случаях пакеты и грамотное именование переменных может решить проблему захвата переменной. В коде, непосредственно написанном программистом, редко возникают баги, связанные с захватом переменной. Большинство проблем с захватом переменной проявляются тогда, когда другие макросы начинают использовать ваши макросы (комбинируются с вашими макросами) самым неожиданным образом. Пол Грэм даёт прямой ответ на вопрос о защите от нежелательного захвата переменной:

Зачем писать программы с маленькими багами, когда вы можете писать программы без багов?

Я думаю мы можем развить этот вопрос дальше: вне зависимости от тонкости проблемы, зачем делать что-то неправильно, когда вы можете делать это правильно?

К счастью, оказывается, что захват переменной, в тех случаях, когда это является проблемой, - это задача с простым решением. Последнее утверждение - это спорный вопрос для многих людей, особенно для тех, кто не любит очевидные решения и посвящают большую часть своего времени на поиски других решений. Как профессиональному программисту макросов, вам придётся столкнуться со многими решениями проблемы захвата переменной. Текущее популярное решение - это использование так называемых гигиенических макросов (hygienic macros) 11. Эти решения пытаются ограничить или устранить воздействие нежелательных захватов переменной, но, к несчастью выполняют это за счёт желательных захватов переменной. Почти все подходы, используемые для уменьшения воздействия захвата переменной, также ограничивают вас в использовании defmacro. Гигиенические макросы, в лучшем случае, - это ограждение, предназначенное для безопасности новичков; а в худшем случае гигиенические макросы принимают форму электрического забора, помещающего свои жертвы в стерильную, захвато-безопасную тюрьму. Более того, недавние исследования показали, что гигиенические макро системы, подобные тем, что реализованы в различных ревизиях Scheme, могут быть уязвимы ко многим интересным проблемам захвата [SYNTAX-RULES-INSANE] [SYNTAX-RULES-UNHYGIENIC].

11 Другой популярный термин - это "макросы по примеру" ("macros by example").

Настоящее решение проблемы захвата переменных известно как генерированный символ (generated symbol) или, кратко, gensym. Gensym - это способ, с помощью которого Лисп получает имя для нашей переменной. Но, вместо того, чтобы использовать такие неудачные имена как наш obscure-name, Лисп использует хорошие имена. Действительно хорошие имена. Эти имена настолько хороши и уникальны, что нет такого способа (кроме самого gensym) по которым можно было бы создать такое имя снова. Каким образом это становится возможным? В Common Lisp символы (имена) ассоциируются с пакетами (packages). Пакет - это коллекция символов, из которых вы можете получить указатели к строкам их symbol-name (имя-символа). Самым важным свойством этих указателей (обычно просто называемых символами) в том что они будут eq'абельны остальным указателям (символам), обнаруживаемым в этом пакете с тем же самым symbol-name. Gensym - это символ, который не существует ни в одном пакете, поэтому не существует такого symbol-name, который бы вернул символ eq'абельный gensym'у. Gensym'ы нужны тогда, когда вам нужно дать знать Лиспу что некоторый символ должен быть eq'абелен некоторым другим символам в выражении без необходимости их именования. Поскольку вы ничему не даёте имён, то, соответственно, не происходит никаких столкновений имён.

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

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

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

Никогда не определяйте или пере-привязывайте специальную форму, макрос или функцию, определённую в Common Lisp.

Некоторые, отличающиеся от Common Lisp, Лиспы, такие как Scheme, обладают неудачным свойством комбинирования пространства имён переменных с пространством имён функций/макросов. Иногда эти Лиспы называются lisp-1, в отличие от Common Lisp с его разделённым пространством имён, который называется lisp-2. При работе с макросами в гипотетическом lisp-1 нам придётся следовать дополнительным двум правилам:

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

Убедитесь, что добавленная функция или макро привязка не конфликтует с добавленными лексическими или динамическими привязками.

Мудрое архитектурное решение отделения пространства имён переменных от пространства имён функций в Common Lisp устраняет большую часть проблем связанных с нежелательным захватом переменной. Конечно, lisp-1 Лиспы не содержат никаких теоретических барьеров в создании макросов: если мы будем следовать предыдущим двум правилам, то мы можем избежать захвата переменной тем же способом что и в Common Lisp. Однако, отслеживание символов в единственном и изолированном пространстве имён может значительно усложниться при программировании сложных макросов. Присутствие взаимного обмена именами позволяет сделать вывод о том, что процесс создания макросов станет сложнее.

Scheme - замечательный язык, но проблема незавершённого стандарта 12, это также касается единственного пространства имён, перевешивает все достоинства этого языка и делает его непригодным к созданию серьёзных макро конструкций 13. Ричард Гэбриэл и Кент Питман обобщили эту проблему в следующей запоминающейся цитате [LISP2-4LIFE]:

12 Это касается и макросов и исключений.

13 На протяжении всей книги мы увидим что есть множество причин предпочесть Common Lisp, а не Scheme.

Есть две точки зрения на проблему макросов и пространств имён. Первая в том, что единственное пространство имён носит фундаментальный характер, следовательно, макросы являются проблемой. Вторая заключается в том, что макросы - фундаментальны, следовательно, единое пространство имён является проблемой.

Поскольку число пространств имён менее важно возможности конструирования макросов, то можно сделать вывод, что Scheme - это неправильный выбор, а Common Lisp - это правильный выбор.

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

Эта книга представляет новый синтаксис для gensym'ов, преимущество этого синтаксиса в том, что он до разумных размеров короче чем традиционные Лисп выражения. Наш новый синтаксис для gensym'ов, который будет использоваться как основа для большинства макросов в этой книге, можно более точно охарактеризовать как снятие слоёв с простого макроса, использующего наш синтаксис. Продолжим рассматривать пример nif, с предыдущего раздела. Вот как Грэм определяет захвато-безопасный nif:

(defmacro nif (expr pos zero neg)
(let ((g (gensym)))
‘(let ((,g ,expr))
(cond ((plusp ,g) ,pos)
((zerop ,g) ,zero)
(t ,neg)))))

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

(with-gensyms (a b c)
. . .)

Расширяется в

(let ((a (gensym))
(b (gensym))
(c (gensym) ))
. ..)

Поскольку gensym очень широко используется в форме defmacro, мы решили продолжить дальнейшее сокращение. В частности, заметьте, что для каждого gensym'а мы печатаем временное имя (такое, как a, b и c) по крайней мере дважды: первый раз когда мы объявляем его gensym'ом и другой раз, когда используем его. Можем ли мы устранить эту избыточность?

Для начала рассмотрим как макрос nif использует gensym'ы. При расширении макроса nif происходит вызов gensym, который возвращает сгенерированный символ. Поскольку гарантируется уникальность этого символа, мы можем безопасно использовать его в расширении макроса, будучи уверенными в том, что этот символ никогда не будет захвачен через какую-либо непреднамеренную ссылку. Но, нам по прежнему необходимо как то называть этот gensym в определении макроса для того, чтобы мы могли использовать его в нужных местах в расширении. Грэм, в пределах макроса nif называет этот gensym как g. Учтите, что это имя никогда не появится в макро расширении nif:

* (macroexpand ’(nif x ’pos ’zero ’neg))
(LET ((#:G1605 x))
(COND ((PLUSP #:G1605) ’pos)
((ZEROP #:G1605) ’ZERO)
(T ’NEG)))
T

Название g исчезает в расширении макроса. Поскольку g был привязан только к нашей среде расширителя, имя, данное такой переменной не имеет никакого отношения к захваченному имени в расширениях. Все g в расширении будут замещены символом с именем G1605. Перед именем стоит префикс #: и обозначает, что символ не входит ни в один пакет - это gensym. При печати форм, обязательно следует использовать такие префиксы, поскольку Лисп должен прекратить работу если мы захотим использовать (вычислить) эту форму после его прочтения. Мы должны прервать работу Лиспа по той причине, что невозможно просто глядя на два имена gensym'ов сказать, являются ли они eq'абельными или нет - это остаётся на усмотрении системы. Лисп прерывает работу интересным способом: поскольку каждый раз символ #: считывает новый созданный символ, а поскольку (eq '#:a '#:a) никогда не будет истинным значением, внутренние символы #:G1605 в предыдущем расширении не соответствуют привязкам, созданным формой let, поэтому Лисп считает что выражение обладает свободной переменной, давая нам знать что эта форма обладает gensym'ами.

Несмотря на gensym'овское поведение по-умолчанию, всё же возможно сохранение и перезагрузка макро расширений. Для более точного отображения напечатанных отображений формы с gensym'ами, мы можем включить режим *print-circle* при печати результатов 14:

14 Мы возвращаем t, поэтому мы не видим формы, возвращённой print'ом. Возвращение (values) - это обычное явление.

Листинг 3.5: G-BANG-SYMBOL-PREDICATE
(defun g!-symbol-p (s)
(and (symbolp s)
(> (length (symbol-name s)) 2)
(string= (symbol-name s)
"G!"
:start1 0
:end1 2)))

* (let ((*print-circle* t))
(print
(macroexpand ’(nif x ’pos ’zero ’neg)))
t)
(LET ((#1=#:G1606 x))
(COND ((PLUSP #1#) ’pos)
((ZEROP #1#) ’ZERO)
(T ’NEG)))
T

В вышеприведённой форме Лисп принтер использует считывающие макросы #= и ##. Эти считывающие макросы позволяют нам создать формы, ссылающиеся на самих себя (self-referential), более подробно об этих формах мы поговорим в разделе 4.5, Циклические Выражения. Если мы прочитаем предыдущую форму, символы, используемые внутри будут теми же самыми, что и символы, используемые в привязке let и поэтому выражение будет работать. Кажется, что в вышеприведённом выражении удалось избежать избыточности с двойным именованием. Есть ли способ добавления этого способа в шаблон макроса, пишущего макросы?

Помните, что в определении макроса мы можем именовать наши gensym'ы во что угодно, даже, как это сделал Грэм, в простые имена, такие как g, и они исчезнут при расширении макроса. Так как у нас есть свобода в именовании, то следует как-то стандартизировать соглашение об именовании gensym'ов. В виде компромисса между краткостью и уникальностью, считается что любой символ, начинающийся с двух символов G! и последующим после них одним или более символами рассматривается как специальный ссылающийся gensym'овский символ называемый G-банг символ (G-bang symbol). Мы определили предикат, g!-symbol-p, определяющий является ли переданный атом G-банг символом.

Листинг 3.6: DEFMACRO-WITH-G-BANG
(defmacro defmacro/g! (name args &rest body)
(let ((syms (remove-duplicates
(remove-if-not #’g!-symbol-p
(flatten body)))))
‘(defmacro ,name ,args
(let ,(mapcar
(lambda (s)
‘(,s (gensym ,(subseq
(symbol-name s)
2))))
syms)
,@body))))

Теперь, после того как мы стандартизировали G-bang символы, мы можем создать макрос, пишущий для нас макро определения и использующий сокращение написания макроса известное как автоматические gensym'ы (automatic gensyms). Макрос defmacro/g! определяет предметно-ориентированный язык, предназначенный для решения задачи по написанию макросов, но в этом языке удаётся сохранить всю мощь Лиспов. Defmacro/g! прост, но его использование и то, как он работает может быть не очевидным. По этой причине, а также по причине того, что это первый настоящий макрос впервые представленный вам в этой книге, мы не спеша проанализируем defmacro/g!.

При исследовании любого макроса, первый шаг, который вам следует сделать - это остановиться. Не думайте о макросе как о трансформации синтаксиса или любой другой абстракции. Думайте о макросе как о функции. В основе макроса лежит функция и макрос работает таким же способом. Функция - это заданные не вычисленные выражения, переданные как аргументы и ожидается возвращённый код для Лиспа с последующей вставкой в другие выражения.

Итак, размышляя о defmacro/g! как о функции, рассмотрим его исполнение. Поскольку мы программируем обычную Лисп функцию, то это значит что мы имеем доступ ко всем особенностям Лиспа и даже утилитам, добавленными позже к языку. В defmacro/g! мы используем утилиту Грэма flatten, Лисповские функции remove-if-not и remove-duplicates и наш предикат G-bang символа g!-symbol-p, для создания нового списка состоящего из всех G-bang символов, найденных внутри тела формы, переданной в наш макрос. Дальше мы используем шаблон обратную кавычку для того, чтобы вернуть список представляющий код, в который нам нужно расширить макрос. Поскольку в нашем случае мы пишем улучшение для defmacro, то нам нужно чтобы наш код самостоятельно расширился в форму defmacro. Но, мы добавили новые удобные особенности в язык defmacro и нужно создать чуть более сложное расширение. Для того, чтобы применить gensym к каждому G-bang символу, найденному в теле макроса, мы используем mapcar для применения функции ко всему списку из собранных G-bang символов, создавая новый список, который может быть объединён в форму let, устанавливая привязки для каждого gensym'а 15.

15 Функции gensym может быть передан аргумент в виде единственной строки. Это меняет печатаемое имя gensym'а, что может помочь при чтении расширений. Для этих целей defmacro/g! использует печать имён символов в G-bang символе.

Заметьте как лямбда, используемая в отображении, содержит выражение, созданное с помощью оператора обратной кавычки, эта ситуация должна была бы являться, но не является, вложенной обратной кавычкой (nested backquote). Поскольку mapcar, применяющий эту функцию, не закавычен (unquoted), то не закавыченные выражения во вложенной обратной кавычке по прежнему вычисляются в нашем исходном контексте. Вложенные обратные кавычки известны своей чрезвычайной сложностью в освоении и мы вернёмся к рассмотрению этой концепции в главе 4, Считывающие Макросы.

Итак, что конкретно позволяет нам выполнять defmacro/g!? Он позволяет нам использовать технику автоматических gensym'ов - способ проверки существования определённых символов в лексической области видимости кода, переданного макросу 16. Если мы не будем использовать никаких G-bang символов, то defmacro/g! будет работать также, как и defmacro. Но, появление любого G-bang символа в теле расширения макроса будет интерпретироваться следующим образом:

16 На данный момент это просто упрощение. Читайте раздел о суб-лексической области видимости.

Я хочу, чтобы была создана привязка вокруг этого выражения и я уже дал символ. Сделай это.

Мы можем использовать defmacro/g! для явного создания gensym'а в таком определении nif:

(defmacro/g! nif (expr pos zero neg)
‘(let ((,g!result ,expr))
(cond ((plusp ,g!result) ,pos)
((zerop ,g!result) ,zero)
(t .neg))))

Когда нам нужно использовать gensym мы просто его используем. Конечно, нам нужно соблюдать осторожность, потому что все ссылки на G-bang символы вычисляются только макро расширением, поскольку это единственное место, где привязан gensym 17. Разкавычивание G-bang символов, как это показано выше, происходящее внутри обратной кавычки - наиболее очевидный способ выполнения этой операции и мы можем увидеть в этом параллель с разкавычиванием символа g в исходном определении nif Грэма.

17 G-bang символы не должны появляться в расширении самих себя - именно этого мы пытаемся избежать с помощью gensym'ов.

Мы определили макрос nif, который вроде как должен работать также как и Грэмовский nif, но это улучшение выглядит слишком хорошим чтобы быть правдой. Это действительно работает? Взглянем на макро расширение 18 прежде чем выносить какое-либо суждение:

18 Мы использовали macroexpand-1, благодаря чему произвели единственное расширение макроса defmacro/g! не вызвав последующее расширение defmacro.

* (macroexpand-1
’(defmacro/g! nif (expr pos zero neg)
‘(let ((,g!result ,expr))
(cond ((plusp ,g!result) ,pos)
((zerop ,g!result) ,zero)
(t .neg)))))

(DEFMACRO NIF (EXPR POS ZERO NEG)
(LET ((G!RESULT (GENSYM "RESULT")))
‘(LET ((.G!RESULT ,EXPR))
(COND ((PLUSP ,G!RESULT) ,pos)
((ZEROP ,G!RESULT) ,ZERO)
(T ,NEG)))))
T

Кажется, что defmacro/g! написал почти тот же код что написал Грэм в исходной версии nif. Изучая этот пример использования defmacro/g! мы видим что в этом расширении создаются только gensym привязки. Nif, определённый с помощью defmacro/g! свободен от проблем захвата переменных.

Так как defmacro/g! сам является макросом, могут ли возникнуть проблемы в среде расширения макроса с нежелательным захватом или заменой? Как и в любой достаточно сложной абстракции такое поведение возможно. В том же смысле в каком захват переменной - это недостаток, некоторые особенности defmacro/g! могут оказаться недостатками, существующими в самой архитектуре конструкции 19. Как всегда, самое лучшее решение - это полное понимание абстракции.

19 Хотя не безопасно исключать ошибку самого программиста.

Ещё одним интересным случаем является использование defmacro/g! в G-bang макросе, определяющим G-bang макрос. Defmacro/g! вводит набор привязок в среду расширения, каждая привязка связана с gensym'ом, который при необходимости может использовать макрос. Если возникает такой случай, когда есть несколько возможных привязок gensym'а, то их всегда можно различить по контексту. Другими словами, вы всегда можете указать какую среду следует использовать gensym'у, основываясь на среде в которой он вычисляется. Вот один синтетический пример:

(defmacro/g! junk-outer ()
‘(defmacro/g! junk-inner ()
‘(let ((,g!abc))
.g!abc)))

Здесь создаётся два gensym'а. Использованию g!abc предшествует только одно разкавычивание (запятая) поэтому мы знаем, что расширение относится к внутреннему gensym'у, созданному с помощью расширения junk-inner. Если же вместо одного разкавычивания было бы два разкавычивания, то они стали бы относиться к gensym'у созданному в расширении junk-outer.

Defmacro/g! использует функцию Грэма flatten. Flatten, как было описано в разделе 1.3, Лисп Утилиты, получает дерево cons структуры - наш Лисп код - и возвращает новый список из всех листьев/атомов. Использование flatten в defmacro/g! - это простой пример прохода по коду (code-walking), тема, к которой мы будем возвращаться на протяжении этой книги.

Упражнение: Что произойдёт в предыдущем G-bang макросе, определяющим G-bang макрос, если первый gensym будет разкавычен один раз, а другой два раза?

3.6 Once Only

Питер Норвиг - блестящий программист и автор. Его книги по ИИ, особенно Искусственный Интеллект: Современный Подход (Artificial Intelligence: A Modern Approach [AIMA]), обязательны к прочтению всем людям, работающим с наиболее трудными проблемами в области информатики. Скорее всего, Норвиг известен Лисп программистам по книге Парадигмы Программирования Искусственного Интеллекта: Обучающие Примеры на Common Lisp (Paradigms Of Artificial Intelligence Programming: Case Studies in COMMON LISP [PAIP]). И хотя эта книга довольно старая, она обязательна к прочтению всеми серьёзными Лисп студентами, поскольку содержит описание многих важных идей Лиспа 20. Этот раздел посвящён Питеру Норвигу, а название раздела взято из названия макроса, описанного в PAIP. Тему последних нескольких страниц, описывающих реализацию функции последовательности, можно выразить так

20 Один из, вечно актуальных, советов PAIP по Common Lisp следующий: никогда не смешивайте аргументы &optional и &key в деструктурирующих формах lambda или defmacro. Иначе будет больно!

Once-only: Урок Макрологии

Далее следует чрезвычайно интригующее выражение:

Если вы можете понять как писать и когда использовать once-only, то это значит что вы полностью освоили макросы.

Как мы уже знаем, никто не в состоянии полностью понять макросы. Понимание одного макроса, пусть даже такого важного как once-only приближает вас к пониманию макросов также как и понимание важной теоремы может помочь в полном освоении математики. Поскольку их возможности (математики и макросов) безграничны, то полное понимание математики или макросов абсолютно невозможно.

Здесь мы не будем давать определение once-only Норвига, но, поскольку это сложный макрос с некоторыми интересными свойствами, мы реализуем его несколько по-другому. Изначально once-only был реализован для программной среды канувшей в лету Лисп машины и не был внесён в Common Lisp по несущественным причинам.

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

В качестве примера once-only Норвиг приводит макрос square. Его расширение получает один аргумент и возвращает результат умножения этого аргумента на самого себя:

(defmacro square (x)
‘(* ,x ,x))

Такой подход будет работать со многими вещами: большинство переменных, чисел и других форм, которые могут свободно вычисляться столько раз, сколько это необходимо. Но, стоит только передать этой версии square формы с побочными эффектами (side-effects), как все ставки будут отменены. Конечно, поведение по прежнему будет детерминированным, но теперь трудно предсказать к чему оно приведёт. При работе с этим макросом, форма будет вычислена дважды. Но, поскольку эти сущности усложняются очень быстро, то в целом, все ставки будут отменены. Назначение once-only - создание удобства и возможность избежать этих нежелательных побочных эффектов. Заметьте, что если бы мы использовали функцию, то такое поведение досталось бы нам бесплатно. Мы уйдём с области надуманных книжных примеров, опустимся на грешную землю и определим square в виде функции, функция будет выглядеть примерно так:

(defun square (x)
(* x x))

Мы можем использовать любую форму в качестве аргумента в этом определении функции square. Поскольку этот аргумент будет вычисляться только один раз, то будут удовлетворены наши представления и концептуальные модели побочных эффектов. Во многих примерах мы ожидаем, что выражение, написанное нами один раз будет вычислено только единожды. С другой стороны, одна из составляющих частей макроса - нарушение этого предположения с помощью манипулирования частотой и порядком выполнения вычисления. Например, в таких вещах как циклы (loops), мы можем захотеть вычисления выражений более одного раза. Кроме того, мы даже можем захотеть чтобы эти выражения вообще не вычислялись, поскольку нам может понадобиться от них что-то другое, а не результат вычисления.

Once-only позволяет нам указать конкретные параметры в нашем макро расширении, которые должны вычисляться только один раз с порядком вычисления слева-направо, как в лямбде. Вот как будет выглядеть макрос square вкупе с макросом once-only:

(defmacro square (x)
(once-only (x)
‘(* .x .x)))

Конечно, если вы хотите подвергнуть once-only все аргументы вашего макроса, то в этом случае взамен макроса следует использовать функцию (лямбду). Мы ещё вернёмся к этому вопросу, но, поскольку эта книга не содержит прямой реализации once-only, то мы введём альтернативную реализацию этой функциональности в нашу нотацию макроса. Хотя существуют множество интересных реализаций once-only [PAIP-P853] [PRACTICAL-CL-P95], в этом разделе мы введём новую технику, созданную в комбинации с defmacro/g!.

Первый шаг в нашей реализации once-only - это создание некоторых новых предикатов и функций-утилит. С целью нахождения компромисса между краткостью и уникальностью мы зарезервируем другой набор символов для нашего персонального использования. Все символы начинающиеся с сочетания символов O! называются O-bang символами.

Предикат, используемый для того, чтобы отличать O-bang символы от других объектов будет называться так: o!-symbol-p. Его определение очень близко к g!-symbol-p. Кроме того мы создадим удобную функцию-утилиту для смены O-bang символа на G-bang символ, сохраняя остальные символы после bang'а: o!-symbol-to-g!-symbol. Эта функция-утилита будет использовать удобную функцию-утилиту Грэма symb для создания новых символов.

Листинг 3.7: O-BANG-SYMBOLS
(defun o!-symbol-p (s)
(and (symbolp s)
(> (length (symbol-name s)) 2)
(string= (symbol-name s)
"O!"
:start1 0
:end1 2)))

(defun o!-symbol-to-g!-symbol (s)
(symb "G!"
(subseq (symbol-name s) 2)))

Листинг 3.8: DEFMACRO-BANG
(defmacro defmacro! (name args &rest body)
(let* ((os (remove-if-not #’o!-symbol-p args))
(gs (mapcar #’o!-symbol-to-g!-symbol os)))
‘(defmacro/g! ,name ,args
‘(let ,(mapcar #’list (list ,@gs) (list ,@os))
.(progn .@body)))))

Defmacro! представляет последний шаг в нашем языке определения макросов - он добавляет свойство once-only. Defmacro! комбинируется с defmacro/g! из предыдущего раздела. Поскольку defmacro! расширяется прямо в форму defmacro/g!, то происходит наследование (inherits) поведения автоматических gensym'ов. Для создания сложных сочетаний очень важно понимать работу всех скомбинированных частей. Напомним, что defmacro/g! ищет символы начинающиеся с G-bang и автоматически создаёт gensym'ы. Расширяясь в форму с G-bang символами, defmacro! может избежать дублирования gensym'а при реализации once-only.

Defmacro! создаёт сокращение известное как автоматический once-only. С помощью автоматического once-only мы можем предварить один или более символов в аргументах макроса O-bang'ом, таким образом создав O-bang символы определённые с помощью o!-symbol-p. Благодаря этому defmacro! понимает что мы хотим создать привязку в создаваемом коде. Эта привязка будет содержать результат вычисления кода, переданного в виде аргумента макроса. Эта привязка будет доступна расширению макроса через gensym. Но как нам сослаться на gensym при создании расширения? Через использование эквивалентного G-bang символа определённого с помощью o!-symbol-to-g!-symbol.

Реализация зависит от возможностей defmacro/g!. С помощью утилиты o!-symbol-to-g!-symbol мы создаём новые G-bang символы для добавления в форму defmacro/g!. Поскольку у нас уже есть автоматические gensym'ы, то once-only легко реализовать, об этом свидетельствует краткость определения defmacro!.

Вернёмся на короткое время на землю синтетических книжных примеров и реализуем макрос square, на этот раз с помощью defmacro!:

(defmacro! square (0!x)
‘(* ,g!x ,g!x))

Применим macroexpand к этому выражению:

* (macroexpand
’(square (incf x)))
(LET ((# X1633 (INCF x)))
(* #:X1633 #:X1633))
T

В предыдущем разделе я упомянул что мы передаём строковое значение в gensym для всех G-bang символов. Это значительно облегчает изучение подобных форм. Также нет ничего удивительного в таком имени gensym'ов как #:X1633, если бы мы отладили определение defmacro! в последнем square, то обнаружили бы связь между этим символом и символом, используемом в определении макроса: X. Возможность сравнивания символов определения и расширения и наоборот становится проще если информация находится в печатаемых именах используемых gensym'ов, как это было сделано в расширениях defmacro/g! 21.

Это же является причиной возникновения чисел в печатаемых именах gensym'а, числа определяются с помощью *gensym-counter*. Этот счётчик позволяет нам различать экземпляры gensym'ов с одинаковыми печатаемыми именами.

Помимо менее выразительного использования и более удобного вывода расширения по сравнению с традиционным once-only, в defmacro! есть ещё одна ключевая особенность. В обычном once-only привязка gensym используется для получения доступа к созданной лексической переменной с тем же именем, что и аргумент макро расширения, перекрывающие (shadows) аргументы макроса, в результате, к ним нельзя получить доступ из определения макроса. Поскольку defmacro! разделяет это на два различных типа символов, G-bang символы и O-bang символы, то мы можем писать макро расширения, использующие оба значения. В качестве примера, вот ещё одно определение макроса square:

(defmacro! square (o!x)
‘(progn
(format t "[~a gave ~a]~%"
’,o!x ,g!x)
(* ,g!x ,g!x)))

Это может использоваться так:

* (defvar x 4)
X
* (square (incf x))
[(INCF x) gave 5]
25

Обратите внимание: мы закавычиваем разкавыченный O-bang символ в последнем определении square. Мы делаем это потому, что нам не нужно вычислять эту форму снова. Расширение сгенерированное от defmacro! уже вычислило его. Мы просто хотим получить форму, переданную в square и использовать её по другому назначению, в данном случае мы используем её в роли сырого отладочного выражения. Однако, даже если выражение уже было вычислено один раз, ничто не мешает нам вычислить эту форму ещё раз если этого требуют наши абстракции.

Листинг 3.9: NIF
(defmacro! nif (o!expr pos zero neg)
‘(cond ((plusp ,g!expr) .pos)
((zerop ,g!expr) ,zero)
(t ,neg)))

Язык defmacro! позволяет нам осуществлять удобный, гранулированный контроль над вычислением аргументов, переданных в наши макросы. Если мы добавим префиксы в виде O-bang ко всем символам, представляющим аргументы в макро определении, а в макро определении будем использовать только соответствующие G-bang символы, то наши выражения будут теми же лямбда выражениями - каждая форма вычисляется только один раз, слева направо. Без этих символов в args и без использования G-bang символов в расширении, defmacro! будет работать также, как и обычный defmacro Common Lisp'а.

Defmacro! - наиболее удобен при итеративной разработке макросов. Поскольку мы простым добавлением двух символов в аргумент макроса получаем вычисление в лямбда стиле, а использование gensym'ов становится таким же лёгким как написание этого слова, то мы можем легко менять наше представление о макросе. Defmacro! можно представить как более плотно подогнанную оболочку над лямбдой, чем Common Lisp'овский defmacro. По этим причинам в итеративной разработке мы будем использовать defmacro! в качестве главного определителя макросов на протяжении всей оставшейся части книги.

Вернёмся с макросу nif Грэма. При переписывании этого макроса с использованием defmacro! мы упомянули, что аргумент expr, для которого мы создавали gensym вычисляется только один раз. Здесь мы использовали defmacro! для того, чтобы этот аргумент вычислялся только один раз назвав аргумент как o!expr. Эта реализация nif представляет последний шаг в нашей эволюции этого макроса.

Defmacro! размывает границу между макросом и функцией. Именно эта особенность, возможность использования некоторых O-bang символов в аргументах макроса наряду с обычными символами делает defmacro! особенно полезным. Как обратная кавычка позволяет вам перевернуть поведение закавычивания, также defmacro! позволяет вам перевернуть семантики вычисления аргументов макроса из обычных не вычисляемых макро форм в однократно вычисляемые справа налево лямбда аргументы.

3.7 Двойственность Синтаксиса

Одна из наиболее важных концепций Лиспа называется двойственностью синтаксиса. Понимание того, как использовать эту двойственность и почему эта двойственность так важна - общая тема создания макросов и этой книги. Эта двойственность отчасти была спроектирована, а отчасти случайно открыта. Для программистов на не-Лисп языках реальность двойственного синтаксиса будет очень нереальной, поэтому на данный момент мы уклонимся от прямого определения этого явления. Для того, чтобы избежать шока вы, читатель, будете изучать её шаг за шагом, постепенно и неторопливо. Если вы при чтении этой книги будете испытывать какой-либо дискомфорт и/или головную боль, то я рекомендую вам немедленно выполнить цикл сборки мусора (немного поспать), и только потом вернуться к чтению с ясным и незамутнённым умом.

Ссылочная прозрачность (Referential transparency) иногда определяется как следующее свойство кода: любое выражение может быть вставлено куда угодно и везде это выражение будет иметь один и тот же смысл. Вводя синтаксическую двойственность мы искусственно нарушаем ссылочную прозрачность и получаем плоды новых возможностей, от языка, в котором возможны такие нарушения. В то время когда другие языки позволяют вам создавать полупрозрачные стеклянные панели, Лисп позволяет использовать набор из дыма, зеркал и призм. Магическая пыль создаёт макросы и самые лучшие трюки макросов основаны на синтаксической двойственности.

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

Цель динамической области видимости - это предоставление способа получения значений внутри и из Лисп выражений, основанном на вычислении выражения, а не местом где это выражение было определено или скомпилировано. Это возможно благодаря синтаксису, с помощью которого Common Lisp определяет идентичность, используемую для получения доступа к лексическим переменным, являющимися прямой противоположностью динамическим переменным в том, что они всегда относятся к тем местам, в которых они были скомпилированы, будучи независимыми от места использования. На самом деле без внешнего контекста в форме определения вы не можете ничего сказать о типе переменной, на которую ссылается выражение. Двойственный синтаксис нарушает ссылочную прозрачность, но вместо того, чтобы пытаться избежать эту, казалось бы, проблему, Лисп программисты приветствуют её, поскольку также как вы не можете различать выражение без контекста, также вы не можете писать макросы без двойственности. Поразмыслите секунду над этой мыслью. Это объясняет почему создание привязок для динамических переменных не создаёт лексических замыканий. В качестве примера привяжем снова переменную temp-special, которую ранее мы объявили специальной:

* (let ((temp-special ’whatever))
(lambda () temp-special))
#<Interpreted Function>

И хотя мы видим lambda'у окружённую let'ом, это не лексическое замыкание. Это простое вычисление макро формы lambda в некотором динамическом контексте, результатом чего является, конечно же, анонимная функция. Эта функция, при её применении, будет работать в текущей динамической среде и будет получать значение temp-special. При вычислении макроса lambda существовала динамическая привязка temp-special к символу whatever, но кому это будет интересно? Помните что lambda формы - это объекты-константы, всего лишь простой машинный код, возвращающий указатели, поэтому вычисление этой lambda формы никогда не получит доступа к динамической среде. Что случится с нашим символом whatever? После вычисления лямбда формы Лисп посчитает символ whatever не нужным и удалит его из динамической среды.

Некоторые ранние Лиспы поддерживали динамические замыкания, это обозначало что каждая функция определялась в не-null'евой динамической среде, обладавшей своим (возможно частично общим) стеком динамических привязок. Это был эффект, похожий на Common Lisp'овскую лексическую область видимости и реализовался с помощью так называемого спагетти стека [SPAGHETTI-STACKS] [INTERLISP-TOPS20]. Эта структура уже не представляла из себя стековую структуру данных, но была скорее множеством путей, этакой собирающей мусор сетью. Common Lisp не использует спагетти стеки и предоставляет только лексические замыкания [MACARONI].

Таким образом, лексические и динамические переменные - это абсолютно различные концепции, с одним синтаксисом в коде Common Lisp. Зачем нам нужна эта, так называемая, двойственность синтаксиса? Ответ на этот вопрос может понять меньшая часть Лисп программистов, но этот ответ настолько фундаментален, что заслуживает пристального изучения. Эта двойственность синтаксиса позволяет нам писать макросы, имеющие общий, единый интерфейс для создания расширений, полезных и в динамическом и в лексическом контексте. И хотя значение расширений макроса может абсолютно различаться в зависимости от заданного контекста и каждое расширение может иметь в своём основании абсолютно разные вещи, мы по прежнему можем использовать тот же самый макрос и те же самые комбинации этого макроса с другими макросами. Другими словами макросы могут быть двойственными не только по отношению к аргументам макросов, но также обладать различными значениями по отношению к их расширениям. Мы можем использовать макрос только ради понимания трансформации кода, игнорируя семантическое обозначение кода, всё это потому, что код имеет значение только тогда, когда мы его где то используем - и не имеет значения при работе макроса. Чем больше двойственность синтаксиса, тем более мощным становится макрос. На протяжении этой книги вы увидите много примеров, в деталях показывающих преимущества двойственного синтаксиса. Двойственность между динамическими и лексическими переменными является не очень выразительным (но полезным) примером Лисп философии. Некоторые макросы были созданы на основе мощи двойственности и предназначены для специфических целей, иногда в расширениях таких макросов могут быть заложены более чем два различных обозначения.

Согласно традиционному соглашению в Common Lisp имена специальных переменных следует предварять и завершать символами астериска. Например, для нашей переменной temp-special мы должны выбрать имя *temp-special*. Поскольку это соглашение выглядит как обладание другим пространством имени для динамических переменных тем самым снижая двойственность с лексическими переменными, то в этой книге не выдерживается строгое следование данному соглашению. Астериски - это всего лишь соглашение и, к счастью, Common Lisp не строго следует ему. Мы можем не только не использовать астериски в именах специальных переменных, но даже использовать астериски в именах лексических переменных. Возможно это вопрос стиля. Что будет менее преступным с точки зрения моды: лексические переменные с астерисками или специальные переменные без астерисков? Я склоняюсь к более короткому варианту. Кроме того, имена лексических и специальных переменных могут быть gensym'ами, концепцией, выводящей печать имён за пределы символов.

Как уже упоминалось эта книга нарушает традиционное соглашение об астерисках. Вместо

Имя переменной с астериском обозначает специальную переменную.

эта книга использует

Имена переменных с астерисками обозначают специальные переменные, определённые по стандарту.

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

Переведено на Нотабеноиде
http://notabenoid.org/book/40014/160636

Переводчики: Charlz_Klug

Настройки

Готово:

100.00% КП = 1.0