Macro's: Je eigen macro's definiëren
Misschien is de grootste barrière voor een correct begrip van macro's ironisch genoeg dat ze zo goed in de taal zijn geïntegreerd. In veel opzichten lijken ze op een vreemd soort functie: ze zijn geschreven in Lisp, ze nemen argumenten en geven resultaten terug, en ze stellen je in staat om afleidende details weg te abstraheren. Toch, ondanks deze overeenkomsten, opereren macro's op een ander niveau dan functies en creëren ze een totaal ander soort abstractie.
Zodra je het verschil tussen macro's en functies begrijpt, zal de nauwe integratie van macro's in de taal een groot voordeel zijn. Maar ondertussen is het een frequente bron van verwarring voor nieuwe Lisp-gebruikers. Het volgende verhaal, hoewel niet waar in historische of technische zin, probeert deze verwarring weg te nemen door je een manier te geven om na te denken over hoe macro's werken.
Het verhaal van Mac: Een "Just-So" verhaal
Er was eens, lang geleden, een groep Lisp-programmeurs. Het was zo lang geleden dat Lisp nog geen macro's had. Alles wat niet met een functie kon worden gedefinieerd of met een speciale operator kon worden gedaan, moest elke keer volledig worden uitgeschreven, wat behoorlijk vermoeiend was.
Helaas waren de programmeurs in dit bedrijf — hoewel briljant — ook vrij lui. Vaak schreven ze midden in hun programma's, wanneer de eentonigheid van het schrijven van een hoop code te veel werd, in plaats daarvan een notitie waarin ze de code beschreven die ze op die plek in het programma moesten schrijven. Wat nog erger was: omdat ze lui waren, haatten de programmeurs het om terug te gaan en daadwerkelijk de code te schrijven die door de notities werd beschreven. Al snel had het bedrijf een grote stapel programma's die niemand kon uitvoeren, omdat ze vol stonden met notities over code die nog geschreven moest worden.
Uit wanhoop huurden de grote bazen een junior programmeur in, Mac, wiens taak het was om de notities te vinden, de vereiste code te schrijven en deze in het programma op de plek van de notities in te voegen. Mac voerde de programma's nooit uit — ze waren natuurlijk nog niet af, dus dat kon hij niet. Maar zelfs als ze voltooid waren geweest, had Mac niet geweten welke inputs hij aan hen moest voeren. Dus schreef hij zijn code gewoon op basis van de inhoud van de notities en stuurde deze terug naar de oorspronkelijke programmeur.
Met de hulp van Mac waren alle programma's snel voltooid en verdiende het bedrijf een fortuin met de verkoop ervan — zoveel geld dat het bedrijf seu personeelsbestand kon verdubbelen. Maar om redenen die onbekend zijn, dacht niemand eraan om iemand in te huren om Mac te helpen; al snel hielp hij in zijn eentje tientallen programmeurs. Om te voorkomen dat hij al zijn tijd zou besteden aan het zoeken naar notities in de broncode, bracht Mac een kleine wijziging aan in de compiler die de programmeurs gebruikten. Vanaf dat moment, telkens wanneer de compiler op een notitie stuitte, e-mailde hij de notitie naar Mac en wachtte hij tot Mac de vervangende code terug-e-mailde.
Helaas had Mac, zelfs met deze wijziging, moeite om het tempo van de programmeurs bij te houden. Hij werkte zo zorgvuldig mogelijk, maar soms — vooral wanneer de notities onduidelijk waren — maakte hij fouten. De programmeurs merkten echter op dat hoe preciezer ze hun notities schreven, hoe groter de kans was dat Mac correcte code terugstuurde. Op een dag voegde een van de programmeurs, die er moeite mee had om de gewenste code in woorden te beschrijven, een Lisp-programma toe aan een van zijn notities dat de gewenste code zou genereren. Dat was prima voor Mac; hij voerde gewoon het programma uit en stuurde het resultaat naar de compiler.
De volgende innovatie kwam toen een programmeur bovenaan een van zijn programma's een notitie plaatste met een functiedefinitie en een opmerking die zei: "Mac, schrijf hier geen code, maar bewaar deze functie voor later; ik ga hem gebruiken in enkele van mijn andere notities." Andere notities in hetzelfde programma zeiden dingen als: "Mac, vervang deze notitie door het resultaat van het uitvoeren van die andere functie met de symbolen x en y als argumenten."
Deze techniek sloeg zo snel aan dat de meeste programma's binnen enkele dagen tientallen notities bevatten die functies definieerden die alleen werden gebruikt door code in andere notities. Om het voor Mac makkelijker te maken om de notities te herkennen die alleen definities bevatten en geen onmiddellijke reactie vereisten, markeerden de programmeurs ze met het standaardvoorvoegsel: "Definition for Mac, Read Only." Dit werd — omdat de programmeurs nog steeds vrij lui waren — snel ingekort tot "DEF. MAC. R/O" en daarna tot "DEFMACRO".
Al snel was er geen Engels meer over in de notities voor Mac. Alles wat hij de hele dag deed, was reageren op e-mails van de compiler met DEFMACRO-notities en aanroepen van de functies die in de DEFMACRO's waren gedefinieerd. Omdat de Lisp-programma's in de notities al het echte werk deden, was het bijhouden van de e-mails geen probleem. Mac had plotseling veel tijd over en zat in zijn kantoor te dagdromen over witte zandstranden, helderblauw oceaanwater en drankjes met kleine papieren paraplu's erin.
Enkele maanden later realiseerden de programmeurs zich dat niemand Mac al een hele tijd niet had gezien. Toen ze naar zijn kantoor gingen, vonden ze alles bedekt met een dun laagje stof, een bureau bezaaid met reisbrochures voor verschillende tropische locaties en de computer uitgeschakeld. Maar de compiler werkte nog steeds — hoe kon dat? Het bleek dat Mac één laatste wijziging aan de compiler had aangebracht: in plaats van notities naar Mac te e-mailen, bewaarde de compiler nu de functies die door DEFMACRO-notities waren gedefinieerd en voerde ze uit wanneer dat werd gevraagd door andere notities. De programmeurs besloten dat er geen reden was om de grote bazen te vertellen dat Mac niet meer naar kantoor kwam. Dus tot op de dag van vandaag ontvangt Mac een salaris en stuurt hij af en toe een ansichtkaart vanuit een tropische locatie.
Macro-expansietijd versus Runtime
De sleutel tot het begrijpen van macro's is om heel duidelijk te zijn over het onderscheid tussen de code die code genereert (macro's) en de code die uiteindelijk het programma vormt (alles wat overblijft). Wanneer je macro's schrijft, schrijf je programma's die door de compiler worden gebruikt om de code te genereren die vervolgens wordt gecompileerd. Pas nadat alle macro's volledig zijn uitgebreid ("expanded") en de resulterende code is gecompileerd, kan het programma daadwerkelijk worden uitgevoerd. De tijd waarin macro's draaien wordt macro-expansietijd genoemd; dit is verschillend van runtime, wanneer reguliere code, inclusief de door macro's gegenereerde code, wordt uitgevoerd.
Het is belangrijk om dit onderscheid stevig in gedachten te houden, omdat code die tijdens de macro-expansietijd draait, in een zeer andere omgeving draait dan code tijdens runtime. Namelijk: tijdens de macro-expansietijd is er geen manier om toegang te krijgen tot de data die tijdens runtime zal bestaan. Net als Mac, die de programma's waar hij aan werkte niet kon uitvoeren omdat hij niet wist wat de juiste inputs waren, kan code die draait tijdens de macro-expansietijd alleen omgaan met de data die inherent is aan de broncode.
Stel bijvoorbeeld dat de volgende broncode ergens in een programma voorkomt:
(defun foo (x)
(when (> x 10) (print 'big)))
Normaal gesproken zou je x beschouwen als een variabele die het argument bevat dat wordt meegegeven bij een aanroep van foo. Maar tijdens de macro-expansietijd, zoals wanneer de compiler de WHEN-macro uitvoert, is de enige beschikbare data de broncode. Omdat het programma nog niet draait, is er geen aanroep naar foo en dus geen waarde geassocieerd met x. In plaats daarvan zijn de waarden die de compiler aan WHEN doorgeeft de Lisp-lijsten die de broncode representeren, namelijk: (> x 10) and (print 'big).
Stel dat WHEN is gedefinieerd met iets als de volgende macro:
(defmacro when (condition &rest body)
`(if ,condition (progn ,@body)))
Wanneer de code in foo wordt gecompileerd, wordt de WHEN-macro uitgevoerd met die twee vormen als argumenten. De parameter condition wordt gebonden aan de vorm (> x 10), en de vorm (print 'big) wordt verzameld in een lijst die de waarde van de &rest body parameter wordt. De backquote-expressie genereert vervolgens deze code:
(if (> x 10) (progn (print 'big)))
door de waarde van condition te interpoleren en de waarde van body in de PROGN te splicen.
Wanneer Lisp wordt geïnterpreteerd in plaats van gecompileerd, is het onderscheid tussen macro-expansietijd en runtime minder duidelijk omdat ze tijdelijk met elkaar verweven zijn. Ook specificeert de taalstandaard niet precies hoe een interpreter macro's moet behandelen — hij zou alle macro's in de vorm die wordt geïnterpreteerd kunnen expanderen en vervolgens de resulterende code kunnen interpreteren, of hij zou direct kunnen beginnen met het interpreteren van de vorm en macro's expanderen zodra hij erop stuit. In beide gevallen krijgen macro's altijd de niet-geëvalueerde Lisp-objecten door die de subvormen van de macro-vorm representeren, en de taak van de macro is nog steeds om code te produceren die iets zal doen, in plaats van zelf direct iets te doen.
DEFMACRO
Macro's worden daadwerkelijk gedefinieerd met DEFMACRO-vormen (wat staat voor DEFine MACRO). Het basisframe van een DEFMACRO lijkt sterk op dat van een DEFUN.
(defmacro naam (parameter*)
"Optionele documentatiestring."
body-form*)
Net als een functie bestaat een macro uit een naam, een parameterlijst, een optionele documentatiestring en een body van Lisp-expressies. [^1]
Echter, zoals eerder besproken, is de taak van een macro niet om direct iets te doen — zijn taak is om code te genereren die later doet wat je wilt. Macro's kunnen de volledige kracht van Lisp gebruiken om hun expansie te genereren. Er is een algemeen proces voor het schrijven van macro's dat werkt voor alle macro's, van de eenvoudigste tot de meest complexe.
De taak van een macro is om een macro-vorm — met andere woorden, een Lisp-vorm waarvan het eerste element de naam van de macro is — te vertalen naar code die een bepaalde actie uitvoert. Soms begin je een macro te schrijven met de code die je zou willen kunnen schrijven (een voorbeeld macro-vorm). Andere keren besluit je een macro te schrijven nadat je hetzelfde patroon aan code meerdere keren hebt geschreven en beseft dat je je code duidelijker kunt maken door het patroon te abstraheren.
Ongeacht waar je begint, moet je het andere uiteinde bepalen voordat je aan de macro kunt beginnen: je moet weten waar je vandaan komt en waar je naartoe gaat voordat je code kunt schrijven om dit automatisch te doen. De stappen voor het schrijven van een macro zijn als volgt:
- Schrijf een voorbeeld van een aanroep tot de macro en de code waartoe die aanroep zou moeten expanderen, of andersom.
- Schrijf code die de handgeschreven expansie genereert vanuit de argumenten in de voorbeeldoproep.
- Zorg ervoor dat de abstractie van de macro geen details van de implementatie "lekt".
Een voorbeeldmacro: do-primes
Om te zien hoe dit driestappenproces werkt, schrijven we een macro do-primes die een looping-construct biedt vergelijkbaar met DOTIMES en DOLIST, behalve dat het niet itereert over integers of elementen van een lijst, maar over opeenvolgende priemgetallen.
Eerst heb je twee hulpfuncties nodig: één om te testen of een getal priem is en een andere die het volgende priemgetal retourneert dat groter of gelijk is aan het argument.
(defun primep (number)
(when (> number 1)
(loop for fac from 2 to (isqrt number) never (zerop (mod number fac)))))
(defun next-prime (number)
(loop for n from number when (primep n) return n))
Nu kunnen we de macro schrijven. Stel dat je het volgende wilt kunnen schrijven:
(do-primes (p 0 19)
(format t "~d " p))
Om een loop uit te drukken die de body één keer uitvoert voor elk priemgetal tussen 0 en 19, waarbij de variabele p het priemgetal bevat. Zonder de do-primes macro zou je zo'n loop met DO (en de twee hulpfuncties) als volgt schrijven:
(do ((p (next-prime 0) (next-prime (1+ p))))
((> p 19))
(format t "~d " p))
Macro Parameters
Omdat de argumenten die aan een macro worden doorgegeven Lisp-objecten zijn die de broncode van de macro-aanroep representeren, is de eerste stap het extraheren van de delen die nodig zijn om de expansie te berekenen.
Voor do-primes is het eerste argument een lijst met de naam van de loopvariabele (p), de ondergrens (0) en de bovengrens (19). In de uiteindelijke expansie wordt deze lijst echter uit elkaar gehaald. Je zou dit handmatig kunnen doen:
(defmacro do-primes (var-and-range &rest body)
(let ((var (first var-and-range))
(start (second var-and-range))
(end (third var-and-range)))
`(do ((,var (next-prime ,start) (next-prime (1+ ,var))))
((> ,var ,end))
,@body)))
Je hoeft var-and-range echter niet handmatig uit elkaar te halen, omdat macro-parameterlijsten zogenaamde destructuring parameter lists zijn. Destructurering houdt in dat een structuur — in dit geval de lijststructuur van de vormen die aan een macro worden doorgegeven — wordt ontleed.
Je kunt var-and-range vervangen door een lijst (var start end), en de drie elementen van de lijst worden automatisch verdeeld over deze drie parameters. Een andere speciale functie is dat je &body als synoniem voor &rest kunt gebruiken; dit helpt veel development environments om de inspringing van de macro correct weer te geven.
De verbeterde definitie ziet er zo uit:
(defmacro do-primes ((var start end) &body body)
`(do ((,var (next-prime ,start) (next-prime (1+ ,var))))
((> ,var ,end))
,@body))
Destructurerende parameterlijsten zorgen ook voor automatische foutcontrole; Lisp kan detecteren wanneer de eerste aanroep geen lijst van drie elementen is en geeft een betekenisvolle foutmelding.
De expansie genereren
Voor eenvoudige macro's zoals do-primes is de speciale backquote-syntaxis perfect. Een backquoted expressie is vergelijkbaar met een quoted expressie, behalve dat je specifieke subexpressies kunt "unquoten" door ze vooraf te laten gaan met een komma, eventueel gevolgd door een at (@) teken. Zonder het @-teken wordt de waarde van de subexpressie zoals hij is opgenomen. Met een @-teken wordt de waarde — die een lijst moet zijn — "gespliced" in de omhullende lijst.
Tabel 8-1 laat enkele voorbeelden zien van backquoted expressies.
Tabel 8-1. Backquote Voorbeelden
| Backquote Syntaxis | Equivalent List-Building Code | Resultaat |
|---|---|---|
` (a (+ 1 2) c) `` | (list 'a '(+ 1 2) 'c) | (a (+ 1 2) c) |
` (a ,(+ 1 2) c) `` | (list 'a (+ 1 2) 'c) | (a 3 c) |
` (a (list 1 2) c) `` | (list 'a '(list 1 2) 'c) | (a (list 1 2) c) |
` (a ,(list 1 2) c) `` | (list 'a (list 1 2) 'c) | (a (1 2) c) |
` (a ,@(list 1 2) c) `` | (append (list 'a) (list 1 2) (list 'c)) | (a 1 2 c) |
Om te verifiëren of de macro werkt, kun je de functie MACROEXPAND-1 gebruiken. Deze neemt een Lisp-expressie als argument en retourneert het resultaat van één niveau van macro-expansie. [^3]
CL-USER> (macroexpand-1 '(do-primes (p 0 19) (format t "~d " p)))
(DO ((P (NEXT-PRIME 0) (NEXT-PRIME (1+ P))))
((> P 19))
(FORMAT T "~d " P))
T
De lekken dichten
Een "lekkende abstractie" beschrijft een abstractie die details lekt die hij juist zou moeten verbergen. [^5] Een macro kan op drie manieren lekken.
De huidige definitie van do-primes lijdt aan één van deze lekken: het evalueert de end-subvorm te vaak. Stel dat je de macro aanroept met een expressie zoals (random 100) in de end-positie:
(do-primes (p 0 (random 100))
(format t "~d " p))
In de expansie wordt (RANDOM 100) elke keer aangeroepen dat de test voor de loop wordt uitgevoerd. Dit is een lek, omdat de aanroeper moet weten dat de end-vorm vaker dan één keer wordt geëvalueerd. Om dit te dichten, moeten we code genereren die end één keer evalueert en de waarde opslaat in een variabele.
(defmacro do-primes ((var start end) &body body)
`(do ((ending-value ,end)
(,var (next-prime ,start) (next-prime (1+ ,var))))
((> ,var ending-value))
,@body))
Helaas introduceert deze fix twee nieuwe lekken. Ten eerste worden de initialisatievormen in een DO-loop geëvalueerd in de volgorde waarin ze zijn gedefinieerd; hierdoor wordt end nu vóór start geëvalueerd, wat tegenstrijdig is met de volgorde in de macro-aanroep. Dit lossen we op door de volgorde om te draaien:
(defmacro do-primes ((var start end) &body body)
`(do ((,var (next-prime ,start) (next-prime (1+ ,var)))
(ending-value ,end))
((> ,var ending-value))
,@body))
Het laatste lek is het gebruik van de variabele ending-value. Deze naam kan conflicteren met code die aan de macro wordt doorgegeven of in de context waarin de macro wordt aangeroepen. Als iemand een variabele genaamd ending-value in zijn eigen code heeft, ontstaat er een conflict (shadowing).
De oplossing is de functie GENSYM, die elke keer dat hij wordt aangeroepen een uniek symbool retourneert dat niet geïnterneerd is in enig pakket.
(defmacro do-primes ((var start end) &body body)
(let ((ending-value-name (gensym)))
`(do ((,var (next-prime ,start) (next-prime (1+ ,var)))
(,ending-value-name ,end))
((> ,var ,ending-value-name))
,@body)))
Nu wordt er elke keer dat de macro expandeert een nieuw symbool (zoals #:g2141) aangemaakt, waardoor conflicten onmogelijk zijn. Samenvattend zijn dit de vuistregels voor het voorkomen van lekken:
- Plaats subvormen in de expansie zo dat ze worden geëvalueerd in dezelfde volgorde als waarin ze in de macro-aanroep verschijnen.
- Zorg dat subvormen slechts één keer worden geëvalueerd door een variabele te creëren om de waarde op te slaan.
- Gebruik
GENSYMtijdens de macro-expansie om variabelennamen te creëren die in de expansie worden gebruikt.
Macro-schrijvende Macro's
Je kunt macro's ook gebruiken om andere macro's te schrijven. Een veelvoorkomend patroon is het gebruik van een LET die meerdere gensymed symbolen introduceert. Dit kunnen we abstraheren met een macro genaamd with-gensyms.
(defmacro with-gensyms ((&rest names) &body body)
`(let ,(loop for n in names collect `(,n (gensym)))
,@body))
Nu kunnen we do-primes veel eleganter schrijven:
(defmacro do-primes ((var start end) &body body)
(with-gensyms (ending-value-name)
`(do ((,var (next-prime ,start) (next-prime (1+ ,var)))
(,ending-value-name ,end))
((> ,var ,ending-value-name))
,@body)))
Een andere klassieke macro-schrijvende macro is once-only, die ervoor zorgt dat argumenten precies één keer en in de juiste volgorde worden geëvalueerd. Hiermee zou do-primes bijna net zo simpel kunnen zijn als de oorspronkelijke (lekkende) versie:
(defmacro do-primes ((var start end) &body body)
(once-only (start end)
`(do ((,var (next-prime ,start) (next-prime (1+ ,var))))
((> ,var ,end))
,@body)))
De implementatie van once-only is complexer omdat deze meerdere niveaus van backquoting en unquoting gebruikt. Voor wie zijn macro-vaardigheden wil aanscherpen, ziet de code er als volgt uit:
(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)))))
Voorbij eenvoudige macro's
Alle voorbeelden tot nu toe waren relatief eenvoudig en bedoeld om typewerk te besparen. In komende hoofdstukken zul je macro's zien die radicale nieuwe manieren bieden om dingen uit te drukken, zoals het bouwen van een unit test framework.
***
[^1]: Net als functies kunnen macro's ook declaraties bevatten, maar daar hoef je je voorlopig geen zorgen over te maken. [^2]: APPEND is een functie die een willekeurig aantal lijst-argumenten neemt en deze samenvoegt tot één enkele lijst. [^3]: De functie MACROEXPAND blijft de resultaten expanderen zolang het eerste element van de resulterende expansie de naam van een macro is. Dit geeft vaak een te laag abstractieniveau, omdat basisconstructen zoals DO ook als macro's zijn geïmplementeerd. [^4]: Als de macro-expansie op één regel wordt getoond, komt dat waarschijnlijk door de variabele PRINT-PRETTY. Het uitvoeren van (setf print-pretty t) maakt de expansie leesbaarder. [^5]: Dit is gebaseerd op het essay "The Law of Leaky Abstractions" van Joel Spolsky. Zijn punt is dat alle abstracties tot op zekere hoogte lekken, maar dat betekent niet dat je lekken moet tolereren die eenvoudig te dichten zijn. [^6]: Natuurlijk zijn bepaalde vormen bedoeld om vaker dan één keer geëvalueerd te worden, zoals de vormen in de body van een do-primes loop. [^7]: Het feit dat deze loop noodzakelijkerwijs oneindig is, kan worden bewezen met het postulaat van Bertrand: voor elke $n > 1$ bestaat er een priemgetal $p$ waarvoor geldt $n < p < 2n$.
Groetjes,