Programowanie w Scheme

Scenariusz 2: makra

Określenie ,,makra'' jest mało adekwatne, bo sugeruje coś w rodzaju preprocesora C lub m4. W dialektach Lispu makra pozwalają definiować całkowicie nowe konstrukcje syntaktyczne. Takie rozszerzenie syntaktyczne (potocznie nazywane makrem) sygnalizowane jest nazwą/symbolem i ma postać podobną do już istniejących, na przykład

(while warunek wyrażenie)
które wygląda podobnie do standardowego
(when warunek wyrażenie)

Definicje makr mogą używać pełnej mocy języka, tyle że są to funkcje, które operują na wyrażeniach, a nie na wartościach. Definicja makra związuje nazwę makra z jego funkcją transformacji wyrażeń, zwaną transformerem.

Wynik transformacji zastępuje w programie oryginalne wyrażenie. Ponieważ może zawierać kolejne makrowołania, więc rozwijanie makr jest procesem rekurencyjnym.

Definicje niskopoziomowe makr

Mechanizmy niskopoziomowe definiowania makr nie wchodzą obecnie w skład standardu Scheme (naruszono tu po raz pierwszy w historii tych standardów podstawową zasadę: ,,należy usuwać ograniczenia języka, a nie spiętrzać je''). Występują jednak w każdej implementacji i są wszędzie prawie jednakowe. My oczywiście popatrzymy na Guile.

Do definiowania makr służy konstrukcja

(define-macro (nazwa argument ...)
  wyrażenie ...)
Makro jest normalną definicją funkcji, tyle że:

Popatrzmy na przykład dla while

