Метапрограммирование в scheme: syntax-rules и все-все-все
От: Mr.Cat  
Дата: 10.02.09 21:31
Оценка: 80 (11)
Что делает программист на scheme, прочитав про nemerle вообще и про его макрос $ в частности? Вариантов, конечно, несколько (вешается, кидается реализовавать $ на схеме, навсегда бросает scheme и уходит в стан поклонников nemerle...), однако в случае, когда nemerle.org лежит и признаков жизни не подает, ответ напрашивается сам собой.

Как и следовало ожидать, сегодня мы напишем аналог $ на scheme, попросим у коллег из лагеря nemerle показать реализацию $ и заодно поговорим о макросах в scheme вообще.

Поскольку знак $ в программах на scheme традиционно как-то не фигурирует — мы назовем наш макрос ~ (тильда), поскольку лично у меня оная ассоциируется именно со строками, форматированием и прочими printfами. В ту же самую тильду в форматируемой строке мы будем заключать выражения, значения которых будут вычисляться и подставляться в строку. Таким образом, использовать наш макрос надобно будет примерно так...
(let ((x 1)) (display (~ "x + 1 = ~(+ x 1)~"))(newline))

... что в итоге невозбранно выведет на экран цифру 2.

По сути наша задача проста до безобразия. Очевидно, что если форматируемую строку разбить тильдами на подстроки, и каждую вторую подстроку прогнать через read, то мы получим список выражений, которые должны получиться на выходе макроса в качестве компонентов результирующей строки. В качестве примера возьмем такую строку:
"x=~x~, x+1=~(+ x 1)~, x+2=~(+ x 2)~"

Применив к ней наш алгоритм, получим список
("x=" x ", x+1=" (+ x 1) ", x+2=" (+ x 2))

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

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

