Как мы выяснили изначально экосистема script-fu, состоит из встроенных функций(порядка полутора сотен) и специальных форм. Загружаемого скрипта инициализации: /usr/share/gimp/2.0/scripts/script-fu.init, в котором также присутствуют около полутора сотен определений.

Не надо считать в ручную.

grep -e "^(define" script-fu.init | wc -l ;;132

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

Программисты: Разработчик, твой язык жалок, синтаксис беден и убог, как нам писать код?!?
Разработчик(языка lisp): Вот вам МАКРОС!!!

Программист и Разработчик языка.
Программист и Разработчик языка.

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

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

С появлением и закреплением в Лиспе этой гомоиконичности связана забавная история.

Изначально изобретатель языка Лисп, Маккарти, планировал строить Лисп из Sexpr и Mexpr выражений, так сказать примитивных выражений и МЕТА выражений. Давайте покажу:

;;Sexp выражения
(apply f1 12 "stre" 'sym)

;;Mexpr - аналог Sexp
apply[f1;12;"stre";(quote sym)]

;;Mexpr манипулирует sexp как данными:
eval[(EQ (QUOTE A) (CAR (CONS (QUOTE A) (QUOTE (B C D)))));NIL]

планировалось, что Sexpr будут примитивными выражениями лиспа, это что то типа данных, которыми будут манипулировать мета выражения - Mexpr. Но с реализацией sexpr и постепенно накопленной практикой программирования на них, выяснилось, что никаких Mexpr не требуется, через sexp можно было выразить любую необходимую функциональность, поэтому Mexpr так и не появились в лиспе. Так сказать более простой синтаксис(но не менее выразительный) победил.

Отличие Лисп синтаксиса от Си подобных языков

Действительно, в чём обычно упрекают Лисп: у вас слишком много скобочек. А почему много? Давайте сравним вызов функции Лисп и Си:

;;Лисп
(f1 1 2 3)

;;Си
f1(1 2 3)

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

;;Лисп
(set! rez (+ 1 2 3 4))

;;Си
rez = 1 + 2 + 3 + 4;

;;Лисп
(set! ret (if (= 0 value)
              (f1 1 2)
              (f2 1 2 3))) 

;;Си
if (0 == value) {
    ret = f1(1 2);
} else  {
    ret = f2(1 2 3);
}

но из за операторного синтаксиса разбор выражений Си сопряжён с необходимостью иметь всякие парсеры и лексеры. Сравнив выражения if мы обнаружим ровно по 10 скобочек в каждом, хотя в Лиспе, "торчит" более длинный "скобочных хвост", но по факту их количество одинаково.

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

Вообще знакомство с Julia создало у меня впечатление, как будто это и есть Лисп с реализованными в нём Mexpr. Хотя на мой взгляд язык получился переусложнённый и всё из за попытки внедрить макросы. А вот идея отмечать макросы знаком @ мне понравилась.

Главное отличие макросов от функций.

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

Экстравагантный пример использования макросов.

Бывают в Лисп случаи, когда нужно работать с вложенными структурами данных можно даже сказать, рекурсивными, древовидными, и там количество геттеров и сеттеров оказывается очень значительным и такой функциональный лисп синтаксис оказывается трудно читаемым, да бывает. Но на это у лиспа есть свой ответ - МАКРОСЫ!

Приведу пример без раскрытия сути макросов: Недавно я работал с бинарными деревьями, а конкретнее писал функции работы с АВЛ-деревьями, там приходилось работать сразу с несколькими узлами бинарного дерева, и от геттеров и сеттеров начинало рябить в глазах, тогда я взял и "позаимствовал" dot-синтаксис из Си подобных языков:

;;без dot-синтаксиса
(define-m (avl-node-calculate-height node)
    (avl-node-height! node (+ 1 (max (if (null? (avl-node-left node))
                                          0
                                          (avl-node-height (avl-node-left node)))
                                      (if (null? (avl-node-right node))
                                           0
                                           (avl-node-height (avl-node-right node)))))))

;;с dot-синтаксисом
(define-m (avl-node-calculate-height node)
  (with-stru (node       avl-node
              node.left  avl-node
              node.right avl-node)
       (set! node.height (+ 1 (max (if (null? node.left)  0 node.left.height)
                                   (if (null? node.right) 0 node.right.height))))))

кажется в дот-синтаксисе смысла нет, код по объёму приблизительно одинаковый, но это лишь кажется, рабочая логика занимает гораздо меньше места и компактнее, смысл доступа к полям яснее. Единственно уязвимое(для критики) место это "шапка" в которой определяются типы переменных, что можно было бы придумать в этом случае? Вот для таких монотипных и много переменных записей прекрасно подошёл бы синтаксис:

(define-m (avl-node-calculate-height node)
  (with-vars-stru (node node.left node.right) avl-node 
       (set! node.height (+ 1 (max (if (null? node.left)  0 node.left.height)
                                   (if (null? node.right) 0 node.right.height))))))

реализовать синтаксис with-var-stru на базе уже имеющегося with-stru не представляет совершенно никакого труда.

(define-macro (with-vars-stru vars-stru type . body)
   `(with-stru ,(fold (lambda (lst el) (cons el (cons type lst))) '() (reverse vars-stru)) ,@body))

другой вопрос, а стоит ли? Стоит ли плодить синтаксисы только потому что мы это можем сделать? Мне кажется в данном случае стоило бы усовершенствовать синтаксис исходного макроса with-stru. Изначально макрос with-stru разбирал определения имеющие структуру: (переменная тип-структуры переменная тип-структуры ...).

корректируем with-stru
;;сам код макроса - как видите он очень простой.
(define-macro (with-stru var-stru . body)
  (let* ((vars   (parse-var-stru var-stru body))   ;;(parse-slot-objs fields-stru)
         (new-body  (tree-expr-replace-vars-dot-fields body vars))) ;;
    `(begin ,@new-body)))

