Che dire di LISP, semmai, semplifica l'implementazione dei sistemi macro?


21

Sto imparando Scheme dalla SICP e ho l'impressione che gran parte di ciò che rende Scheme e, ancora di più, LISP speciale è il sistema macro. Ma poiché le macro vengono espanse in fase di compilazione, perché le persone non creano sistemi macro equivalenti per C / Python / Java / qualunque cosa? Ad esempio, si potrebbe associare il pythoncomando a expand-macros | pythonqualsiasi cosa. Il codice sarebbe comunque portatile per le persone che non usano il sistema macro, si dovrebbero semplicemente espandere le macro prima di pubblicare il codice. Ma non conosco nulla del genere tranne i modelli in C ++ / Haskell, che raccolgo non sono proprio gli stessi. Che dire di LISP, semmai, semplifica l'implementazione dei sistemi macro?


3
"Il codice sarebbe comunque portabile alle persone che non usano il sistema macro, si dovrebbero semplicemente espandere le macro prima di pubblicare il codice." - solo per avvisarti, questo tende a non funzionare bene. Quelle altre persone sarebbero in grado di eseguire il codice, ma in pratica il codice macro-espanso è spesso difficile da comprendere e di solito difficile da modificare. In effetti è "mal scritto", nel senso che l'autore non ha adattato il codice espanso per gli occhi umani, hanno adattato la vera fonte. Prova a dire a un programmatore Java che esegui il tuo codice Java attraverso il preprocessore C e guarda di che colore girano ;-)
Steve Jessop,

1
Le macro devono essere eseguite, a quel punto stai già scrivendo un interprete per la lingua.
Mehrdad,

Risposte:


29

Molti Lisper ti diranno che ciò che rende speciale Lisp è l' omoiconicità , il che significa che la sintassi del codice è rappresentata usando le stesse strutture di dati di altri dati. Ad esempio, ecco una semplice funzione (usando la sintassi dello Schema) per calcolare l'ipotenusa di un triangolo rettangolo con le lunghezze laterali indicate:

(define (hypot x y)
  (sqrt (+ (square x) (square y))))