(define-macro (while test . body)
  (list* 'do '()
         (list (list 'not test))
         body))

> (let ((i 3)) (while (> i 0) (display i) (newline) (set! i (- i 1)))
3
2
1

Jak sprawdzić, czy wynik rozwinięcia makra jest zgodny z naszymi oczekiwaniami. Niestety nie jest to łatwe. Istnieje funkcja macroexpand, ale pokazuje ona wynik w wewnętrznej postaci, niebyt wygodnej do oglądania:

scheme@(guile-user)> (macroexpand '(let ((i 3)) (while (> i 0) (display i) (newline) (set! i (- i 1)))))
$4 = #<tree-il (let (i) (i-544) ((const 3)) (letrec (loop) (loop-548)
((lambda ((name . loop)) (lambda-case ((() #f #f #f () ())
(if (apply (toplevel not) (apply (toplevel >) (lexical i i-544) (const 0)))
(if (const #f) (const #f) (void)) (begin (apply (toplevel display)
(lexical i i-544)) (apply (toplevel newline)) (set! (lexical i i-544)
(apply (toplevel -) (lexical i i-544) (const 1)))
(apply (lexical loop loop-548)))))))) (apply (lexical loop loop-548))))>

Sama makrodefnicja też nie była zbyt czytelna, ale z tym łatwo sobie poradzić. Scheme posiada mechanizm ,,półcytowania'' (quasiquote), podobny do quote. Jest on bardzo przydatny do czytelnego opisu złożonych wyrażeń budujących struktury listowe (i wektorowe). Używamy znaku "`" zamiast apostrofu.

Działa to jak zwykły quote z dwoma wyjątkami:

Popatrzmy teraz na naszą definicję

(define-macro (while test . body)
  `(do ()
       ((not ,test))
     ,@body))
Prawda, że dużo czytelniej?

To teraz pora na ćwiczenia

  1. Zapiszmy definicję konstrukcji let w wersji bazowej. Ma działać tak
    (my-let ((var init) ...)
      body ...)
    
    ==>
    
    ((lambda (var ...)
       body ...)
     init ...)
    
    Rozwiązanie tutaj
  2. A teraz and
    (and) ==> #t
    (and e) ==> e
    (and e1 e2 ...) ==> (if e1
                            (and e2 ...)
                            #f)
    
    Rozwiązanie tutaj

Higiena = przejrzystość referencyjna

Co by było, gdybym zdefiniował while używając nazwanego let

(define-macro (while test . body)
    `(let iter ()
       (when ,test ,@body (iter))))

> (let ((iter 3)) (while (> iter 0) (display iter) (newline) (set! iter (- iter 1))))
<unnamed port>:65:23: In procedure iter:
<unnamed port>:65:23: In procedure >: Wrong type argument in position 1: #

Popatrzmy na definicję makra or

(define-macro (my-or . args)
  (cond ((null? args) #f)
        ((null? (cdr args)) (car args))
        (else
          `((lambda (temp)
              (if temp
                  temp
                  (my-or ,@(cdr args))))
            ,(car args)))))

scheme@(guile-user)> (my-or #f 2 3)
$6 = 2

Wygląda na to, że jest dobrze. Ale próbujmy dalej

scheme@(guile-user)> (let ((temp 7)) (my-or #f temp 3))
$7 = 3
scheme@(guile-user)> (let ((temp1 7)) (my-or #f temp1 3))
$8 = 7
Ups... Chyba jednak nie jest tak świetnie - zmiana nazwy zmiennej lokalnej zmieniła wynik.

Co się stało? Identyfikator temp został przechwycony: utożsamiony ze zmienną lokalną wprowadzoną w treści makra. Naruszyliśmy przejrzystość referencyjną.

Popatrzmy na inny przykład z (częściowym) rozwinięciem

(let ((temp 7))
  (my-or (foo temp)
         temp))

==>

(let ((temp 7))
   ((lambda (temp)
      (if temp temp temp))
    (foo temp)))

Można by zmienić w treści makra tę nazwę na inną, na przykład %temp@^&%-. Takie dziwaczne nazwy generuje funkcja gensym (zaczynają się od spacji), ale ryzyko wciąż istnieje. Można jednak dostać unikalny symbol: taki, który nie jest ,,internowany'' --\umieszczany w bazie symboli. Nie można go jednak wczytać ani wypisać. Tworzymy go funkcją make-symbol

(define-macro (my-or . args)
  (cond ((null? args) #f)
        ((null? (cdr args)) (car args))
        (else
         (let ((temp (make-symbol "temp")))
          `((lambda (,temp)
              (if ,temp
                  ,temp
                  (my-or ,@(cdr args))))
            ,(car args))))))

scheme@(guile-user)> (let ((temp1 7)) (my-or #f temp1 3))
$9 = 7
scheme@(guile-user)> (let ((temp 7)) (my-or #f temp 3))
$10 = 7

Inny sposób to użyć zero-argumentowych procedur (thunk)

(define-macro (my-or . args)
  (cond ((null? args) #f)
        ((null? (cdr args)) (car args))
        (else
          `((lambda (temp thunk)
              (if temp temp (thunk)))
            ,(car args)
            (lambda () (my-or ,@(cdr args)))))))

Bardzo eleganckie teoretycznie, ale ciut skomplikowane dla większych definicji. Łatwo popełnić błąd.

Wróćmy jeszcze do przykładu z while używającym nazwanego let

(define-macro (while test . body)
  (let ((iter (make-symbol "iter")))
    `(let ,iter ()
       (when ,test ,@body (,iter)))))

> (let ((iter 3)) (while (> iter 0) (display iter) (newline) (set! iter (- iter 1))))
3
2
1

syntax-rules

w definicjach niskopoziomowych możemy zrobić wszystko -- do dyspozycji mamy całą moc Scheme. Ale uwaga: możemy też popsuć wszystko. Dlatego w standardzie R5RS do Scheme dołożono definiowanie konstrukcji syntaktycznych opisując je parami (wzorzec, rozwinięcie). Oddajemy trochę mocy, mamy wygodę i bezpieczeństwo. Popatrzmy na znane już definicje

(define-syntax while
  (syntax-rules ()
    ((_ test body ...)
     (do () ((not test)) body ...))))

(define-syntax my-let
  (syntax-rules ()
    ((let ((name val) ...)
       body ...)
     ((lambda (name ...)
        body ...)
      val ...)))

Pierwszy argument syntax-rules to lista symboli zarezerwowanych w definicji -- system traktuje je jako stałe. Zwykle ta lista jest pusta (nazwa makra jest zarezerwowana implicite). Pozostałe symbole pełnią rolę zmiennych syntaktycznych.

Reszta to reguły: pary wzorzec + rozwinięcie. Transformer znajduje pierwszą regułę, której wzorzec pasuje do wyrażenia i zastępuje wyrażenie nowym, utworzonym z rozwinięcia. Symbol ... oznacza wielokrotne potórzenie poprzedzającego elementu.

To teraz or

(define-syntax (or)
  (syntax-rules ()
    ((or) #f)
    ((or e) e)
    ((or e1 e2 ...)
    ((lambda (temp)
       (if temp
           temp
           (or e2 ...)))
      e1)))

I kilka innych przykładów

(define-syntax while
  (syntax-rules ()
    ((while test body ...)
     (let iter ()
       (when test body ... (iter))))))

Pora poćwiczyć:

  1. (until test expr ...) oblicza cyklicznie wyrażenia dopóki test nie będzie spełniony. Rozwiązanie tutaj
  2. let* działa jak let, ale tworzy wiązania po kolei (zagnieżdża). Rozwiązanie tutaj

    Przykłady

    Makro define-stub służy do definiowania funcji ,,zastępczych'', zamiast właściwej funkcji,