¿Qué pasa con LISP, en todo caso, hace que sea más fácil implementar sistemas macro?

21

Estoy aprendiendo Scheme del SICP y tengo la impresión de que gran parte de lo que hace a Scheme y, aún más, LISP especial es el sistema macro. Pero, dado que las macros se expanden en tiempo de compilación, ¿por qué la gente no crea sistemas de macros equivalentes para C / Python / Java / lo que sea? Por ejemplo, uno podría vincular el pythoncomando expand-macros | pythono lo que sea. El código aún sería portátil para las personas que no usan el sistema macro, uno simplemente expandiría las macros antes de publicar el código. Pero no sé nada de eso, excepto las plantillas en C ++ / Haskell, que creo que no son lo mismo. ¿Qué pasa con LISP, en todo caso, hace que sea más fácil implementar sistemas macro?

Elliot Gorokhovsky
fuente
3
"El código aún sería portátil para las personas que no usan el sistema macro, uno simplemente expandiría las macros antes de publicar el código". - solo para advertirte, esto tiende a no funcionar bien. Esas otras personas podrían ejecutar el código, pero en la práctica el código macro expandido a menudo es difícil de comprender y, por lo general, difícil de modificar. En efecto, está "mal escrito" en el sentido de que el autor no ha adaptado el código expandido para los ojos humanos, sino la fuente real. Intente decirle a un programador de Java que ejecute su código Java a través del preprocesador C y observe de qué color se vuelven ;-)
Steve Jessop
1
Sin embargo, las macros deben ejecutarse, en ese momento ya está escribiendo un intérprete para el idioma.
Mehrdad

Respuestas:

29

Muchos Lispers le dirán que lo que hace a Lisp especial es la homoiconicidad , lo que significa que la sintaxis del código se representa utilizando las mismas estructuras de datos que otros datos. Por ejemplo, aquí hay una función simple (usando la sintaxis de Scheme) para calcular la hipotenusa de un triángulo rectángulo con las longitudes laterales dadas:

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

Ahora, la homoiconicidad dice que el código anterior es realmente representable como una estructura de datos (específicamente, listas de listas) en el código Lisp. Por lo tanto, considere las siguientes listas y vea cómo se "pegan":

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

Las macros le permiten tratar el código fuente como eso: listas de cosas. Cada uno de esos 6 "sublistas" contener punteros a otras listas, o a los símbolos (en este ejemplo: define, hypot, x, y, sqrt, +, square).


Entonces, ¿cómo podemos usar la homoiconicidad para "separar" la sintaxis y crear macros? Aquí hay un ejemplo simple. Vuelva a implementar la letmacro, que llamaremos my-let. Como recordatorio,

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

debería expandirse a

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

Aquí hay una implementación que usa macros de "cambio de nombre explícito" del esquema :

(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)))))

El formparámetro está vinculado a la forma real, por lo que para nuestro ejemplo, sería (my-let ((foo 1) (bar 2)) (+ foo bar)). Entonces, analicemos el ejemplo:

  1. Primero, recuperamos los enlaces del formulario. cadragarra la ((foo 1) (bar 2))parte del formulario.
  2. Luego, recuperamos el cuerpo de la forma. cddragarra la ((+ foo bar))parte del formulario. (Tenga en cuenta que esto pretende capturar todas las subformas después del enlace; por lo tanto, si el formulario fuera

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

    entonces el cuerpo sería ((debug foo) (debug bar) (+ foo bar)).)

  3. Ahora, en realidad construimos la resultante lambda expresión y llamamos usando los enlaces y el cuerpo que hemos recopilado. El backtick se llama "cuasiquote", lo que significa tratar todo dentro de la cuasiquote como datos literales, excepto los bits después de las comas ("entre comillas").
    • Los (rename 'lambda)medios para usar el lambdaenlace en vigor cuando se define esta macro , en lugar de cualquier lambdaenlace que pueda existir cuando se usa esta macro . (Esto se conoce como higiene ).
    • (map car bindings) devoluciones (foo bar) : el primer dato en cada uno de los enlaces.
    • (map cadr bindings) devoluciones (1 2) : el segundo dato en cada uno de los enlaces.
    • ,@ hace "empalme", ​​que se usa para expresiones que devuelven una lista: hace que los elementos de la lista se peguen en el resultado, en lugar de la lista misma.
  4. Al juntar todo eso, obtenemos, como resultado, la lista (($lambda (foo bar) (+ foo bar)) 1 2), donde $lambdaaquí se refiere al cambio de nombre lambda.

Directo, ¿verdad? ;-) (Si no es sencillo para usted, imagine lo difícil que sería implementar un sistema macro para otros idiomas).