Ora, l'omoiconicità afferma che il codice sopra è effettivamente rappresentabile come una struttura di dati (in particolare, elenchi di elenchi) nel codice Lisp. Quindi, considera i seguenti elenchi e vedi come "incollano" insieme:

  1. (define #2# #3#)
  2. (hypot x y)
  3. (sqrt #4#)
  4. (+ #5# #6#)
  5. (square x)
  6. (square y)

Le macro ti consentono di trattare il codice sorgente in questo modo: elenchi di cose. Ognuno di questi 6 "liste parziali" contenere puntatori ad altre liste, o simboli (in questo esempio: define, hypot, x, y, sqrt, +, square).


Quindi, come possiamo usare l'omoiconicità per "separare" la sintassi e creare macro? Ecco un semplice esempio. Ricominciamo la letmacro, che chiameremo my-let. Come promemoria,

(my-let ((foo 1)
         (bar 2))
  (+ foo bar))

dovrebbe espandersi in

((lambda (foo bar)
   (+ foo bar))
 1 2)

Ecco un'implementazione che utilizza le macro di "ridenominazione esplicita" dello Schema :

(define-syntax my-let
  (er-macro-transformer
    (lambda (form rename compare)
      (define bindings (cadr form))
      (define body (cddr form))
      `((,(rename 'lambda) ,(map car bindings)
          ,@body)
        ,@(map cadr bindings)))))

Il formparametro è associato alla forma effettiva, quindi per il nostro esempio lo sarebbe (my-let ((foo 1) (bar 2)) (+ foo bar)). Quindi, esaminiamo l'esempio:

  1. Innanzitutto, recuperiamo i binding dal modulo. cadrprende la ((foo 1) (bar 2))parte del modulo.
  2. Quindi, recuperiamo il corpo dal modulo. cddrprende la ((+ foo bar))parte del modulo. (Si noti che questo ha lo scopo di afferrare tutte le sottomaschere dopo l'associazione, quindi se il modulo fosse

    (my-let ((foo 1)
             (bar 2))
      (debug foo)
      (debug bar)
      (+ foo bar))
    

    allora il corpo sarebbe ((debug foo) (debug bar) (+ foo bar)).)

  3. Ora, in realtà costruiamo l' lambdaespressione risultante e chiamiamo usando le associazioni e il corpo che abbiamo raccolto. Il backtick è chiamato "quasiquote", che significa trattare ogni cosa all'interno del quasiquote come riferimenti letterali, tranne i bit dopo le virgole ("unquote").
    • I (rename 'lambda)mezzi per utilizzare l' lambdaassociazione in vigore quando viene definita questa macro , piuttosto che qualsiasi lambdaassociazione possa essere presente quando viene utilizzata questa macro . (Questo è noto come igiene .)
    • (map car bindings)restituisce (foo bar): il primo dato in ciascuna delle associazioni.
    • (map cadr bindings)restituisce (1 2): il secondo dato in ciascuna delle associazioni.
    • ,@ fa "splicing", che viene usato per le espressioni che restituiscono un elenco: fa sì che gli elementi dell'elenco vengano incollati nel risultato, piuttosto che nell'elenco stesso.
  4. Mettendo tutto insieme, otteniamo, di conseguenza, l'elenco (($lambda (foo bar) (+ foo bar)) 1 2), dove $lambdaqui si fa riferimento al rinominato lambda.

Semplice, vero? ;-) (Se non è semplice per te, immagina quanto sarebbe difficile implementare un sistema macro per altre lingue.)


Quindi, puoi avere sistemi macro per altre lingue, se hai un modo per essere in grado di "separare" il codice sorgente in modo non goffo. Ci sono alcuni tentativi in ​​questo. Ad esempio, sweet.js lo fa per JavaScript.

† Per gli esperti Schemers che leggono questo, ho scelto intenzionalmente di usare macro di rinominazione esplicita come un compromesso intermedio tra i defmacros usati da altri dialetti Lisp e syntax-rules(che sarebbe il modo standard per implementare una tale macro in Scheme). Non voglio scrivere in altri dialetti Lisp, ma non voglio alienare i non-Schemers che non sono abituati syntax-rules.

Per riferimento, ecco la my-letmacro che utilizza syntax-rules:

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

La syntax-caseversione corrispondente sembra molto simile:

(define-syntax my-let
  (lambda (stx)
    (syntax-case stx ()
      ((_ ((id val) ...)
         body ...)
       #'((lambda (id ...)
            body ...)
          val ...)))))

La differenza tra i due è che tutto in syntax-rulesha un implicito #'applicato, quindi puoi solo avere coppie pattern / template syntax-rules, quindi è completamente dichiarativo. Al contrario, in syntax-case, il bit dopo il modello è il codice effettivo che, alla fine, deve restituire un oggetto sintassi ( #'(...)), ma può contenere anche altro codice.


2
Un vantaggio che non hai menzionato: sì, ci sono tentativi in ​​altre lingue, come sweet.js per JS. Tuttavia, in lisps, la scrittura di una macro viene eseguita nella stessa lingua della scrittura di una funzione.
Florian Margaine,

Bene, puoi scrivere macro procedurali (contro dichiarative) nelle lingue Lisp, che è ciò che ti consente di fare cose davvero avanzate. A proposito, questo è quello che mi piace dei sistemi macro Scheme: ce ne sono diversi tra cui scegliere. Per le macro semplici, io uso syntax-rules, che è puramente dichiarativo. Per le macro complicate, posso usare syntax-case, che è in parte dichiarativo e in parte procedurale. E poi c'è la ridenominazione esplicita, che è puramente procedurale. (La maggior parte delle implementazioni dello Schema forniranno uno syntax-caseo entrambi . Non ne ho visto uno che fornisca entrambi. Sono equivalenti in potenza.)
Chris Jester-Young

Perché le macro devono modificare l'AST? Perché non possono lavorare a un livello superiore?
Elliot Gorokhovsky,

1
Allora perché è meglio LISP? Cosa rende speciale LISP? Se si possono implementare macro in js, sicuramente si possono implementare anche in qualsiasi altra lingua.
Elliot Gorokhovsky,

3
@ RenéG come ho detto nel mio primo commento, un grande vantaggio è che stai ancora scrivendo nella stessa lingua.
Florian Margaine,

23

Un'opinione dissenziente: l'omoiconicità di Lisp è molto meno utile di quanto molti fan di Lisp vorrebbero farti credere.

Per comprendere le macro sintattiche, è importante comprendere i compilatori. Il compito di un compilatore è trasformare il codice leggibile in codice eseguibile. Da una prospettiva di altissimo livello, ci sono due fasi generali: analisi e generazione di codice .

L'analisi è il processo di lettura del codice, interpretazione secondo una serie di regole formali e trasformazione in una struttura ad albero, generalmente nota come AST (Abstract Syntax Tree). Per tutta la diversità tra i linguaggi di programmazione, questa è una notevole comunanza: essenzialmente ogni linguaggio di programmazione per scopi generici analizza una struttura ad albero.

La generazione del codice prende l'AST del parser come input e lo trasforma in codice eseguibile tramite l'applicazione di regole formali. Dal punto di vista delle prestazioni, questo è un compito molto più semplice; molti compilatori di linguaggio di alto livello impiegano il 75% o più del loro tempo per l'analisi.

La cosa da ricordare di Lisp è che è molto, molto vecchio. Tra i linguaggi di programmazione, solo FORTRAN è più vecchio di Lisp. Nel lontano passato, l'analisi (la parte lenta della compilazione) era considerata un'arte oscura e misteriosa. Gli articoli originali di John McCarthy sulla teoria di Lisp (quando era solo un'idea che non avrebbe mai pensato potesse essere effettivamente implementato come un vero linguaggio di programmazione per computer) descrivono una sintassi un po 'più complessa ed espressiva rispetto alle moderne "S-espressioni ovunque per tutto "notazione. Ciò è avvenuto più tardi, quando le persone stavano cercando di implementarlo. Dato che all'epoca l'analisi non era ben compresa, in pratica hanno puntato su di essa e hanno attenuato la sintassi in una struttura ad albero omoiconico per rendere il lavoro del parser assolutamente banale. Il risultato finale è che tu (lo sviluppatore) devi fare molto del parser " s lavoraci scrivendo l'AST formale nel tuo codice. L'omoiconicità non "rende le macro molto più facili" tanto quanto rende molto più difficile scrivere tutto il resto!

Il problema è che, specialmente con la tipizzazione dinamica, è molto difficile per le espressioni S trasportare molte informazioni semantiche con sé. Quando tutta la sintassi è dello stesso tipo di cose (elenchi di elenchi), non c'è molto nel modo di contesto fornito dalla sintassi, e quindi il sistema macro ha molto poco con cui lavorare.

La teoria dei compilatori ha fatto molta strada dagli anni '60 quando inventò Lisp e, sebbene le cose che realizzò fossero impressionanti ai suoi tempi, ora sembrano piuttosto primitive. Per un esempio di un moderno sistema di metaprogrammazione, dai un'occhiata al linguaggio Boo (tristemente sottovalutato). Boo è tipicamente statico, orientato agli oggetti e open-source, quindi ogni nodo AST ha un tipo con una struttura ben definita su cui uno sviluppatore di macro può leggere il codice. Il linguaggio ha una sintassi relativamente semplice ispirata a Python, con varie parole chiave che danno un significato semantico intrinseco alle strutture ad albero costruite da esse, e la sua metaprogrammazione ha una sintassi quasiquote intuitiva per semplificare la creazione di nuovi nodi AST.

Ecco una macro che ho creato ieri quando mi sono reso conto che stavo applicando lo stesso modello a un sacco di posizioni diverse nel codice GUI, in cui avrei richiamato BeginUpdate()un controllo UI, avrei eseguito un aggiornamento in un tryblocco e quindi avrei chiamato EndUpdate():

macro UIUpdate(value as Expression):
    return [|
        $value.BeginUpdate()
        try:
            $(UIUpdate.Body)
        ensure:
            $value.EndUpdate()
    |]

Il macrocomando è, infatti, una macro stessa , che accetta un corpo macro come input e genera una classe per elaborare la macro. Utilizza il nome della macro come variabile che rappresenta il MacroStatementnodo AST che rappresenta la chiamata della macro. Il [| ... |] è un blocco di quasiquote, che genera l'AST corrispondente al codice all'interno e all'interno del blocco di quasiquote, il simbolo $ fornisce la funzione "non quotata", sostituendo in un nodo come specificato.

Con questo, è possibile scrivere:

UIUpdate myComboBox:
   LoadDataInto(myComboBox)
   myComboBox.SelectedIndex = 0

e fallo espandere a:

myComboBox.BeginUpdate()
try:
   LoadDataInto(myComboBox)
   myComboBox.SelectedIndex = 0
ensure:
   myComboBox.EndUpdate()

Esprimendo la macro in questo modo è più semplice e più intuitiva di quanto lo sarebbe in una macro Lisp, perché lo sviluppatore conosce la struttura di MacroStatemente sa come la Argumentse Bodyle proprietà di lavoro, e che la conoscenza intrinseca può essere utilizzato per esprimere i concetti coinvolti in modo molto intuitivo modo. È anche più sicuro, perché il compilatore conosce la struttura di MacroStatemente se provi a codificare qualcosa che non è valido per a MacroStatement, il compilatore lo catturerà immediatamente e segnalerà l'errore invece di te che non lo sai fino a quando qualcosa ti esplode in runtime.

L'innesto di macro su Haskell, Python, Java, Scala, ecc. Non è difficile perché queste lingue non sono omoiconiche; è difficile perché le lingue non sono progettate per loro e funziona meglio quando la gerarchia AST della tua lingua è progettata da zero per essere esaminata e manipolata da un sistema macro. Quando lavori con un linguaggio progettato pensando alla metaprogrammazione fin dall'inizio, le macro sono molto più semplici e facili da usare!


4
Gioia da leggere, grazie! Le macro non Lisp si estendono fino a cambiare la sintassi? Poiché uno dei punti di forza di Lisp è che la sintassi è la stessa, quindi è facile aggiungere una funzione, un'istruzione condizionale, qualunque sia perché sono tutti uguali. Mentre con linguaggi non Lisp una cosa differisce da un'altra, if...ad esempio non sembra una chiamata di funzione. Non conosco Boo, ma immagina che Boo non avesse un pattern matching, potresti introdurlo con la sua sintassi come macro? Il punto è che ogni nuova macro in Lisp sembra naturale al 100%, in altre lingue funzionano, ma puoi vedere i punti.
Greenoldman,

4
La storia come l'ho sempre letta è un po 'diversa. È stata pianificata una sintassi alternativa a s-expression ma il lavoro su di essa è stato ritardato perché i programmatori avevano già iniziato a usare s-espressioni e le trovavano convenienti. Quindi il lavoro sulla nuova sintassi è stato infine dimenticato. Puoi per favore citare la fonte che indica le carenze della teoria dei compilatori come la ragione per usare le espressioni s? Inoltre, la famiglia Lisp ha continuato a evolversi per molti decenni (Scheme, Common Lisp, Clojure) e la maggior parte dei dialetti ha deciso di attenersi alle espressioni s.
Giorgio,

5
"più semplice e più intuitivo": scusa, ma non vedo come. "Update.Arguments [0]" non ha senso, preferirei avere un argomento con nome e lasciare che il compilatore si controlli se il numero di argomenti corrisponde: pastebin.com/YtUf1FpG
coredump

8
"Dal punto di vista delle prestazioni, questo è un compito molto più semplice; molti compilatori di linguaggio di alto livello impiegano il 75% o più del loro tempo per l'analisi." Mi sarei aspettato di cercare e applicare ottimizzazioni per la maggior parte del tempo (ma non ho mai scritto un vero compilatore). Mi sto perdendo qualcosa qui?
Doval,

5
Sfortunatamente il tuo esempio non lo dimostra. È primitivo da implementare in qualsiasi Lisp con macro. In realtà questa è una delle macro più primitive da implementare. Questo mi fa sospettare che tu non sappia molto delle macro in Lisp. "La sintassi di Lisp è bloccata negli anni '60": in realtà i sistemi macro di Lisp hanno fatto molti progressi dal 1960 (nel 1960 Lisp non aveva nemmeno macro!).
Rainer Joswig,

3

Sto imparando Scheme dalla SICP e ho l'impressione che gran parte di ciò che rende Scheme e, ancora di più, LISP speciale è il sistema macro.

Come mai? Tutto il codice in SICP è scritto in stile senza macro. Non ci sono macro in SICP. Solo in una nota a pagina 373 vengono mai menzionate le macro.

Tuttavia, poiché le macro vengono espanse in fase di compilazione

Non sono necessariamente. Lisp fornisce macro sia per interpreti che per compilatori. Quindi potrebbe non esserci un tempo di compilazione. Se si dispone di un interprete Lisp, le macro vengono espanse al momento dell'esecuzione. Poiché molti sistemi Lisp hanno un compilatore integrato, è possibile generare codice e compilarlo in fase di esecuzione.

Proviamo questo usando SBCL, un'implementazione di Common Lisp.

Passiamo SBCL all'interprete:

* (setf sb-ext:*evaluator-mode* :interpret)

:INTERPRET

Ora definiamo una macro. La macro stampa qualcosa quando viene chiamato per il codice espanso. Il codice generato non viene stampato.

* (defmacro my-and (a b)
    (print "macro my-and used")
    `(if ,a
         (if ,b t nil)
         nil))

Ora usiamo la macro:

MY-AND
* (defun foo (a b) (my-and a b))

FOO

Vedere. Nel caso precedente Lisp non fa nulla. La macro non viene espansa al momento della definizione.

* (foo t nil)

"macro my-and used"
NIL

Ma in fase di esecuzione, quando viene utilizzato il codice, la macro viene espansa.

* (foo t t)

"macro my-and used"
T

Ancora una volta, in fase di esecuzione, quando viene utilizzato il codice, la macro viene espansa.

Si noti che SBCL si espanderebbe solo una volta quando si utilizza un compilatore. Ma varie implementazioni Lisp forniscono anche interpreti, come SBCL.

Perché le macro sono facili in Lisp? Beh, non sono davvero facili. Solo in Lisps, e ce ne sono molti che hanno il supporto per le macro incorporato. Dato che molti Lisps sono dotati di macchinari estesi per le macro, sembra che sia facile. Ma i macro meccanismi possono essere estremamente complicati.


Ho letto molto su Scheme sul Web e ho letto SICP. Inoltre, le espressioni Lisp non sono compilate prima di essere interpretate? Almeno devono essere analizzati. Quindi immagino che "tempo di compilazione" dovrebbe essere "tempo di analisi".
Elliot Gorokhovsky,

@ Il punto di RenéG Rainer, credo, è che se tu evalo il loadcodice in qualsiasi linguaggio Lisp, anche le macro in questi verranno elaborate. Considerando che se si utilizza un sistema di preprocessore come proposto nella domanda, evale simili non trarranno vantaggio dall'espansione macro.
Chris Jester-Young,

@ RenéG Inoltre, "parse" viene chiamato readin Lisp. Questa distinzione è importante, perché evalfunziona sull'attuale struttura dei dati dell'elenco (come menzionato nella mia risposta), non sul modulo testuale. Quindi puoi usare (eval '(+ 1 1))e tornare indietro di 2, ma se tu (eval "(+ 1 1)"), torni "(+ 1 1)"(la stringa). Si usa readper passare da "(+ 1 1)"(una stringa di 7 caratteri) a (+ 1 1)(un elenco con un simbolo e due numeri fissi).
Chris Jester-Young,

@ RenéG Con questa comprensione, le macro non funzionano al momento read. Funzionano in fase di compilazione nel senso che se si dispone di codice simile (and (test1) (test2)), verrà espanso in (if (test1) (test2) #f)(nello schema) solo una volta, quando il codice viene caricato, anziché ogni volta che viene eseguito il codice, ma se si fa qualcosa di simile (eval '(and (test1) (test2))), che compilerà (ed espanderà macro) quell'espressione in modo appropriato, in fase di esecuzione.
Chris Jester-Young,

@ RenéG L'omiconicità è ciò che consente alle lingue Lisp di valutare le strutture dell'elenco anziché la forma testuale e di trasformare tali strutture dell'elenco (tramite macro) prima dell'esecuzione. La maggior parte delle lingue evalfunziona solo su stringhe di testo e le loro capacità di modifica della sintassi sono molto più scarse e / o ingombranti.
Chris Jester-Young,

1

L'omoiconicità rende molto più facile implementare le macro. L'idea che il codice sia un dato e che un dato sia un codice rende possibile più o meno (salvo l'acquisizione accidentale di identificatori, risolta da macro igieniche ) per sostituirsi liberamente l'uno all'altro. Lisp e Scheme rendono questo più facile con la loro sintassi di espressioni S che sono strutturate in modo uniforme e quindi facili da trasformare in AST che formano la base dei Macro sintattici .

I linguaggi senza S-Expressions o omoiconicity avranno problemi nell'implementazione di Macro sintattiche, sebbene sia ancora possibile farlo. Il progetto Keplero sta tentando di presentarli a Scala per esempio.

Il problema più grande con l'utilizzo delle macro di sintassi oltre alla non omoiconicità è il problema della sintassi generata arbitrariamente. Offrono un'enorme flessibilità e potenza, ma al prezzo che il tuo codice sorgente potrebbe non essere più facile da capire o mantenere.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.