Common Lisp (7) - makrá

15.06.2008 19:10 | Články | Adam Sloboda
Čo dokážu makrá v Lispe a na čo je potrebné dávať pozor.

Makrá v Lispe sú zložitejšie a majú omnoho viac možností než napríklad preprocesorové makrá jazyka C. Tieto možnosti ale prinášajú aj možné problémy, ktoré treba mať na pamäti a vyhnúť sa im. Prvá vec je, že generujú kód ešte v čase kompilácie – to znamená, že niekde ich generácia musí končiť, nesmú byť rekurzívne, takéto makro sa neskompiluje.

Na pomoc pri vývoji máme k dispozícii macroexpand a macroexpand-1, resp. v prostredí Emacsu a SLIME s predponou slime- (M-x názov; skratka C-c RET). macroexpand-1 vyhodnotí makro jedenkrát, už nevyhodnocuje ďalej ani keď je výsledok ďalšie volanie makra.

Pozrime sa ako sa expanduje makro, ktorým sme si počítali {π}; v druhej časti seriálu:

(defmacro pi-macro (n)
  `(+ ,@(mapcar
         #'(lambda (n)
             (if (= n 0) 1 (/ (if (evenp n) 1 -1)
                              (+ 1 (* n 2)))))
         (loop for x from 0 to n collect x))))
 
;; expandujeme volanie makra
(pi-macro 10)
;; výsledok
(+ 1 -1/3 1/5 -1/7 1/9 -1/11 1/13 -1/15 1/17 -1/19 1/21)

Niektorých zrejme napadne, že na tomto makre nie je niečo v poriadku. Spomínal som, že makro sa vyhodnocuje v čase kompilácie, pre priamo zadané číslo to funguje, ale ak by sme chceli zadať premennú tak neuspejeme. Volanie mapcar sa totiž vyhodnocuje v čase kompilácie a premenná nadobúda hodnotu v čase behu. Tento rozdiel nám umožňuje vytvárať makrá typu loop, ktoré prijíma rôzne kľúčové slová (má vlastnú špecifickú syntax) a podľa nich vygeneruje kód.

Hlavné použitie makier spočíva v tom, že za nás dokážu generovať opakujúci sa kód, ale hlavne taký, ktorý nie je možné jednoducho zapísať funkciou. Makrá sú použité aj v štandardnej knižnici, kde napríklad do a loop generujú dosť nízkoúrovňový kód, ktorý môže byť problematické čítať (alebo dokonca expandujú na kód, ktorý je implementačne závislý).

;; dotimes je makro, ktoré využíva makro "do" na zjednodušenie inak
;; opakujúceho a menej prehľadného zápisu
(dotimes (x 10) (format t "hello world!"))
;; slime-macroexpand-1
(DO ((X 0 (1+ X)))
    ((>= X 10) NIL)
  (DECLARE (TYPE UNSIGNED-BYTE X))
  (FORMAT T "hello world!"))

Ďalšia expanzia je už veľmi nízkoúrovňová a neprehľadná.

Makro svoje parametre nevyhodnocuje, to je tiež dôležitá vlastnosť, inak by vyššie expandované makro do nemohlo napríklad obsahovať zoznam (X 0 (1+ X)), pretože symbol X by musel byť funkcia alebo makro.

S týmto poznatkom ľahko zistíme ďalšiu vec, na ktorú treba pamätať: každý argument vyhodnocovať len jedenkrát. Pokiaľ by sme použili argument dva alebo viackrát, mohla by nastať podobná situácia ako v nasledujúcom príklade:

 
;; zlé makro
(defmacro sucet (n)
  `(+ ,n ,n))
;; zavoláme ho s nepredpokladaným argumentom
(sucet (random 10))
;; argument sa nevyhodnocuje, preto je výsledok súčet náhodných čísel
(+ (RANDOM 10) (RANDOM 10))

Takže makro skúsime opraviť:

(defmacro sucet (n)
  `(let ((x ,n))
     (+ x x)))
;; nová expanzia
(LET ((X (RANDOM 10)))
  (+ X X))

Toto už je korektné, no spravme si ďalšie nezmyselné makro, ktoré je ale účelovo trošku zložitejšie:

(defmacro sucet (m n)
  `(let ((x ,n))
     (+ ,m x x)))
;; expandujeme v takomto prostredí
(let ((x 10))
  (sucet x (random 10)))
;; vo výsledku pôvodné x nefiguruje a súčet teda môže byť aj menší než 10
(LET ((X (RANDOM 10)))
  (+ X X X))

Na vyriešenie tohoto problému potrebujeme naozaj náhodné meno premennej, to dostaneme zavolaním gensym:

;; toto makro si vygeneruje unikátny symbol
(defmacro sucet (m n)
  (let ((x (gensym)))
    `(let ((,x ,n))
       (+ ,m ,x ,x))))
;; a ten sa vo vyššie uvedenom prostredí naozaj dosadí za x
(LET ((#:G1724 (RANDOM 10)))
  (+ X #:G1724 #:G1724))

Toto je tak časté, že sa všeobecne používa makro, ktoré toto spraví za nás:

(defmacro with-gensyms ((&rest names) &body body)
  `(let ,(loop for n in names collect `(,n (gensym)))
     ,@body))
 
;; nová verzia
(defmacro sucet (m n)
  (with-gensyms (x)
    `(let ((,x ,n))
       (+ ,m ,x ,x)))))

Makrá dokážu rozkladať parametre, preto makro with-gensyms môže prijať zoznam names takýmto spôsobom. &body v makre je zameniteľné s &rest, ďalej makro môže tiež prijímať rovnaké typy argumentov ako funkcia. Pridaný typ je &whole, ktorý uloží celé volanie makra do premennej a za ním môže nasledovať obyčajný zoznam argumentov.

Ďalšia dôležitá vec je poradie vyhodnocovania vo výslednom kóde – pokiaľ nie je dôvod robiť inak, vyhodnocujeme rovnakom poradí ako sú zadané (v súlade so zbytkom jazyka).

;; nesprávne poradie produkuje nepredpokladané výsledky
(defmacro sucet (x y)
  `(+ ,y ,x))
;; očakávame súčet 6 a 5
(let ((a 5))
  (sucet (incf a) (decf a)))
;; a dostaneme pravý opak
(+ (DECF A) (INCF A))
;; čo je samozrejme 9

Makro kombinujúce generovanie symbolov, vyhodnotenie jedenkrát a v prípade správneho uvedenia argumentov aj vyhodnocovanie zľava doprava je once-only (obsahuje ho napr. balíček cl-utilities):

(defmacro once-only ((&rest names) &body body)
  (let ((gensyms (loop for n in names collect (gensym))))
    `(let (,@(loop for g in gensyms collect `(,g (gensym))))
      `(let (,,@(loop for g in gensyms for n in names collect ``(,,g ,,n)))
        ,(let (,@(loop for n in names for g in gensyms collect `(,n ,g)))
           ,@body)))))

Toto makro je trochu ťažšie pochopiteľné, v skratke: vytvorí si sadu symbolov, do ktorých si vygeneruje symboly, ktoré budú použité na uloženie hodnôt premenných a nakoniec priradí menám premenných symboly obsahujúce ich pôvodné hodnoty (a preto ich mená môžeme použiť v tele makra).

To je pre túto časť všetko, nabudúce si stručne povieme v čom sú OOP a výnimky v Common Lispe odlišné od tradičnejších jazykov.