Por lo tanto, puede tener sistemas macro para otros idiomas, si tiene una manera de poder "separar" el código fuente de una manera no complicada. Hay algunos intentos de esto. Por ejemplo, sweet.js hace esto para JavaScript.

† Para los Schemers experimentados que leen esto, elegí intencionalmente usar macros de cambio de nombre explícito como un compromiso medio entre los defmacros utilizados por otros dialectos de Lisp y syntax-rules(que sería la forma estándar de implementar tal macro en Scheme). No quiero escribir en otros dialectos de Lisp, pero no quiero alienar a los no Schemers que no están acostumbrados syntax-rules.

Como referencia, aquí está la my-letmacro que usa syntax-rules:

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

La syntax-caseversión correspondiente se ve muy similar:

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

La diferencia entre los dos es que todo lo que en syntax-rulestiene una implícita #'aplicada, por lo que puede solamente tener pares de patrón / plantilla en syntax-rules, por lo tanto, es totalmente declarativa. En contraste, en syntax-case, el bit después del patrón es un código real que, al final, tiene que devolver un objeto de sintaxis ( #'(...)), pero también puede contener otro código.

Chris Jester-Young
fuente
2
Una ventaja que no ha mencionado: sí, hay intentos en otros idiomas, como sweet.js para JS. Sin embargo, en lisps, escribir una macro se realiza en el mismo idioma que escribir una función.
Florian Margaine
Correcto, puede escribir macros de procedimiento (versus declarativas) en lenguajes Lisp, que es lo que le permite hacer cosas realmente avanzadas. Por cierto, esto es lo que me gusta de los sistemas macro de Scheme: hay varios para elegir. Para macros simples, yo uso syntax-rules, que es puramente declarativo. Para macros complicadas, puedo usar syntax-case, que es en parte declarativo y en parte procesal. Y luego está el cambio de nombre explícito, que es puramente de procedimiento. (La mayoría de las implementaciones de Scheme proporcionarán uno syntax-caseo ER. No he visto uno que proporcione ambos. Son equivalentes en potencia.)
Chris Jester-Young
¿Por qué las macros tienen que modificar el AST? ¿Por qué no pueden trabajar a un nivel superior?
Elliot Gorokhovsky
1
Entonces, ¿por qué es mejor LISP? ¿Qué hace especial a LISP? Si uno puede implementar macros en js, seguramente también puede implementarlas en cualquier otro idioma.
Elliot Gorokhovsky
3
@ RenéG como dije en mi primer comentario, una gran ventaja es que todavía estás escribiendo en el mismo idioma.
Florian Margaine
23

Una opinión disidente: la homoiconicidad de Lisp es mucho menos útil de lo que la mayoría de los fanáticos de Lisp te hacen creer.

Para comprender las macros sintácticas, es importante comprender los compiladores. El trabajo de un compilador es convertir el código legible por humanos en código ejecutable. Desde una perspectiva de muy alto nivel, esto tiene dos fases generales: análisis y generación de código .

El análisis es el proceso de leer el código, interpretarlo de acuerdo con un conjunto de reglas formales y transformarlo en una estructura de árbol, generalmente conocida como AST (Árbol de sintaxis abstracta). Para toda la diversidad entre los lenguajes de programación, esta es una característica común notable: esencialmente cada lenguaje de programación de propósito general se analiza en una estructura de árbol.

