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

20. Специальные операторы

Между прочим, наиболее впечатляющим аспектом условной системы, описанной в предыдущей главе, является то, что если бы она не была частью языка, то она могла бы быть полностью написана в виде отдельной библиотеки. Это возможно, поскольку специальные операторы Common Lisp (когда никто не осуществляет прямой доступ к обработке или выдаче условий) обеспечивают достаточный уровень доступа к низкоуровневым частям языка, что делает возможным контроль раскрутки стэка (unwinding of the stack).

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

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

Контроль вычисления

К первой категории специальных операторов относятся три оператора, которые обеспечивают базовый контроль вычисления выражений. Это QUOTE, IF и PROGN, про которые я уже рассказывал. Однако было бы неправильным не отметить то, как каждый из этих специальных операторов предоставляет контроль за вычислением одной или нескольких форм. QUOTE предотвращает вычисление выражения и позволяет вам получить s-выражение в виде данных. IF реализует базовый оператор логического выбора, на основе которого могут быть построены все остальные условные выражения.1) А PROGN обеспечивает возможность вычисления последовательности выражений.

Манипуляции с лексическим окружением

Наибольший класс специальных операторов содержит операторы, которые манипулируют и производят доступ к лексическому окружению. LET и LET*, которые мы уже обсуждали, являются примерами специальных операторов, которые манипулируют лексическим окружением, поскольку они вводят новые лексические связи для переменных. Любая конструкция, такая как DO или DOTIMES, которая связывает лексические переменные будет развернута в LET или LET*.2) Специальный оператор SETQ является одним из операторов для доступа к лексическому окружению, поскольку он может быть использован для установки значений переменных, чьи связи были созданы с помощью LET и LET*.

Однако, не только переменные могут быть поименованны внутри лексического окружения. Хотя большинство функций и определены глобально с использованием DEFUN, но все равно возможно создание локальных функций с помощью специальных операторов FLET и LABELS, локальных макросов с помощью MACROLET, а также специальных видов макросов (называемых символьными макросами) с помощью SYMBOL-MACROLET.

Точно также как и LET позволяет вам ввести переменную, чьей областью видимости будет тело LET, FLET и LABELS позволяют вам определить функцию, которая будет видна только внутри области видимости FLET или LABELS. Эти специальные операторы являются очень удобными, если вам нужна локальная функция, которая является слишком сложной для ее определения как LAMBDA, или если вам нужно вызвать ее несколько раз. Оба этих оператора имеют одинаковую форму, которая выглядит так:

(flet (function-definition*)
  body-form*
)

или так:

(labels (function-definition*)
  body-form*
)

где каждая из function-definition имеет следующую форму:

(name (parameter*) form*)

Разница между FLET и LABELS заключается в том, что имена функций, которые определены с помощью FLET, могут использоваться только в теле FLET, в то время как имена, определенные с помощью LABELS могут использоваться сразу, включая тела функций, определенных с помощью LABELS. Таким образом, LABELS может определять рекурсивные функции, а FLET – не может. Может показаться ограничением то, что FLET не может быть использован для определения рекурсивных функций, но Common Lisp предоставляет и FLET и LABELS по той причине, что иногда бывает полезным иметь возможным написать локальную функцию, которая может вызвать другую функцию с тем же именем, либо глобальную, либо определенную в охватывающей области видимости.

Внутри тела FLET или LABELS, вы можете использовать имена определенных функций, точно также как и имена любых других функций, включая использование со специальным оператором FUNCTION. Поскольку вы можете использовать FUNCTION для получения объекта-функции, представляющего функцию, определенную с помощью FLET или LABELS, и поскольку FLET и LABELS могут быть в области видимости других связывающих форм, таких как LET, то эти функции могут использоваться как замыкания (closures).

Поскольку локальные функции могут ссылаться на переменные из охватывающего окружения, то они могут часто записываться таким образом, чтобы принимать меньше параметров, чем эквивалентные вспомогательные функции. Это очень удобно, когда вам необходимо в качестве параметра-функции передать функцию, которая принимает единственный аргумент. Например, в следующей функции, которую вы увидите снова в главе 25, функция count-version, определенная с помощью FLET, принимает единственный аргумент, как этого требует функция walk-directory, но она также может использовать переменную versions, заданную охватывающим LET:

(defun count-versions (dir)
  (let ((versions (mapcar #'(lambda (x) (cons x 0)) '(2 3 4))))
    (flet ((count-version (file)
             (incf (cdr (assoc (major-version (read-id3 file)) versions)))
)
)

      (walk-directory dir #'count-version :test #'mp3-p)
)

    versions
)
)

Эта функция также может быть записана с использованием анонимной функции вместо использования FLET, но задание имени делает исходный текст более понятным.

И когда вспомогательная функция должна быть рекурсивной, она не может быть анонимной.3) Когда вам не нужно определять рекурсивную вспомогательную функцию как глобальную функцию, то вы можете использовать LABELS. Например, следующая функция, collect-leaves, использует рекурсивную вспомогательную функцию walk для прохода по дереву и сбора всех объектов дерева в список, который затем возвращается collect-leaves (после его реверсирования):

(defun collect-leaves (tree)
  (let ((leaves ()))
    (labels ((walk (tree)
               (cond
                 ((null tree))
                 ((atom tree) (push tree leaves))
                 (t (walk (car tree))
                    (walk (cdr tree))
)
)
)
)

      (walk tree)
)

    (nreverse leaves)
)
)

Снова отметьте, как внутри функции walk вы можете ссылаться на переменную leaves, объявленную окружающим LET.

FLET и LABELS также являются полезными при раскрытии макросов – макрос может раскрываться в код, который содержит FLET или LABELS для создания функций, которые могут быть использованы внутри тела макроса. Этот прием может быть использован либо для введения функций, которые будет вызывать пользователь макроса, либо для организации кода, генерируемого макросом. Это может служить примером того, как может быть определена функция, такая как CALL-NEXT-METHOD, которая может быть использована только внутри определения метода.

К той же группе, что FLET и LABELS, можно отнести специальный оператор MACROLET, который вы можете использовать для определения локальных макросов. Локальные макросы работают также, как и глобальный макросы, определенные с помощью DEFMACRO, за тем исключением, что они не затрагивают глобальное пространство имен. Когда вычисляется MACROLET, то выражения в теле вычисляются с использованием локального определения макроса, которое возможно скрывает глобальное определение, а также используя локальные определения из окружающих выражений. Подобно FLET и LABELS, MACROLET может использоваться напрямую, но оно также очень удобно для использования кодом, сгенерированным макросом – путем обертки в MACROLET некоторого кода, написанного пользователем, макрос может предоставлять конструкции, которые могут быть использованы только внутри этого кода, или скрывать глобально определенный макрос. Вы увидите примеры использования MACROLET в главе 31.

В заключение, еще одним специальным оператором для определения макросов является SYMBOL-MACROLET, который определяет специальный вид макросов, называемых символьными макросами (symbol macro). Символьные макросы аналогичны обычным, за тем исключением, что они не могут принимать аргументы, и их используют как обычный символ, а не в листовой записи. Другими словами, после того, как вы определили символьный макрос с некоторым именем, любое использование этого символа как значения будет раскрыто, и вместо него будет вычислена соответствующая форма. Это как раз относится к тому, как макросы, такие как WITH-SLOTS и WITH-ACCESSORS получают возможность определения "переменных", которые осуществляют доступ к состоянию определенного объекта. Например, следующее выражение WITH-SLOTS:

(with-slots (x y z) foo (list x y z)))

может быть раскрыто в код, который использует SYMBOL-MACROLET:

(let ((#:g149 foo))
  (symbol-macrolet
      ((x (slot-value #:g149 'x))
       (y (slot-value #:g149 'y))
       (z (slot-value #:g149 'z))
)

    (list x y z)
)
)

Когда вычисляется выражение (list x y z), то символы x, y и z будут раскрыты в соответствующие формы, такие как (slot-value #:g149 'x).4)

Символьные макросы наиболее часто используются локально и определяются с помощью SYMBOL-MACROLET, но Common Lisp также предоставляет макрос DEFINE-SYMBOL-MACRO, который определяет глобальный символьный макрос. Символьный макрос, определенный с помощью SYMBOL-MACROLET скрывает другие макросы с тем же именем, определенные с помощью DEFINE-SYMBOL-MACRO или охватывающих выражений SYMBOL-MACROLET.

Локальный поток управления

Следующие четыре специальных оператора, о которых я буду говорить, также создают и используют имена в лексическом окружении, но для целей изменения контроля потока, а не для определения новых функций и макросов. Я упоминал ранее все четыре из этих специальных операторов, потому что они предоставляют низкоуровневые механизмы, используемые для других особенностей языка. Вот они: BLOCK, RETURN-FROM, TAGBODY, и GO. Первые два, BLOCK и RETURN-FROM, используются вместе для написания кода, который совершает выход немедленно из секции кода – я говорил про RETURN-FROM в Главе 5, как о способе немедленного выхода из функции, но он работает и в более общих случаях, чем тот. Два другие, TAGBODY и GO, предоставляют вполне низкоуровневую goto конструкцию, которая составляет основу для всех высокоуровневых конструкций цикла, которые вы уже видели.

Общий скелет формы BLOCK таков:

(block name
  form*
)

name является символом и form*, это формы языка. Формы выполняются по порядку и значение последней формы возвращается как значение всего BLOCK, если не использован RETURN-FROM для возврата из блока ранее. RETURN-FROM форма, как вы видели в Главе 5, состоит из имени блока, из которого выходят, и, по желанию, формы, которая предоставляет возвращаемое значение. Когда RETURN-FROM выполняется, это является причиной немедленного выхода из упомянутого в нём BLOCK. Если RETURN-FROM вызван с формой, возвращающей значение, BLOCK вернёт это значение; в ином случае BLOCK вернёт NIL.

Именем для BLOCK может быть любой символ, включая NIL. Многие из стандартных макросов конструкций контроля, таких как DO, DOTIMES и DOLIST, генерируют расширение, состоящее из BLOCK, названного NIL. Это позволяет вам использовать макрос RETURN, который скорее является синтаксическим сахаром для (return-from nil ...), чтобы прерывать такие циклы. Так, следующий цикл напечатает не более десяти случайных чисел, остановившись сразу же, как только достигнет числа большего чем 50:

(dotimes (i 10)
  (let ((answer (random 100)))
    (print answer)
    (if (> answer 50) (return))
)
)

Задающие функции макросы, такие как DEFUN, FLET и LABELS, с другой стороны, оборачивают свои тела в BLOCK с тем же именем, что и функция. Вот почему вы можете пользоваться RETURN-FROM для возврата из функции.

TAGBODY и GO имеют такое же отношение друг к другу, как BLOCK и RETURN-FROM: TAGBODY задаёт контекст в котором определены имена, используемые GO. Скелет TAGBODY следующий:

(tagbody
  tag-or-compound-form*
)

где каждая tag-or-compound-form, это или символ, называемый тег, или непустая списочная форма. Форма выполняется по порядку и теги игнорируются, кроме случая, о котором я скоро скажу. После выполнения последней формы в TAGBODY, TAGBODY возвращает NIL. В любом месте внутри лексической области видимости TAGBODY вы можете использовать специальный оператор GO для немедленного перехода на любой тег и выполнение продолжится с формы, следующей за тегом. Например, вы можете написать простейший бесконечный цикл с TAGBODY и GO вроде этого:

(tagbody
 top
   (print 'hello)
   (go top)
)

Заметьте, что в то время, как имена тегов должны появляться на верхнем уровне в TAGBODY, не заключёнными внутри форм, специальный оператор GO может появляться где угодно внутри области видимости TAGBODY. Это означает, что вы можете написать цикл, который выполняется случайное число раз, например так:

(tagbody
 top
   (print 'hello)
   (when (plusp (random 10)) (go top))
)

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

(tagbody
 a (print 'a) (if (zerop (random 2)) (go c))
 b (print 'b) (if (zerop (random 2)) (go a))
 c (print 'c) (if (zerop (random 2)) (go b))
)

Эта форма будет прыгать вокруг случайно печатаемых a,b и c до тех пор, пока RANDOM не вернёт 1, и управление наконец достигнет конца TAGBODY.

TAGBODY редко используется прямо, так как почти всегда удобней писать итеративные конструкции в терминах существующих циклических макросов. Однако он становится удобным для перевода алгоритмов, написанных на других языках в Коммон Лисп либо автоматически, либо вручную. Примером инструмента автоматического перевода является транслятор FORTRAN-to-Common Lisp, f2cl, который переводит исходный код на Фортране, в исходный код на Коммон Лисп для того, чтобы сделать различные библиотеки из Фортрана доступными для программистов на Коммон Лисп. Так как многие библиотеки Фортрана были написаны до революции структурного программирования, они полны операторов goto. f2cl транслятор может просто переводить такие goto в GO внутри соответствующих TAGBODY.5)

Аналогично, TAGBODY и GO могут быть полезны, когда переводятся алгоритмы, написанные или прозой, или диаграммами переходов – например в классической серии Дональда Кнута "Искусство программирования", он описывает алгоритмы, используя формат "рецептов": step 1, do this; step 2, do that; step 3, go back to step 2; и так далее. Для примера на странице 142, "Искусства программирования", Том 2: Получисленные алгоритмы, 3-е издание (Addison-Wesley, 1998), он описывает Алгоритм S, который вы увидите в Главе 27, в такой форме:

Алгоритм S (Метод выбора последовательности). Для выбора n случайных записей из множества N, где 0 < n <= N.

* S1. [Инициализировать.] Установить t <-- 0, m <-- 0. (В этом алгоритме m
представляет количество записей уже выбранных, а t общее количество
записей которые мы просмотрели.
)


 * S2. [Сгенерировать U.] Сгенерировать случайное число U, равномерно
распределённое между нулём и единицей.

 * S3. [Проверить.] Если (N - t)U >= n - m, то перейти к шагу S5.

 * S4. [Выбрать.] Выбрать следующую запись в последовательность и увеличить
m и t на 1. Если m < n, то перейти к шагу S2; иначе
последовательность закончена и алгоритм завершается.

 * S5. [Пропустить.] Пропустить следующую запись (не включать её в
последовательность
)
, увеличить t на 1, и вернуться к шагу S2.

Это описание может быть легко переведено в Коммон Лисп функцию, после переименования нескольких переменных таким образом:

(defun algorithm-s (n max) ; max это N в алгоритме Кнута
 (let (seen               ; t в в алгоритме Кнута
       selected           ; m в алгоритме Кнута
       u                  ; U в алгоритме Кнута
       (records ()))
     ; список, где мы сохраняем выбранные записи
   (tagbody
     s1
       (setf seen 0)
       (setf selected 0)
     s2
       (setf u (random 1.0))
     s3
       (when (>= (* (- max seen) u) (- n selected)) (go s5))
     s4
       (push seen records)
       (incf selected)
       (incf seen)
       (if (< selected n)
           (go s2)
           (return-from algorithm-s (nreverse records))
)

     s5
       (incf seen)
       (go s2)
)
)
)

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

Сложив все кусочки, вы возможно получите что-то наподобие этого:

(defun algorithm-s (n max)
  (loop for seen from 0
     when (< (* (- max seen) (random 1.0)) n)
     collect seen and do (decf n)
     until (zerop n)
)
)

Хотя это может быть не очевидно, что этот код правильно представляет Алгоритм S, но если вы пришли к нему посредством серии функций, которые вели себя идентично оригинальному дословному переводу рецепта Кнута, у вас есть хорошее основание верить, что он правильный.

Раскрутка стека

Другим интересным аспектом языка является то, что специальные операторы дают вам контроль за поведением стека вызовов функций. Например, хотя вы обычно используете BLOCK и TAGBODY для управления потоком выполнения команд внутри отдельной функции, вы также можете использовать их вместе с замыканиями, для выполнения немедленного нелокального выхода из функции в точку, находящуюся ниже на стеке. Это происходит потому, что имена BLOCK и теги TAGBODY могут быть FIXME closed over by any code внутри лексического окружения BLOCK или TAGBODY. Например, рассмотрим следующую функцию:

(defun foo ()
  (format t "Entering foo~%")
  (block a
    (format t " Entering BLOCK~%")
    (bar #'(lambda () (return-from a)))
    (format t " Leaving BLOCK~%")
)

  (format t "Leaving foo~%")
)

Анонимная функция, переданная bar использует RETURN-FROM для выхода из BLOCK. Но это выражение RETURN-FROM не будет вычислено до тех пор, пока анонимная функция не будет выполнена с помощью FUNCALL или APPLY. Теперь, предположим что bar выглядит следующим образом:

(defun bar (fn)
  (format t "  Entering bar~%")
  (baz fn)
  (format t "  Leaving bar~%")
)

Все равно, анонимная функция не будет вызвана. Теперь посмотрим на baz.

(defun baz (fn)
  (format t "   Entering baz~%")
  (funcall fn)
  (format t "   Leaving baz~%")
)

И в заключение, функция выполняется. Но к чему приведет вызов RETURN-FROM из блока, который находится на несколько уровней выше по стеку? На поверку все работает отлично – стек отматывается к той точке, где BLOCK был создан и контроль возвращается из соответствующего выражения BLOCK. Вызовы FORMAT в функциях foo, bar и baz демонстрируют это:

CL-USER> (foo)
Entering foo
Entering BLOCK
Entering bar
Entering baz
Leaving foo
NIL

Заметьте, что единственным выдаваемым сообщением Leaving . . . является то, которое было вставлено после выражения BLOCK в функции foo.

Поскольку имена блоков имеют лексическую область видимости, RETURN-FROM всегда возвращается из наименьшего охватывающего блока BLOCK в лексическом окружении, в котором задана форма RETURN-FROM, даже если RETURN-FROM выполняется в отличающемся динамическом контексте. Например, bar также может содержать форму BLOCK с именем a, например, вот так:

(defun bar (fn)
  (format t "  Entering bar~%")
  (block a (baz fn))
  (format t "  Leaving bar~%")
)

Это дополнительное выражение BLOCK никак не изменит поведение foo – поиск имен, таких как a, производится лексически, во время компиляции, а не динамически, так что дополнительный блок не влияет на работу RETURN-FROM. И наборот, имя BLOCK может быть использовано только тем выражением RETURN-FROM, которое задано внутри лексической области видимости выражения BLOCK; нет никакой возможности для кода, который определен вне блока, выполнить выход из него, кроме как выполнения замыкания, которая выполнит RETURN-FROM и лексического окружения BLOCK.

TAGBODY и GO работают точно также как и BLOCK и RETURN-FROM. Когда вы выполняете замыкание, которое содержит выражение GO, и GO вычисляется, то стек отматывается назад к соответствующей форме TAGBODY, и затем уже переходит к соответствующему тегу.

Однако, имена блоков BLOCK и теги TAGBODY, достаточно сильно отличаются от связывания лексических переменных. Как обсуждалось в главе 6, лексические связывания имеют неопределенный экстент (FIXME extent), что означает, что связывания не исчезают даже после того, как связывающая форма будет возвращена. С другой стороны, выражения BLOCK и TAGBODY имеют динамический экстент – вы можете выполнить RETURN-FROM из BLOCK или GO из тега TAGBODY только когда BLOCK или TAGBODY находятся на стеке вызова функций. Другими словами, замыкание, которое захватывает имя блока, или тег TAGBODY, может быть передано вниз по стеку, для последующего выполнения, но оно не может быть возвращено вверх по стеку. Если вы выполните замыкание, которое будет пытаться выполнить RETURN-FROM из BLOCK, после того, как BLOCK будет завершен, то вы получите ошибку. Аналогично, попытка выполнить GO для TAGBODY которое больше не существует, также вызовет ошибку.7)

Маловероятно, что вам понадобится использовать BLOCK и TAGBODY для такого способа раскрутки стека. Но вы скорее всего будете использовать этот подход как часть системы условий и рестартов, так что понимание того, как оно работает, поможет вам понять что в точности делается при запуске рестарта.8)

CATCH и THROW являются еще одной парой специальных операторов, которые приводят к раскрутке стека. Вы будете использовать эти операторы еще реже, чем описанные выше – они являются наследием ранних версий Lisp, которые не имели в своем составе систему условий Common Lisp. Они не должны путаться с конструкциями try/catch и try/except из таких языков, как Java и Python.

CATCH и THROW являются динамическими аналогами конструкций BLOCK и RETURN-FROM. Так что вы используете CATCH для какого-то кода и затем используете THROW для выхода из блока CATCH с возвратом указанного значения. Разница заключается в том, что связь между CATCH и THROW устанавливается динамически – вместо лексически ограниченного имени, метка CATCH является объектом, называемым тегом catch, и любое выражение THROW вычисляется внутри динамического экстента CATCH, так что "выбрасывание" (throws) этого объекта будет приводить к раскрутке стека к блоку CATCH и приводить к немедленному возврату. Так что вы можете написать новые версии функций foo, bar и baz используя CATCH и THROW вместо BLOCK и RETURN-FROM:

(defparameter *obj* (cons nil nil)) ; некоторый произвольный объект

(defun foo ()
  (format t "Entering foo~%")
  (catch *obj*
    (format t " Entering ''CATCH''~%")
    (bar)
    (format t " Leaving ''CATCH''~%")
)

  (format t "Leaving foo~%")
)


(defun bar ()
  (format t "  Entering bar~%")
  (baz)
  (format t "  Leaving bar~%")
)


(defun baz ()
  (format t "   Entering baz~%")
  (throw *obj* nil)
  (format t "   Leaving baz~%")
)

Заметьте, что нет необходимости передавать замыкание вниз по стеку – baz может напрямую вызвать THROW. Результат будет таким же как и раньше.

CL-USER> (foo)
Entering foo
Entering ''CATCH''
Entering bar
Entering baz
Leaving foo
NIL

Однако, CATCH и THROW слишком динамичные. И в CATCH, и в THROW, форма, представляющая тег, вычисляется, что означает, что ее значение в обоих случаях определяется во время выполнения. Так что, если некоторый код в bar присвоит новое значение *obj*, то THROW в baz не будет пойман в том же блоке CATCH. Это делает использование CATCH и THROW более тяжелым чем BLOCK и RETURN-FROM. Единственным преимуществом версии foo, bar и baz, которая использует CATCH и THROW, является то, что нет необходимости передавать замыкание вниз по стеку, для возврата из CATCH – любой код, который выполняется внутри динамического экстента CATCH может заставить вернуться к нему, путем "бросания" (FIXME throwing) нужного объекта.

В старых диалектах Lisp в которых не было ничего подобного системе условий Common Lisp, CATCH и THROW использовались для обработки ошибок. Однако, для того, чтобы сделать ее сопровождаемой, теги catch обычно были FIXME quoted symbols, так что вы могли понять, глядя на CATCH и THROW, где они будут перехвачены во время выполнения. В Common Lisp вы будете редко иметь нужду в использовании CATCH и THROW, поскольку система условий намного более гибкая.

Последним специальным оператором, относящимся к управлению стеком вызовов, является UNWIND-PROTECT, который я вскользь упоминал раньше. UNWIND-PROTECT позволяет вам контролировать что происходит при раскрутке стека и быть уверенным в том, что определенный код всегда выполняется, независимо от того, как поток выполнения покидает область видимости UNWIND-PROTECT – обычным способом, путем запуска рестарта, или любым другим способом, описанным в этом разделе.9) Базовая форма UNWIND-PROTECT выглядит примерно так:

(unwind-protect protected-form
  cleanup-form*
)

Сначала вычисляется одиночное выражение protected-form, и затем, вне зависимости от способа его завершения, будут вычислены выражения заданные cleanup-forms. Если protected-form завершается нормальным образом, то его результат будет возвращен UNWIND-PROTECT после вычисления возвратит cleanup-forms. Выражения cleanup-forms вычисляются в том же самом динамическом окружении, что и UNWIND-PROTECT, так что все динамические переменные, связи, перезапуски и обработчики условий, будут доступны коду cleanup-forms, так как они были видны перед выполнением UNWIND-PROTECT.

Вы редко будете использовать UNWIND-PROTECT напрямую. Наиболее часто, вы его будете использовать как основу для макросов в стиле WITH-, таких как WITH-OPEN-FILE, который вычисляет произвольное количество выражений в контексте, где они имеют доступ к некоторому ресурсу, который должен быть освобожден после того, как все выполнено, вне зависимости от того, как выражения были завершены – нормальным образом, или через рестарт или любой другой нелокальный выход. Например, если вы пишете библиотеку для работы с базами данных, которая определяет функции open-connection и close-connection, то вы можете написать вот такой вот макрос:10)

(defmacro with-database-connection ((var &rest open-args) &body body)
  `(let ((,var (open-connection ,@open-args)))
    (unwind-protect (progn ,@body)
      (close-connection ,var)
)
)
)

что позволяет вам писать в следующем стиле:

(with-database-connection (conn :host "foo" :user "scott" :password "tiger")
  (do-stuff conn)
  (do-more-stuff conn)
)

и не беспокоиться о закрытии соединения к базе данных, поскольку UNWIND-PROTECT позаботится о его закрытии, вне зависимости от того, что случится в теле формы with-database-connection.

Множественные значения

Еще одним свойством Common Lisp, которое я упоминал вскользь в главе 11, когда я обсуждал GETHASH, является возможность возвращения множества значений из одного выражения. Сейчас мы обсудим эту функциональность более подробно. Правда не совсем правильно обсуждать эту функциональность в главе про специальные операторы, поскольку этот функционал не реализуется отдельными операторами, а глубоко интегрирован в язык. Операторами, которые вы наиболее часто будете использовать с множественными значениями, являются макросы и функции, а не специальные операторы. Но базовая возможность получения множественных значений обеспечивается специальным оператором MULTIPLE-VALUE-CALL, на основе которого построен более часто используемый макрос MULTIPLE-VALUE-BIND.

Ключом к понимаю множественных значений является тот факт, что возврат множества значений совершенно отличается от возврата списка – если форма возвращает множество значений, то до тех пор пока вы не сделаете что-то специальное для их получения, все значения, кроме первого (основного) будут игнорироваться. Для того, чтобы увидеть это отличие, рассмотрим функцию GETHASH, которая возвращает два значения: найденное значение и логическое значение, которое равно NIL если значение не было найдено. Если бы эти значения возвращались в виде списка, то при каждом вызове GETHASH вам требовалось бы выделять найденное значение из списка, вне зависимости от того, нужно ли вам второе возвращаемое значение или нет. Предположим, что у вас есть хэш-таблица *h*, которая содержит числа. Если бы GETHASH возвращал бы список, то вы бы не могли написать что-то подобное:

(+ (gethash 'a *h*) (gethash 'b *h*))

поскольку + ожидает, что его аргументами будут числа, а не списки. Но поскольку механизм работы с множественными значениями просто игнорирует дополнительные возвращаемые значения, которые вам не нужны, то этот код будет работать нормально.

Имеется два аспекта использования множественных значений – возврат множественных значений, и получение не основных значений, возвращаемых формами, которые возвращают множественные значения. Начальной точкой для возврата множественных значений являются функции VALUES и VALUES-LIST. Это обычные функции, а не специальные операторы, так их параметры передаются как обычно. VALUES принимает переменное число аргументов и возвращает их как множественные значения; VALUES-LIST принимает единственный аргумент – список значений, и возвращает все его содержимое в виде множественных значений. Иначе говоря:

(values-list x) === (apply #'values x)

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

Но когда выражение вычисляется в позиции значения, то используется только основное значение – вот почему предыдущий пример с добавлением работает как ожидается. Специальный оператор MULTIPLE-VALUE-CALL предоставляет механизм, который позволяет вам работать с множественными значениями, возвращаемыми выражениями. MULTIPLE-VALUE-CALL аналогичен FUNCALL за тем исключением, что FUNCALL является обычной функцией, и как следствие – использует только основное значение из переданных аргументов, в то время как MULTIPLE-VALUE-CALL переедает функции, указанной в качестве первого аргумента, все значения, возвращенные остальными выражениями, указанными в качестве аргументов.

(funcall #'+ (values 1 2) (values 3 4))             ==> 4
(multiple-value-call #'+ (values 1 2) (values 3 4)) ==> 10

Но скорее всего вы достаточно редко будете просто передавать все значения, возвращенные одной функцией, в другую функцию. Скорее всего вы захотите сохранить множественные значения в отдельные переменные и затем что-то сделать с ними. Макрос MULTIPLE-VALUE-BIND, с которым вы встречались в главе 11, является наиболее часто используемым оператором для работы с множественными значениями. В общем виде он выглядит вот так:

(multiple-value-bind (variable*) values-form
  body-form*
)

Выражение values-form вычисляется, и множественные значения, которые возвращаются им, присваиваются указанным переменным. Затем вычисляются выражения body-forms используя указанные переменные. Так что:

(multiple-value-bind (x y) (values 1 2)
(+ x y)) ==> 3

Другой макрос – MULTIPLE-VALUE-LIST, еще более простой – он принимает одно выражение, вычисляет его и собирает полученные множественные значения в список. Другими словами, этот макрос выполняет действия обратные действиюVALUES-LIST.

CL-USER> (multiple-value-list (values 1 2))
(1 2)
CL-USER> (values-list (multiple-value-list (values 1 2)))
1
2

Однако, если вы обнаружите, что часто используете MULTIPLE-VALUE-LIST, то это может быть сигналом того, что некоторая функция должна возвращать список, а не множественные значения.

И в заключение, если вы хотите присвоить множественные значения, возвращенные формой, существующим переменным, то вы можете использовать функцию VALUES для выполнения SETF. Например:

CL-USER> (defparameter *x* nil)
*X*
CL-USER> (defparameter *y* nil)
*Y*
CL-USER> (setf (values *x* *y*) (floor (/ 57 34)))
1
23/34
CL-USER> *x*
1
CL-USER> *y*
23/34

EVAL-WHEN

Еще одним специальным оператором, принципы работы которого вам нужно понимать для того, чтобы писать некоторые виды макросов, является EVAL-WHEN. По некоторым причинам, книги о Lisp часто считают EVAL-WHEN орудием только для экспертов. Но единственным требованием для понимания EVAL-WHEN является понимание того, как взаимодействуют две функции – LOAD и COMPILE-FILE. И понимание принципов работы EVAL-WHEN будет важным для вас, когда вы начнете писать сложные макросы, такие как, мы будем писать в главах 24 и 31.

В предыдущих главах я немного касался вопросов совместной работы LOAD и COMPILE-FILE, но стоит рассмотреть этот вопрос снова. Задачей LOAD является загрузка файла и вычисление всех выражений верхнего уровня, содержащихся в нем. Задачей COMPILE-FILE является компиляция исходного текста в файл FASL, который может быть затем загружен с помощью LOAD, так что (load "foo.lisp") и (load "foo.fasl") являются практически эквивалентными.

Поскольку LOAD вычисляет каждое выражение до чтения следующего, побочный эффект вычисления выражений, находящихся ближе к началу файла, может воздействовать на то, как будут читаться и вычисляться формы, находящиеся дальше в файле. Например, вычисление выражения IN-PACKAGE изменяет значение переменной *PACKAGE*, что затронет процедуру чтения всех остальных выражений.12) Аналогичным образом, выражение DEFMACRO находящееся раньше в файле, может определить макрос, который будет использоваться кодом, находящимся далее в файле.13)

С другой стороны, COMPILE-FILE обычно не вычисляет выражения в процессе компиляции; это происходит в момент загрузки файла FASL. Однако COMPILE-FILE должен вычислять некоторые выражения, такие как IN-PACKAGE и DEFMACRO, чтобы поведение (load "foo.lisp") и (load "foo.fasl") было консистентным.

Так как же работают макросы, такие как IN-PACKAGE и DEFMACRO, в тех случаях, когда они обрабатываются COMPILE-FILE? В некоторых версиях Lisp, существовавших до разработки Common Lisp, компилятор файлов просто знал, что он должен вычислять некоторые макросы в добавление к их компиляции. Common Lisp избегает необходимости в таких хаков путем введения специального оператора EVAL-WHEN взятого из Maclisp. Этот оператор, как и предполагает его имя, позволяет контролировать то, когда определенные части кода должны вычисляться. В общем виде выражение EVAL-WHEN выглядит вот так:

(eval-when (situation*)
  body-form*
)

Есть три возможных условия (situation) – :compile-toplevel, :load-toplevel и :execute, и то, которое вы укажите, будет определять то, когда будут вычислены выражения указанные body-forms. EVAL-WHEN с несколькими условиями аналогичен записи нескольких выражений EVAL-WHEN с разными условиями, но с одинаковыми выражениями. Для объяснения того, что означают эти условия, нам необходимо объяснить что делает COMPILE-FILE (который также называют компилятором файлов) в процессе компиляции файла.

Для того, чтобы объяснить как COMPILE-FILE компилирует выражения EVAL-WHEN, я должен описать отличия между компиляцией выражений верхнего уровня (FIXME top-level form), и компиляцией остальных выражений. Выражения верхнего уровня, грубо говоря, это те, которые будут скомпилированы в исполняемый код, который будет выполнен при загрузке файла FASL. Так что, все выражения, которые находятся верхнем уровне (FIXME top level of a source) файла с исходным текстом, будут скомпилированы как выражения верхнего уровня. Аналогичным образом, все выражения указанные в выражении PROGN верхнего уровня, будут также скомпилированы как выражения верхнего уровня (PROGN сам ничего не делает – он лишь группирует вместе указанные выражения), и они будут выполнены при загрузке FASL.14) Аналогичным образом, выражения указанные в MACROLET или SYMBOL-MACROLET будут скомпилированы как выражения верхнего уровня, поскольку после того, как компилятор раскроет локальные и символьные макросы, в скомпилированном коде не останется никаких упоминаний MACROLET или SYMBOL-MACROLET. И в заключение, раскрытие макроса верхнего уровня будет скомпилировано как выражение верхнего уровня.

Таким образом, DEFUN указанный на верхнем уровне исходного текста является выражением верхнего уровня – код, который определяет функцию и связывает ее с именем, будет выполнен при загрузке FASL, но выражения внутри тела функции, которые не будут выполнены до тех пор, пока функция не будет вызвана, не являются выражениями верхнего уровня. Большинство выражений компилируются одинаково вне зависимости от того, на верхнем они уровне они или нет, но семантика выражения EVAL-WHEN зависит от того, будет ли оно скомпилировано как выражение верхнего уровня, выражение не верхнего уровня, или просто вычислено, и все это в комбинации с условиями, указанными в выражении.

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

Когда EVAL-WHEN компилируется как выражение не верхнего уровня, то он либо компилируется как PROGN, в том случае, если указано условие :execute, либо просто игнорируется. Аналогичным образом, вычисляемое выражение (FIXME evaluated) EVAL-WHEN (что включает в себя выражения EVAL-WHEN верхнего уровня в исходном тексте, обрабатываемом LOAD и EVAL-WHEN, вычисляемый во время компиляции когда он является подвыражением другого EVAL-WHEN с условием :compile-toplevel) также рассматривается как PROGN если указано условие :execute, и игнорируется в противном случае. (FIXME может быть стоит сделать табличку с условиями и стадиями компиляции?)

Таким образом, макрос, такой как IN-PACKAGE может производить необходимые действия и во время компиляции и при загрузке из исходного кода путем раскрытия в выражения EVAL-WHEN выглядящие примерно так:

(eval-when (:compile-toplevel :load-toplevel :execute)
  (setf *package* (find-package "PACKAGE-NAME"))
)

значение *PACKAGE* будет выставлено во время компиляции из-за условия :compile-toplevel, во время загрузки FASL из-за условия :load-toplevel и во время загрузки исходного кода из-за условия :execute.

Существует два широко распространенных способа использования EVAL-WHEN. Первый – если вы хотите написать макрос, которому необходимо сохранить некоторую информацию во время компиляции, и которая будет использоваться при генерации раскрытий других макро-выражений в том же файле. Это обычно нужно для определяющих (FIXME definitional) макросов, когда определение, расположенное в начале файла, могут влиять на код, генерируемый для определения, расположенного далее в том же файле. Вы будете писать такие макросы в главе 24.

Вам также может понадобиться использовать EVAL-WHEN если вы захотите поместить определение макроса и вспомогательной функции, которая используется в этом макросе, в том же файле с исходным текстом, что и код, использующий данный макрос. DEFMACRO уже использует EVAL-WHEN в своем раскрытии, так что определение макроса становится доступным для использования сразу. Но обычно DEFUN не делает определение функции доступным во время компиляции, а если вы используете макрос в том же файле, где он определен, то вам необходимо чтобы были определены и все функции, используемые в макросе. Если вы обернете все определения вспомогательных функций в выражение EVAL-WHEN с условием :compile-toplevel, то определения будут доступны при раскрытии макросов. Вы наверное захотите включить также условия :load-toplevel и :execute поскольку макросы будут требовать наличие определения функций после того, как файл скомпилирован и загружен, или если вы загружает файл с исходным текстом вместо компиляции.

Другие специальные операторы

Все оставшиеся четыре специальных оператора – LOCALLY, THE, LOAD-TIME-VALUE и PROGV, позволяют получить доступ к некоторым частям нижележащего языка, к которым доступ не может быть осуществлен другими способами. LOCALLY и THE являются частями системы объявлений Common Lisp, которая используется для "связывания" некоторых вещей с компилятором, что не изменит работу вашего кода, но позволит генерировать лучший код – более быстрый, более понятные сообщения об ошибках, и т.п.15) Мы коротко обсудим объявления в главе 32.

Еще два оператора – LOAD-TIME-VALUE и PROGV, используются не часто, и объяснение того, почему это происходит, займет больше времени, чем объяснение того, что они делают. Я расскажу вам то, что они делают, так что просто вы будете иметь эту информацию. Когда-нибудь вы встретите один из них, и тогда вы будете готовы к пониманию их работы.

LOAD-TIME-VALUE используется (как видно из его имени) для создания значения во время загрузки. Когда компилятор обрабатывает код, который содержит выражение LOAD-TIME-VALUE, он генерирует код, который выполнит подвыражения лишь один раз, во время загрузки FASL, и код, содержащий выражение LOAD-TIME-VALUE будет ссылаться на вычисленное значение. Другими словами, вместо того, чтобы писать что-то подобное:

(defvar *loaded-at* (get-universal-time))

(defun when-loaded () *loaded-at*)

вы можете просто написать вот так:

(defun when-loaded () (load-time-value (get-universal-time)))

В коде, не обрабатываемом COMPILE-FILE, выражение LOAD-TIME-VALUE вычисляется однажды, во время компиляции, что может происходить когда вы явно компилируете функцию с помощью COMPILE, или даже раньше, из-за неявной компиляции кода во время его вычисления. В некомпилируемом коде, LOAD-TIME-VALUE вычисляет свои подвыражения при каждом вычислении.

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

(progv symbols-list values-list
  body-form*
)

где symbols-list является выражением, которое вычисляется в список символов, а values-list – выражение, которое вычисляется в список значений. Каждый символ динамически связывается с соответствующим значением, и затем вычисляются выражения, указанные в body-forms. Разница между PROGV и LET заключается в том, что symbols-list вычисляется во время выполнения, и имена связываемых переменных вычисляются динамически. Как я уже сказал, этот оператор не понадобится вам очень часто.

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

1)Конечно, если бы IF не был специальным оператором, а некоторым другим условным выражением, таким как COND, то вы могли бы реализовать IF в виде макроса. На самом деле, во многих диалектах Lisp, начиная с оригинального Lisp McCarthy , COND был примитивным условным оператором.
2)Конечно, с технической точки зрения эти конструкции также будут развернуты в LAMBDA-выражения, поскольку, как я упоминял в главе 6, LET может быть определена (и это делалось в ранних версиях Lisp) в виде макроса, который развертывается в запуск анонимной функции.
3)Сюрпризом может показаться то, что в действительности можно сделать анонимную функцию рекурсивной. Однако вы должны будете использовать достаточно эзотеричный механизм, известный как "Y-комбинатор". Но Y-комбинатор является интересным теоретическим результатом, а не средством практического программирования, так что мы оставим его за пределами данной книги.
4)WITH-SLOTS не обязательно должен быть реализован с помощью SYMBOL-MACROLET – в некоторых реализациях, WITH-SLOTS может проходить по коду и раскрывать макрос с x, y и z, уже заменёнными на соответствующие формы SLOT-VALUE. Вы можете увидеть как это делает ваша реализация, с помощью следующего выражения:
(macroexpand-1 '(with-slots (x y z) obj (list x y z)))
Однако, реализации Lisp легче выполнить такую подстановку внтури своего кода, чем кода, написанного пользователем - чтобы заменить x, y и z только в случаях, когда они используются как значения, проходчик по коду должен понимать синтаксис всех специальных операторов и уметь рекурсивно раскрывать все макросы, чтобы определить, включает ли макрос в раскрытой форме эти символы в позиции значения. Реализации Lisp имеют соответствующий проходчик по коду, но он относится к той части Lisp, которая недоступна пользователю языка.
5)Одна версия f2cl доступна как часть Common Lisp Open Code Collection (CLOCC). Для контраста, рассмотрим трюки, к которым авторам f2j, FORTRAN-to-Java транслятора, пришлось прибегнуть. Хотя Java Virtual Machine (JVM) имеет goto инструкцию, она не выражена прямо в Java. Таким образом, чтобы скомпилировать все goto из Фортрана, они сначала компилируют Фортран-код в стандартный java-код с вызовами к FIXME классу dummy для представления меток и goto. Затем они компилируют исходник обычным java-компилятором и делают постобработку полученного байт-кода для перевода вызовов dummy в JVM байт-коды. Умно, но болезненно.
6)Так как этот алгоритм зависит от значений, возвращаемых RANDOM, вы, может, захотите проверить его с FIXME одним и тем же произвольным зерном (consistent random seed), которое вы можете получить привязывая *RANDOM-STATE* к значению (make-random-state nil) для каждого вызова algorithm-s. Например, вы можете сделать FIXME (basic sanity check) общую санитарную проверку для algorithm-s путём выполнения следующего кода:
(let ((*random-state* (make-random-state nil))) (algorithm-s 10 200))
Если ваша рефакторизация на каждом шаге была правильна, это выражение должно выдавать один и тот же список каждый раз.
7)Это достаточно разумное ограничение – не совсем понятно, что означает возвращение из формы, которая уже завершилась, если вы конечно не программист на Scheme. Scheme поддерживает продолжения (continuations) – языковые конструкции, которые позволяют выполнить возврат из одной и той же функции более одного раза. Но по разным причинам, лишь несколько языков, отличных от Scheme поддерживают этот вид FIXME continuation.
8)Если вы относитесь к тем людям, которые любят знать как что-то работает, вплоть до мелких деталей, то вы можете поразмышлять о том, как вы бы могли реализовать макросы системы условий и рестартов, используя BLOCK, TAGBODY, замыкания и динамические переменные.
9)UNWIND-PROTECT является эквивалентом конструкции try/finally в Java и Python.
10)На самом деле, CLSQL, интерфейс к множеству баз данных, работающий на многих реализациях Lisp, предоставляет макрос с аналогичной функциональностью, имеющий имя with-database. Домашняя страница CLSQL находится по адресу http://clsql.b9.com.
11)Небольшой набор макросов не передает дополнительные возвращаемые значения тех выражений, которые они вычисляют. В частности, макрос PROG1, который вычисляет некоторое количество выражений, подобно PROGN, но возвращает значение первого выражения, возвращает только основное значение. Аналогичным образом, PROG2, который возвращает значение второго выражения, также возвращает лишь основное значение. Специальный оператор MULTIPLE-VALUE-PROG1 является вариантом PROG1, который возвращает все значения первого выражения. Это небольшой недостаток, что PROG1 не ведет себя также как MULTIPLE-VALUE-PROG1, но ни один из них не используется достаточно часто, чтобы это было неудобным. Макросы OR и COND также не всегда прозрачны для множественных значений, возвращая только основное значение определенного выражения.
12)Причиной того, что загрузка файла с выражением IN-PACKAGE в нем не имеет никакого влияния на значение *PACKAGE* после возврата из LOAD, является то, что LOAD связывает *PACKAGE* со своим текущим значением до того, как сделать что-то иное. Говоря другими словами, что-то подобное следующему выражению LET окружает остальной код в реализации LOAD:
(let ((*package* *package*)) ...)
Любые изменения *PACKAGE* будут применяться к новой привязке, а старая привязка будет восстановлена при выходе из LOAD. Аналогичным образом, эта функция связывает переменную *READTABLE*, которую мы еще не обсуждали.
13)В некоторых реализациях вы можете избежать (FIXME get away) вычисления DEFUN, которые используют неопределенные макросы в теле функции, поскольку макросы определяются до того, как функция будет вызвана. Но это будет работать только в тех случаях, когда вы загружаете определения с помощью LOAD из файла с исходным кодом, но не компилируете с помощью COMPILE-FILE, так что в общем, определения макросов должны быть вычислены до того, как они будут использованы
14)В противоположность этому, выражения, указанные в LET верхнего уровня, не будут скомпилированы как выражения верхнего уровня, потому, что они не будут выполняться при загрузке FASL. Они будут выполнены в контексте привязок, созданных LET. Теоретически, LET не связывающий никаких переменных может рассматриваться как PROGN, но это не так – выражения, указанные в LET никогда не будут считаться выражениями верхнего уровня.
15)Одним из объявлений, которая имеет влияние на семантику программы, является объявление SPECIAL, упомянутое в главе 6.
Предыдущая Оглавление Следующая
@2009-2013 lisper.ru