;;функция разбирающая определения связки переменная - структура
(define-m (parse-var-stru var-stru)
  (let ((rez '()))
   (do ((cur var-stru (cddr cur)))
         ((or (null? cur)
              (null? (cdr cur))))
      (let ((var         (car cur))       ;;вот здесь мы выделили имя переменной
            (type-stru   (cadr cur)))     ;;а здесь имя типа структуры.
          (push rez (var-stru-def! var type-stru))))
   rez))

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

(define-m (parse-var-stru var-stru)
  (let ((rez '()))
    (do ((cur var-stru (cddr cur)))
          ((or (null? cur)
               (null? (cdr cur))))
       (let ((var         (car cur))
             (type-stru   (cadr cur)))
          (if (pair? var) ;;теперь анализируем синтаксис один атом(переменная) или целый список переменных
              (for-list (el (reverse var))
                    (push rez (var-stru-def! el type-stru))) ;;список переменных
              (push rez (var-stru-def! var type-stru)))))    ;;одна переменная
    rez))

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

(define-m (avl-node-calculate-height node)
  (with-stru ((node node.left node.right) avl-node )
       (set! node.height (+ 1 (max (if (null? node.left)  0 node.left.height)
                                   (if (null? node.right) 0 node.right.height))))))

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

Как писать макросы.

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

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

;;(define path-home "D:")
(define path-home (getenv "HOME"))
(define path-lib (string-append path-home "/work/gimp/lib/"))
(define path-work (string-append path-home "/work/gimp/"))
(load (string-append path-lib "util.scm"))

Наш первый макрос, в своём составе имеет код исполняющийся при раскрытии макроса(пока это простая печать) и результирующий код, который будет возвращён как результат работы макроса:

(define-macro (test1 var)
   (prn "My first macro: run expand: " var "\n")
     `(begin
        (prn "test print: " ,var "\n"))
   )

(test1 "my string")
;;My first macro: run expand: my string
;;test print: my string
;;#t

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

(macro-expand '(test1 "My string"))
;;My first macro: run expand: My string
;;(begin (prn "test print: " "My string" "\n"))

т.е запускает макрос, который являясь обычной функцией печатает строку и возвращает синтаксическую конструкцию "(begin ...". А если мы задали команду в консоли интерпретатора (test1 "My string") то возвращаемый код, тут же интерпретируется и происходит ещё одна печать.

Типичные макросы

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

;;(for (i 1 10)
;;    (prin1 "i: ") (print i))

Достаточно компактная запись, без необходимости писать какие либо условные проверки. Для её реализации можно создать макрос, например так:

(define-macro (for var . body)
   ;;(prn "Раскрытие макроса for\n")
   (let ((i     (car var))
         (start (cadr var))
         (end   (caddr var)))
      `(let loop ((,i ,start))
          (cond
           ((<= ,i ,end)
            ,@body
            (loop (succ ,i))
            )))))
          
(for (i 1 10)
   (prin1 "i: ") (print i))
          
(for (i 1 10)
   (prin1 "i: ") (print i)
   (for (j 2 (/ i 2))
      (prin1 "   j: ") (prin1 j))
   (newline))

Основная работа по созданию результата происходит под знаком обратной кавычки[`], являющейся синтаксическим сахаром для макроса quasiquote (квази цитирования), которая в отличии от обычного цитирования(quote - а это уже не макрос а специальная форма), позволяет выполнять функции Лиспа.

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

Или вот ещё похожий пример, цикл прохода по элементам списка:

;;(for-list (elm '(1 2 3 4 5))
;;    (prin1 "elm: ") (print elm))

(define-macro (for-list var . body)
   (let ((elm   (car var))
         (lst   (cadr var))
         (cur   (gensym)))
      `(let ((,elm nil))
          (let loop ((,cur ,lst))
             (cond
              ((not (null? ,cur))
               (set! ,elm (car ,cur))
               ,@body
               (loop (cdr ,cur))
               ))))))

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

for-each

(for-each (lambda (elm) (prin1 "elm: ") (print elm) (+ 1 elm)) '(1 2 3 4 5)) ;;"elm: "1 ;;"elm: "2 ;;"elm: "3 ;;"elm: "4 ;;"elm: "5 ;;(3 4 5 6)

Ну что ж, давайте протестируем макрос обработки всех элементов списка:

(for-list (cur '(1 2 3 4 5)) (prin1 "cur: ") (print cur)) ;;"cur: "1 ;;"cur: "2 ;;"cur: "3 ;;"cur: "4 ;;"cur: "5 (for-list (cur '((1 2 3) (1 2) (a b c) (e f e))) (prin1 "cur: ") (for-list (cur2 cur) (prin1 " ") (prin1 cur2)) (newline)) ;;"cur: "" "1" "2" "3 ;;"cur: "" "1" "2 ;;"cur: "" "a" "b" "c ;;"cur: "" "e" "f" "e

Типичные ошибки, при написании макросов.

Всё хорошо, всё работает, работают и вложенные циклы, за исключением одного ньюанса, абстракция цикла for-list - "протекает". Что это значит? Это значит, что макрос составлен так, что пользователь этого макроса может осознанно или не осознанно повлиять на его работу, совершив со своей точки зрения совершенно лигитимное действие. В данном случае, метка loop, "торчит наружу", т.е видна коду пользователя макроса, и если он в своём коде напишет, что-нибудь типа: (loop '(23 12)), не знаю для чего, поведение цикла станет не предсказуемым.

В моём случае плагин script-fu просто завис.

(for-list (cur '(1 2 3 4 5))
    (prin1 "cur: ") (print cur)
	(loop '(23 12)))

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

(define-macro (for-list var . body)
   (let ((elm   (car var))
         (lst   (cadr var))
         (cur   (gensym))
         (lp    (gensym)))
      `(let ((,elm nil))
          (let ,lp ((,cur ,lst))
             (cond
              ((not (null? ,cur))
               (set! ,elm (car ,cur))
               ,@body
               (,lp (cdr ,cur))
               ))))))

(define-macro (for var . body)
   (let ((i     (car var))
         (start (cadr var))
         (end   (caddr var))
		 (lp    (gensym)))
      `(let ,lp ((,i ,start))
          (cond
           ((<= ,i ,end)
            ,@body
            (,lp (succ ,i))
            )))))
Скрытый текст
(define (loop a)
    (print a))
	
(for-list (cur '((1 2 3) (1 2) (a b c) (e f e)))
    (prin1 "cur: ")
    (for-list2 (cur2 cur)
              (prin1 "   ") (prin1 cur2))
    (newline)
	(loop '(5)))

;;"cur: ""   "1"   "2"   "3
;;(5)
;;"cur: ""   "1"   "2
;;(5)
;;"cur: ""   "a"   "b"   "c
;;(5)
;;"cur: ""   "e"   "f"   "e
;;(5)

Ну что ж, как видно из примера, теперь макрос никак не реагирует на присутствие в нём вызова loop. А подгадать какое значение придаст метке функция gensym, это ещё надо постараться, и думаю не всякий пользователь будет столь зловреден, чтобы таким способом испортить себе жизнь.

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

Для более подробного знакомства с принципами написания макросов, я рекомендую свой скромный перевод книги Пола Грэма On Lisp

И вот еще что, совет: НИКОГДА не пользуйтесь МАКРОСАМИ в Script-fu, пока не прочтёте следующую статью!!!

Комментарии (7)


  1. Source
    05.11.2024 14:05

    Как пример такого подхода могу привести язык Julia. Он поступает тоже хитро: парсером разбирает входное выражение, превращает его в Лисп подобный код

    Посмотрите ещё Elixir. Это язык, синтаксис которого полностью построен на макросах. И при том, что он внешне не похож на Lisp, внутренняя реализация очень похожа.


    1. IisNuINu Автор
      05.11.2024 14:05

      спасибо посмотрел, на самом деле на Джулию больше похоже, но в принципе и на Лисп тоже.


      1. Source
        05.11.2024 14:05

        Ну, в отличии от Джулии, тут синтаксис через макросы определяется, например: defmodule, if, && и так практически все "ключевые слова"


  1. vadimr
    05.11.2024 14:05

    quote - а это уже не макрос а специальная форма

    Специальная форма – это понятие семантики языка, а макрос – конкретный механизм реализации. Как quote, так и quasiquote являются специальными формами, фактически обычно реализованными с помощью макросов.

    > quote

    *** ERROR IN (stdin)@13.1 -- Macro name can't be used as a variable: quote

    По сути, специальная форма – это просто любая форма, способ вычисления которой отличается от обычной подстановки значений в известном порядке (в большинстве реализаций Scheme слева-направо).

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

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

    Но в целом очень интересно читать Ваши статьи, много полезного для себя почерпнул из них.


    1. IisNuINu Автор
      05.11.2024 14:05

      Владимир, это вы в какой системе quote проверяли? В script-fu:

      > quote
      Error: eval: unbound variable: quote 
      
      > quasiquote
      #<MACRO>

      quote в тинисхеме обрабатывается интерпретатором, а quasiquote определён как макрос в init.scm.


      1. vadimr
        05.11.2024 14:05

        Любопытно. Я проверял в Gambit Scheme. Конечно, ничто не обязывает обрабатывать их так или иначе. Но очень нелогично, что две эти формы, означающие по сути почти одинаковые вещи (собственно, ведь quote – это упрощённая версия quasiquote, оставшаяся в языке по историческим причинам), обрабатываются в TinyScheme по-разному. В Gambit они обе определены одинаковым образом через syntax-object в файле syntax-case.scm.

        Моё же замечание было больше по терминологии.


        1. IisNuINu Автор
          05.11.2024 14:05

          да я вас понял. Это не существенный вопрос. То что вы назвали специальной формой, это дополнение к функциональной форме в классе форм. я бы то что вы назвали специальной формой назвал синтаксической формой, т.е. всё что отличается от функциональной. А уже синтаксические формы делятся на макросы и специальные формы, по принципу где реализована логика обработки, если в самом интерпретаторе то это именно специальная форма, если в виде макроса, т.е вне интерпретатора, то это макрос. В разных реализациях схем и даже разных версиях одной и той же реализации, набор специальных форм может различаться. но все синтаксические формы должны быть поддержаны, поддерживаются они с помощью инициализационных файлов, в которых и определяют недостающие в интерпретаторе синтаксические формы, в виде макросов. Классический пример формы if и cond, в одних интерпретаторах if может быть специальной формой, а cond макросом, раскрывающимся в набор if, а в других наоброт cond обрабатывается интерпретатором, а if определён как макрос. но это не обязательно можно обе формы сделать специальными и обрабатывать всё в интерпретаторе. В тинисхеме поддерживается старая школа макросов(олдскул) никаких синтаксических объектов нет, как и гигиены, это всё забота программиста.