Codigo de GENERACION toma el AST del analizador como su entrada y lo transforma en código ejecutable mediante la aplicación de reglas formales. Desde una perspectiva de rendimiento, esta es una tarea mucho más simple; muchos compiladores de idiomas de alto nivel dedican el 75% o más de su tiempo al análisis.

Lo que hay que recordar sobre Lisp es que es muy, muy viejo. Entre los lenguajes de programación, solo FORTRAN es más antiguo que Lisp. En el pasado, el análisis (la parte lenta de la compilación) se consideraba un arte oscuro y misterioso. Los documentos originales de John McCarthy sobre la teoría de Lisp (cuando era solo una idea de que nunca pensó que podría implementarse como un lenguaje de programación real) describe una sintaxis algo más compleja y expresiva que las modernas "expresiones S en todas partes para todo". "notación. Eso ocurrió más tarde, cuando la gente intentaba implementarlo. Debido a que el análisis no se entendía bien en ese entonces, básicamente lo golpearon y redujeron la sintaxis a una estructura de árbol homoicónica para hacer que el trabajo del analizador sea completamente trivial. El resultado final es que usted (el desarrollador) tiene que hacer mucho del analizador ' s trabaje para ello escribiendo el AST formal directamente en su código. ¡La homoiconicidad no "hace que las macros sean mucho más fáciles", sino que hace que escribir todo lo demás sea mucho más difícil!

El problema con esto es que, especialmente con la escritura dinámica, es muy difícil para las expresiones S transportar mucha información semántica. Cuando toda su sintaxis es del mismo tipo de cosas (listas de listas), no hay mucho en el contexto proporcionado por la sintaxis, por lo que el sistema macro tiene muy poco con qué trabajar.

La teoría del compilador ha recorrido un largo camino desde la década de 1960, cuando se inventó Lisp, y aunque las cosas que logró fueron impresionantes para su época, ahora parecen bastante primitivas. Para ver un ejemplo de un sistema moderno de metaprogramación, eche un vistazo al lenguaje Boo (tristemente subestimado). Boo es de tipo estático, orientado a objetos y de código abierto, por lo que cada nodo AST tiene un tipo con una estructura bien definida en la que un desarrollador de macros puede leer el código. El lenguaje tiene una sintaxis relativamente simple inspirada en Python, con varias palabras clave que dan un significado semántico intrínseco a las estructuras de árbol construidas a partir de ellas, y su metaprogramación tiene una sintaxis de cuasiquote intuitiva para simplificar la creación de nuevos nodos AST.

Aquí hay una macro que creé ayer cuando me di cuenta de que estaba aplicando el mismo patrón a un montón de lugares diferentes en el código GUI, donde llamaría BeginUpdate()a un control UI, realizaría una actualización en un trybloque y luego llamaría EndUpdate():

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

El macrocomando es, de hecho, una macro en sí , una que toma un cuerpo de macro como entrada y genera una clase para procesar la macro. Utiliza el nombre de la macro como una variable que representa el MacroStatementnodo AST que representa la invocación de la macro. El [| ... |] es un bloque de cuasiquote, que genera el AST que corresponde al código dentro y dentro del bloque de cuasiquote, el símbolo $ proporciona la función "sin comillas", sustituyendo en un nodo como se especifica.

Con esto, es posible escribir:

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

y que se expanda a:

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

Expresando la macro de esta manera es más sencillo y más intuitivo de lo que sería en una macro Lisp, porque el desarrollador conoce la estructura de MacroStatementy sabe cómo el Argumentsy Bodypropiedades de trabajo, y que el conocimiento inherente puede ser utilizado para expresar los conceptos involucrados en una muy intuitiva camino. También es más seguro, porque el compilador conoce la estructura de MacroStatement, y si intentas codificar algo que no es válido para un MacroStatement, el compilador lo detectará de inmediato e informará el error en lugar de que no lo sepas hasta que algo explote en ti. tiempo de ejecución

