A LISP programozási nyelv

Makrók

Lisp: A programozható nyelv

Bár sokmindent csinál a Lisp máshogyan, mint az egyéb, elterjedt programozási nyelvek (legyenek azok akár imperatívak, akár funkcionálisak, akár deklaratívak), mégis van a Lisp nyelvcsaládnak egyetlen jól meghatározható tulajdonsága, amely alapvetően különbözteti meg más nyelvektől: ez pedig a programozhatóság.

De mit is jelent egy programozási nyelv programozhatósága? Azt, hogy a nyelvet a nyelv eszközeivel bővíthetjük, változtathatjuk. Egy valódi Lisp program általában úgy épül fel, hogy először egy csomó, az adott probléma megoldásának leírásában hasznos nyelvi konstrukciót vezetünk be, majd utána a tényleges megoldást már ezen az így kiterjesztett "Lisp'" nyelven fogalmazzuk meg. Az alábbiakban kiderül, hogyan is történik ez a gyakorlatban.

Program és adat

Amikor új nyelvi elemekkel akarjuk bővíteni a Lispet, valójában olyan programokat kell írnunk, amelyeknek a bemenete és a kimenete is programkód. Természetesen megtehetjük például C++ nyelven is, hogy írunk egy programot, aminek a bemenete és a kimenete is C++ nyelvű kód, majd ezt, mint elő-fordítót, ráeresztjük a programunkra a tényleges C++ fordító előtt. Mégsem csinál szinte senki ilyet. Mi ennek az oka?

Elsősorban az, hogy egy ilyen programnak nagy az infrastrukturális igénye. Be kell parzolnunk a C++ programot valamilyen belső reprezentációba, majd végre kell hajtani a teljes típuskalkulust, és csak ezután jön a lényegi lépés, a program belső reprezentációján végzett transzformáció. Ezekután a parzolás ellentéte következne, amivel újra megkapjuk szöveg-formátumban a feldolgozás eredményét. Egy következő lépés lenne még mindennek az integrációja a build-folyamatba.

Ezzel szemben a Lisp programoknak van egy jóldefiniált, standardizált reprezentációja, a fordító- és interpreterprogramok pedig felületet nyújtanak, amin keresztül beregisztrálhatjuk átalakító-programjainkat, hogy azok a kódfa megfelelő rész-fáira automatikusan aktiválódjanak.

A szövegesen S-kifejezésekkel reprezentált programszövegből a beolvasó legelőször egy cons-cellákból felépített, láncolt listákkal reprezentált fát épít, és csak utána történik bármiféle értelmezés, fordítás. Ez azért fontos, mert így a beolvasás után már ugyanazokkal az eszközökkel manipulálhatjuk a forráskódot, mint amikkel egyébként bármilyen más listát manipulálunk. Lássunk egy példát: a következőkben egy elágazás S-kifejezését látjuk, alatta pedig azt a kódot, ami futásidőben létrehozza azt a fát, amit a beolvasó is létrehozna a fenti kifejezésből:

(IF (> x 5) (FOO) (BAR x)) (LIST (QUOTE IF) (LIST (QUOTE >) (QUOTE x) 5) (LIST (QUOTE FOO)) (LIST (QUOTE BAR) (QUOTE x)))

Az olvashatóság érdekében előszöris a QUOTE makró helyett használhatjuk az aposztróf karaktert, másrészt az aposztróf listákra is működik, és pont a fenti kóddal ekvivalens a működése. A fenti példa második kifejezése tehát átírható az alábbi formára, amiből rögtön látszik is, hogy mennyire közeli a kapcsolat a program és belső reprezentációja között:

'(IF (> x 5) (FOO) (BAR x))

Programkód generálása: makrók

A fentiek értelmében tehát egy makró egy speciális függvény, amelynek a bemenete és a kimenete programkód-fa. Makrókat ennek megfelelően a függvényekhez hasonlóan definiálhatunk, a DEFMACRO makró segítségével. Ahogy a függvényeknek formális paraméterei vannak, úgy a makróknak paraméter helyett szintaxisuk van, vagyis a programfának egy alakja, amiben az egyes részfák a makróalkalmazás helyének megfelelő értéket vesznek fel. Ezt a programfa-alakot a függvények formális paramétereihez hasonlóan specifikáljuk:

(DEFMACRO makrónév (szintaxis) kifejezés)