1. Разбиение строки символом.
По какой-то дикой случайности в srfi-13 такой функции не обнаружилось, поэтому разбиение придется реализовать, благо ничего сложного в нем нет.
(define (string-split s splitter)
  (define (string-split-iter s acc)
    (let ((index (string-index s splitter)))
      (if index
          (string-split-iter (string-drop s (+ index 1)) (cons (string-take s index) acc))
          (reverse (cons s acc)))))
  (string-split-iter s '()))


2. Map на каждый второй элемент списка. Ничего слишком уж обобщенного изобретать не хочется, поэтому тут можно просто пронумеровать элементы списка и произвести map над нечетными индексами:
(define (map-odd proc lst)
  (map (lambda (pair)
         (if (odd? (car pair))
             (proc (cadr pair))
             (cadr pair)))
       (zip (iota (length lst)) lst)))


3. Read из строки и склейка строковых представлений списка объектов. Ну тут совсем просто: пойдем проторенной дорогой boost::format.
(define (read-string s)
  (with-input-from-string s read))

(define (display* . args)
  (for-each display args))

(define (display-string* . args)
  (with-output-to-string (lambda () (apply display* args))))


4. Итого, собрав все это в одну функцию, получим:
(define cap-fmt:splitter #\~)

(define (cap-fmt:parse fmt)
  (remove (lambda (o) (and (string? o) (string-null? o)))
          (map-odd (lambda (s) (if (string-null? s)
                                   (make-string 1 cap-fmt:splitter)
                                   (read-string s)))
                   (string-split fmt cap-fmt:splitter))))

Отметим, что в cap-fmt:parse мы двойную тильду преобразоваваем просто в строку "~" и удаляем из выходного списка пустые строки.

5. Итак, фактически у нас готов конструктор s-выражения, который остается обернуть в негигиенический (гигиенический не позволит захватить локальгый биндинг) макрос. Для этого используем syntax-case как наиболее стандартный из негигиенических.
(define-syntax ~
  (lambda (s-expr)
    (syntax-case s-expr ()
      ((_ format)
       (with-syntax ((result
                     (datum->syntax #'_
                                    (cons 'display-string*
                                          (cap-fmt:parse (cadr (syntax->datum s-expr)))))))
          #'result)))))

Что у нас в итоге получилось — так это самый обыкновенный макрос в стиле common lisp: мы разобрали исходное выражение, произвели над ним манипуляции и собрали результат в виде списка. Хорошо это или плохо — мы сейчас и попытаемся понять. (Полный текст макроса, готовый для использования в chicken scheme лежит здесь: http://paste.lisp.org/display/75222).

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

Что мы видим в нашем случае? Наш форматирующий макрос переносит семантику программы из исходного кода в строку, что для программы на scheme является совершенно противоестественным действием. Я бы охарактеризовал scheme way — это отсутствие неявных эффектов. Все действия, так или иначе влияющие на ход вычислений явным образом выражаются в виде S-выражений. Биндинг переменной — S-выражение, вызов функции — S-выражение, побочный эффект — опять таки S-выражение. Когда же эффекты, имеющие доступ к локальным биндингам (т.е. имеющие возможность влиять на ход вычислений), заключаются внутри обыкновенной с виду строки (и таким образом становятся в некотором роде неявными) — это может здорово сбить с толку программиста.
Что интересно, когда я показал этот код каналу #scheme, мне первым делом предложили изменить макрос под вот такой вариант использования (читателю я бы посоветовал еще пару раз по мере чтения вернуться к этому примеру):
(let ((x (format:display x))) (format:sequence "x = "x""))

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

Получается, что и сами программисты, и спецификация scheme ратуют за то, что я бы назвал принципом явных эффектов (как вариант принципа наименьшего удивления). И принцип этот изначально заложен в дизайне языка.
Одной из его вариаций, которую можно привести в качестве наглядного примера, является лексическая область видимости переменных. Вернее, лучше показать, к чему приводит отсутствие лексической области видимости:
Python 2.6.1 (r261:67515, Dec  7 2008, 18:56:39)
[GCC 4.3.2] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> x = 0
>>> [x for x in range(10)]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> x
9

В данном простейшем коде на языке python одним неосторожным движением мы изменили значение переменной, объявленной выше по коду и которую вполне возможно мы бы по неосторожности захотели использовать и дальше. Принцип наименьшего удивления же предписывал бы переменной внутри list comprehension быть локальной для выражения (по аналогии с математической нотацией задания множества, из которой и растут ноги у list comprehension), т.е. подчиняться лексической области видимости — и это значительно облегчило программисту его работу, избавив от необходимости отслеживать именование переменных. Аналогичные примеры на rsdn недавно рассматривались в ветке о недостатках php (http://www.rsdn.ru/forum/message/3275810.1.aspx
Автор: Mr.Cat
Дата: 03.02.09
), где я также попытался показать, что лексическая область видимости устраняет из языка множестно граблей.

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

Если ответственность за нарушение означенного принципа, естественно, возлагается на программиста, то средством ему служит не что иное, как негигиенические макросы. Действительно, как ни старайся, а с помощью 100% гигиенического syntax-rules, не позволяющего нарушать лексическую область видимости, невозможно без явного на то желания описать конструкции, не соответствующие принципу наименбшего удивления. И наоборот, ориентиция на использование syntax-rules в метапрограммировании подталкивает программиста к удачному дизайну макросов (таким образом, можно утверждать, что лексическая область видимости в scheme является эквивалентом принципа наименьшего удивления). Ориентация же на негигиенические системы (и в частности — бездумное использование syntax-case) если и не всегда ведет к скверному дизайну, но подталкивает к нему, скрывает просчеты в изначальной задумке. Безусловно, макросы, нарушающие гигиену, могут быть и весьма безобидными, как, например, вот этот макрос для организации безусловного цикла (взят из спецификации syntax-case в r6rs):
(define-syntax loop
  (lambda (x)
    (syntax-case x ()
      [(k e ...)
       (with-syntax
          ([break (datum->syntax #’k ’break)])
          #’(call-with-current-continuation
              (lambda (break)
                (let f () e ... (f)))))])))

С вот такой вот моделью использования:
(let ((n 3) (ls ’()))
  (loop
  (if (= n 0) (break ls))
  (set! ls (cons ’a ls))
  (set! n (- n 1))))

В данном примере законное желание программиста удивиться использованию нигде не объявленной функции break для выхода из цикла и возврата результата подавляется ассоциациями с безусловными циклами в других языках (например, C), на что, конечно, надеяться в более сложных случаях было бы опрометчиво.

Получаетсяю что в общем случае, если какую-то задумку нельзя реализовать с помощью syntax-rules, это должно стать предупреждением для программиста: возможно. его идея является полнейшей лексической ересью и подлежит экстермина^W... Пересмотру она подлежит, в общем.

В заключении мы можем ответить на вопрос, который рано или поздно задает себе любой начинающий программист на scheme: "Зачем нужны гигиенические макросы? Неужели это очередная необоснованная блажь сообщества программистов на scheme?" Ответ заключается в том, что гигиеничность системы макросов обеспечивает сохранность концепций, положенных в основу языка. Естественно, программисту позволено принимать какие угодно решения, однако у него перед глазами есть syntax-rules, применяя который, он может быть уверен в том, что расширяет язык простым и понятным для своих коллег способом (т.е. не ломает привычную для scheme семантику). Так называемые макросы в стиле common lisp не могут дать таких гарантий.
scheme метапрограммирование макросы syntax-rules syntax-case
 
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.