Injertar macros en Haskell, Python, Java, Scala, etc. no es difícil porque estos lenguajes no son homicónicos; es difícil porque los idiomas no están diseñados para ellos, y funciona mejor cuando la jerarquía AST de su idioma está diseñada desde cero para ser examinada y manipulada por un sistema macro. Cuando trabajas con un lenguaje que fue diseñado teniendo en cuenta la metaprogramación desde el principio, ¡las macros son mucho más simples y fáciles de usar!

Mason Wheeler
fuente
44
Alegría de leer, gracias! ¿Las macros que no son de Lisp se extienden hasta cambiar la sintaxis? Debido a que uno de los puntos fuertes de Lisp es que la sintaxis es la misma, es fácil agregar una función, una declaración condicional, lo que sea porque son todas iguales. Mientras que con los lenguajes que no son de Lisp, una cosa difiere de otra, if...por ejemplo, no parece una llamada de función. No conozco a Boo, pero imagina que Boo no tenía coincidencia de patrones, ¿podrías presentarlo con su propia sintaxis como macro? Mi punto es: cualquier nueva macro en Lisp se siente 100% natural, en otros idiomas funcionan, pero puedes ver las puntadas.
greenoldman
44
La historia como siempre la he leído es un poco diferente. Se planeó una sintaxis alternativa para la expresión s, pero el trabajo se retrasó porque los programadores ya habían comenzado a usar expresiones s y las encontraron convenientes. Por lo tanto, el trabajo sobre la nueva sintaxis fue finalmente olvidado. ¿Puede citar la fuente que indica las deficiencias de la teoría del compilador como la razón para usar expresiones s? Además, la familia Lisp siguió evolucionando durante muchas décadas (Scheme, Common Lisp, Clojure) y la mayoría de los dialectos decidieron apegarse a las expresiones s.
Giorgio
55
"más simple e intuitivo": lo siento, pero no veo cómo. "Actualizar.Argumentos [0]" no tiene sentido, prefiero tener un argumento con nombre y dejar que el compilador compruebe si el número de argumentos coincide: pastebin.com/YtUf1FpG
coredump
8
"Desde una perspectiva de rendimiento, esta es una tarea mucho más simple; muchos compiladores de idiomas de alto nivel dedican el 75% o más de su tiempo a analizar". Hubiera esperado buscar y aplicar optimizaciones para ocupar la mayor parte del tiempo (pero nunca he escrito un compilador real ). ¿Me estoy perdiendo de algo?
Doval
55
Lamentablemente, su ejemplo no muestra eso. Es primitivo implementar en cualquier Lisp con macros. En realidad, esta es una de las macros más primitivas para implementar. Esto me hace sospechar que no sabes mucho sobre macros en Lisp. "La sintaxis de Lisp se atascó en la década de 1960": en realidad, los macro sistemas en Lisp han progresado mucho desde 1960 (¡en 1960 Lisp ni siquiera tenía macros!).
Rainer Joswig
3

Estoy aprendiendo Scheme del SICP y tengo la impresión de que gran parte de lo que hace a Scheme y, aún más, LISP especial es el sistema macro.

¿Cómo es eso? Todo el código en SICP está escrito en estilo libre de macro. No hay macros en SICP. Solo en una nota al pie de la página 373 se mencionan las macros.

Pero, dado que las macros se expanden en tiempo de compilación

No son necesariamente Lisp proporciona macros tanto en intérpretes como en compiladores. Por lo tanto, puede que no haya un tiempo de compilación. Si tiene un intérprete Lisp, las macros se expanden en el momento de la ejecución. Dado que muchos sistemas Lisp tienen un compilador incorporado, uno puede generar código y compilarlo en tiempo de ejecución.

Probemos eso usando SBCL, una implementación de Common Lisp.

Cambiemos SBCL al intérprete:

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

:INTERPRET

Ahora definimos una macro. La macro imprime algo cuando se solicita un código expandido. El código generado no se imprime.

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