A kifejezés értékének egy fának kell lennie, amely értelmes Lisp programot reprezentál (természetesen további makróalkalmazásokat is tartalmazhat). Az alábbi példa szekvenciává lapítva valósítja meg a fordítási időben ismert iterációt tartalmazó ciklust:

(DEFUN sokszoroz (n kifejezes) (LOOP FOR i FROM 1 TO n COLLECT kifejezes)) (DEFMACRO lapitott-ciklus (n &body body) (CONS 'PROGN (sokszoroz n (CONS 'PROGN body))))

A szintaxis-specifikációban a &body kulcsszó azt jelenti, hogy a második paramétertől kezdve tetszőleges számú paraméter megengedett, és ezek összessége lesz, mint lista, a body formális paraméter aktuális értéke.

Fontos kihangsúlyozni, hogy a makró nem a paraméterek értékét kapja meg, hanem a programkód-fát. Hiszen ha nem így lenne, akkor a fenti makró a kimenetére n-szer csak a törzsének értékét tudná írni, ami nyilván nem azonos azzal, mintha n-szer megismételjük a törzsben szereplő kifejezés kiértékelését. Ez természetesen azt is jelenti, hogy az n értéke literális szám-érték kell hogy legyen. Ugyanakkor vegyük észre azt is, hogy makrókból szabadon hívhatunk tetszőleges függvényeket, és ezek a függvények fordítási időben fognak lefutni.

A fenti makródefiníció alapján a további két program teljesen ekvivalens (sőt, a fordító az elsőből közvetlenül a másodikat generálja):

(LAPITOTT-CIKLUS 5 (FORMAT T "Hello World!")) (PROGN (PROGN (FORMAT T "Hello World!")) (PROGN (FORMAT T "Hello World!")) (PROGN (FORMAT T "Hello World!")) (PROGN (FORMAT T "Hello World!")) (PROGN (FORMAT T "Hello World!")))

Ha szeretnénk ellenőrizni, hogy a makrónk valóban így viselkedik, a MACROEXPAND-1 nevű függvénnyel kiértékelhetünk egy makróalkalmazást anélkül, hogy a makró kimenete programként értelmeződne. Az alábbi kifejezés értéke tehát a fenti, ötsoros PROGN:

(MACROEXPAND-1 '(LAPITOTT-CIKLUS 5 (FORMAT T "Hello World!")))

Idézőjelezés

Mivel általában a makrók jelentős részét teszi ki kódfa-vázba való behelyettesítés, az imént ismertetett '-hoz hasonlóan egyéb, speciális szintaxis segíti az ilyenek olvasható leírását.

A fordított aposztróf, `(backquote) viselkedése megegyezik az ismertetett aposztróféval, egyetlen kivétellel: ilyenkor használható belül a vessző, ,(unquote) is, ami az aposztróf ellentéte: az őt követő kifejezést mégis kiértékeli, és az eredményt teszi az éppen épülő listába. Egy speciális esete a ,@(splicing unquote), amikor a kifejezés értéke egy lista, és mi azt szeretnénk, hogy ez belapítva kerüljön a kimenetbe.

Az előbbi makrónk így átírható a következő, sokkal olvashatóbb és kényelmesebb formába:

(DEFMACRO lapitott-ciklus (n &body body) `(PROGN ,@(sokszoroz n `(PROGN ,@body))))

Példák

nil!

Tegyük fel, hogy sokszor kell változókat nil-re állítanunk a programunkban, és ezért ezt szeretnénk lerövidíteni. Ekkor definiálhatjuk pl. a következő makrót:

(defmacro nil! (var) (list 'setq var nil))
Ha ezután azt írjuk, hogy (nil! x), akkor ennek az lesz az eredménye, mintha azt írtuk volna, hogy (setq x nil).

nif

Bevezethetünk a Fortran programozási nyelv aritmetikai if-jéhez hasonló szerkezetet:

(defmacro nif (expr pos zero neg) `(case (truncate (signum ,expr)) (1 ,pos) (0 ,zero) (-1 ,neg)))
Ennek az ifnek három ága lesz, és az első paraméter előjelétől függően (negatív, nulla, pozitív) fog végrehajtódni valamelyik ág. Végiggondolhatjuk, hogy backquote nélkül ezt mennyivel kellemetlenebb lenne megírni. A nif-et egy függvénnyel nem valósíthattuk volna meg; mindenképpen makróra van szükségünk, ugyanis egy függvény meghívásakor először kiértékelődik az összes paraméter, de mi most csak egyet akarunk kiértékelni a három ág közül. Ugyanez a helyzet minden olyan operátor megvalósításakor, aminek a törzsében kifejezések vannak, amelyek közül vagy nem mindegyiket akarjuk kiértékelni, vagy esetleg némelyiket többször. Ilyenek pl. még a ciklusok, illetve a let-hez hasonló kötések.

when-bind

Utóbbira példa a when-bind makró:

(defmacro when-bind ((var expr) &body body) `(let ((,var ,expr)) (when ,var ,@body)))
Ez a makró az expr paraméter értéke, mint feltétel alapján dönti el, hogy végrehajtsa-e a törzsében lévő utasításokat, továbbá az expr értékét hozzáköti a var változóhoz. A &body jelölés azt jelenti, hogy a további paraméterek kerüljenek bele egy listába, a ,@body jelölés pedig kibontja ezt a listát a megfelelő helyen.

With- makrók

Létrehozhatunk olyan nyelvi konstrukciókat, amelyek hasonlítanak a C# using kulcsszavához. Ilyen pl. a with-open-file, amely megnyit egy fájlt, hozzáköti egy változóhoz, kiértékeli a törzsében lévő kifejezéseket, majd lezárja a fájlt. Utóbbi akkor is megtörténik, ha a törzs kiértékelése során kivétel keletkezik, vagy bármi egyéb módon kiugrik a vezérlés a törzsből. Ezt az unwind-protect speciális operátor használatával éri el, amelynek megadható két blokk, melyek közül a második megfelel egy Java-ból vagy C#-ból ismert finally blokknak.

Általánosított változók

A setf beépített makró a setq-nak egy általánosítása: az első argumentuma nem csak változó lehet, hanem egy hívás is. Egy (setf x y) hívást értelmezhetünk a következőképpen: “x kiértékelésének eredménye legyen y”. Ennek eléréséhez a setf megnézi az x-et, és ha az csak egy szimbólum, akkor egy setq-t készít. Azonban ha változó helyett egy olyan függvényhívást talál ott, ami valamilyen adat kiolvasását végzi el, akkor a függvény “inverzét” írja be, ami ugyanezen adat írását teszi meg. Pl. a (setf (car lst) 480) hívás az alábbira bomlik ki: (progn (rplaca lst 480) 480). (Az rplaca függvény a lista első elemét átállító függvény.) Minden gyakran használt hozzáférő függvénynek van előre definiált inverze, pl. a car, cdr, nth, aref, get, gethash függvényeknek, illetve a defstruct által létrehozott kiolvasó függvényeknek is. Általánosított változóknak hívják azokat a kifejezéseket, amelyek lehetnek a setf első argumentumai. Ez valamelyest hasonlít a C++ referenciáira: ha pl. egy int-eket tároló adatstruktúránknak egy hozzáférő függvényének a visszatérési típusa int helyett int&, akkor az azt jelenti, hogy a függvényhívás szerepelhet egy értékadás bal oldalán. Azonban az általánosított változók -a C++ referenciákkal ellentétben- olyankor is használhatóak, ha valamilyen bonyolultabb művelet szükséges az adatstruktúránk frissítéséhez. Gondoljunk például a set körüli problémákra: A find függvény olyan iterátort ad vissza, amelyen keresztül nem lehet módosítani a megtalált értéket. (A C++ szabvány szerint ugyan van egy olyan overloadja a find függvénynek, aminek a visszatérési típusa nem látszik const_iterator-nek, azonban furcsa módon mégsem lehet rajta keresztül módosítani a mutatott értéket.) Ennek az az oka, hogy nincs jó megoldás annak elérésére, hogy ha a mutatott értéket valaki megváltoztatja az iterátoron keresztül, akkor lefusson az a kód, ami frissíti a set által belsőleg használt keresőfa szerkezetét. Lispben könnyedén definiálhatnánk a find inverzét, és akkor használhatnánk setf-ben.

Toggle

A setf-re építve írhatunk egyéb makrókat is, pl.:

(defmacro toggle (obj) ‘(setf ,obj (not ,obj)))
Ez a makró negálja a megadott kifejezés által hivatkozott értéket.

Def-memoized-function

Ezt a makrót ugyanúgy használhatjuk, ahogyan a defun-t haszáljuk, azonban az így létrejött fügvényeknek van egy extrájuk: minden híváskor megnézik, hogy nem volt-e esetleg már korábban meghívva a függvény ugyanezekkel a paraméterekkel, és amennyiben igen, akkor most már nem fut le, hanem rögtön visszakapjuk az akkor kiszámolt és eltárolt értéket. (Egyébként hasonló dolgot csinálhatunk más nyelvekben is: C++: http://slackito.com/2011/03/17/automatic-memoization-in-cplusplus0x/ . Azonban az itt leírt változat (azon túl, hogy nem illeszkedik bele olyan szépen a nyelvbe, mint a Lispes megoldás) nem kezeli a rekurzív függvényeket. A Python megoldás viszont egészen elegáns: http://wiki.python.org/moin/PythonDecoratorLibrary#Memoize )

Anaforikus makrók

Az anaforák olyan szavak, amelyek visszahivatkoznak a szövegnek egy korábbi szavára. Az anaforikus makrók olyan változóhoz kötnek hozzá értéket, amelynek nem a makró használatakor adjuk meg a nevét, hanem a makró definíciójakor lett rögzítve.

Aif

Előfordulhat, hogy valami hosszú számolás után csak akkor szeretnénk további műveleteket végezni az eredménnyel ha az nem null lett. Ekkor el kell tárolnunk egy változóba a számolás eredményét, egy if-fel ellenőrizni, hogy nem null-e, majd elvégezni a további műveleteket, ahol hivatkozunk az eredményt tároló változóra. Ez kinézhet pl. a következőképpen:

(let ((result (big-long-calculation))) (if result (foo result)))
Ehelyett az aif makró segítségével írhatjuk a következőt:
(aif (big-long-calculation) (foo it))
Figyeljük meg, hogy az it változóba automatikusan bekerült a számolás eredménye. Az aif makrót definiálhatjuk a következőképpen:
(defmacro aif (test-form then-form &optional else-form) ‘(let ((it ,test-form)) (if it ,then-form ,else-form)))

aand

Az aand makró az and-re hasonlít, de a második argumentumtól kezdve az it változóhoz mindig hozzá van kötve az előző argumentum eredménye. Pl. egy adatbázisból való lekérdezésnél megspórolhatunk sok egymásba ágyazott, null értékeket kezelő if-et:

(aand (owner x) (address it) (town it))
Itt az owner függvény lekérdezi az x tulajdonosát, majd ha ez volt neki, akkor az address függvény lekérdezi a tulajdonos címét, aztán ha van cím, akkor a town függvény lekérdezi, hogy ez melyik városban van. (Haskellben hasonló problémák kezelésére használható a Maybe, illetve F#-ban az Option.)

alambda

A lambda szimbólummal bevezethetünk névtelen függvényeket. Ezek általában nem lehetnek rekurzívak, mivel a törzsükben nem tudjuk leírni a nevüket, mivelhogy nincs nekik. Viszont az alambda makró használata esetén a self szimbólum értéke mindig az éppen definiálás alatt levő függvény lesz. Pl. a faktoriális függvényt definiálhatjuk az alambda-val rekurzívan:

(alambda (x) (if (= x 0) 1 (* x (self (1- x)))))

alrec

Az alrec makró ahhoz hasonlít, mint amikor a Haskell foldl függvényének egy lambdát adunk át, azonban a lambdával létrehozandó függvény paramétereinek nevei kötöttek, és így rövidebb és tisztább kódot kapunk:

(alrec (and (oddp it) rec) t)
Az it az aktuális elemre vonatkozik, míg a rec az eddigi számítások eredményére. (Egyébként ez kicsit hasonlít a Boost::lambdához, aholis a lambda-függvényünk paramétereit szintén nem kell elneveznünk, hanem hivatkozhatunk rájuk pozíció szerint _1, _2, stb-vel.)

További lehetőségek: beolvasási makrók, makrószimbólumok

A fent ismertetett, "függvényhívás-szerűen" alkalmazható makrókon kívül léteznek másfajta makrók is. Programozható például maga a beolvasó is, a megismert ' és ` karakterek speciális értelmezése is így van implementálva (tehát ha nem lennének, akár magunk is bevezethetnénk a nyelvbe). Egy másik lehetőség, hogy konkrét szimbólumok előfordulásához kössünk makrókat, így aztán megoldható, hogy ne (FOO), hanem FOO formában használhassuk a makrót (persze ilyenkor értelemszerűen nem lehetnek paraméterei).

Ezeknek, és az egyéb makró-lehetőségeknek a bemutatása nem képezi ezen bevezető tárgyát, az érdeklődő a szakirodalomban olvashat utánuk, például Paul Graham On Lisp című könyvében.