Ahora usemos la macro:

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

FOO

Ver. En el caso anterior, Lisp no hace nada. La macro no se expande en el momento de la definición.

* (foo t nil)

"macro my-and used"
NIL

Pero en tiempo de ejecución, cuando se usa el código, la macro se expande.

* (foo t t)

"macro my-and used"
T

Nuevamente, en tiempo de ejecución, cuando se usa el código, la macro se expande.

Tenga en cuenta que SBCL se expandiría solo una vez al usar un compilador. Pero varias implementaciones de Lisp también proporcionan intérpretes, como SBCL.

¿Por qué las macros son fáciles en Lisp? Bueno, no son realmente fáciles. Solo en Lisps, y hay muchos, que tienen soporte de macro incorporado. Dado que muchos Lisps vienen con una amplia maquinaria para macros, parece que es fácil. Pero los mecanismos macro pueden ser extremadamente complicados.

Rainer Joswig
fuente
He estado leyendo mucho sobre Scheme en la web y también he leído SICP. Además, ¿no se compilan las expresiones Lisp antes de que se interpreten? Al menos deben ser analizados. Así que supongo que "tiempo de compilación" debería ser "tiempo de análisis".
Elliot Gorokhovsky
El punto de @ RenéG Rainer, creo, es que si usted evalo loadcodifica en cualquier lenguaje Lisp, las macros también serán procesadas. Mientras que si usa un sistema de preprocesador como se propone en su pregunta, evaly similares no se beneficiarán de la expansión de macro.
Chris Jester-Young
@ RenéG Además, se llama "parse" readen Lisp. Esta distinción es importante, porque evalfunciona en las estructuras de datos de la lista real (como se menciona en mi respuesta), no en la forma textual. Entonces puede usar (eval '(+ 1 1))y recuperar 2, pero si lo hace (eval "(+ 1 1)"), recupera "(+ 1 1)"(la cadena). Se usa readpara ir de "(+ 1 1)"(una cadena de 7 caracteres) a (+ 1 1)(una lista con un símbolo y dos fixnums).
Chris Jester-Young
@ RenéG Con ese entendimiento, las macros no funcionan a readtiempo. Funcionan en tiempo de compilación en el sentido de que si tiene un código similar (and (test1) (test2)), se expandirá (if (test1) (test2) #f)(en Scheme) solo una vez, cuando se carga el código, en lugar de cada vez que se ejecuta el código, pero si hace algo como (eval '(and (test1) (test2))), que compilará (y ampliará macro) esa expresión de manera apropiada, en tiempo de ejecución.
Chris Jester-Young
@ RenéG La homoiconicidad es lo que permite que los lenguajes Lisp evalúen las estructuras de la lista en lugar de la forma textual, y que esas estructuras de la lista se transformen (a través de macros) antes de la ejecución. La mayoría de los idiomas solo evalfuncionan en cadenas de texto, y sus capacidades para la modificación de la sintaxis son mucho más deslucidas y / o engorrosas.
Chris Jester-Young
1

La homoiconicidad hace que sea mucho más fácil implementar macros. La idea de que el código es datos y los datos son códigos hace posible que más o menos (salvo la captura accidental de identificadores, resueltos por macros higiénicos ) sustituyan libremente uno por el otro. Lisp y Scheme lo hacen más fácil con su sintaxis de expresiones S que están estructuradas de manera uniforme y, por lo tanto, fáciles de convertir en AST que forman la base de las macros sintácticas .

Los lenguajes sin S-Expressions o Homoiconicity se encontrarán con problemas para implementar Syntactic Macros, aunque todavía se puede hacer. El Proyecto Kepler está intentando presentarles Scala, por ejemplo.

El mayor problema con el uso de macros de sintaxis, aparte de la no homoiconicidad, es el problema de la sintaxis generada arbitrariamente. Ofrecen una gran flexibilidad y potencia, pero al precio de que su código fuente ya no sea tan fácil de entender o mantener.

Ingeniero mundial
fuente