E o LISP, se houver, facilita a implementação de sistemas macro?

21

Estou aprendendo o Scheme com o SICP e estou tendo a impressão de que grande parte do que torna o Scheme e, mais ainda, o LISP especial é o sistema macro. Mas, como as macros são expandidas em tempo de compilação, por que as pessoas não criam sistemas de macro equivalentes para C / Python / Java / o que quer que seja? Por exemplo, alguém poderia vincular o pythoncomando expand-macros | pythonou o que seja. O código ainda seria portátil para as pessoas que não usam o sistema de macros; basta expandir as macros antes de publicar o código. Mas eu não sei de nada assim, exceto modelos em C ++ / Haskell, que eu deduzo que não são realmente iguais. E o LISP, se houver, facilita a implementação de sistemas macro?

Elliot Gorokhovsky
fonte
3
"O código ainda seria portátil para pessoas que não usam o sistema de macros; basta expandir as macros antes de publicar o código". - só para avisar, isso tende a não funcionar bem. Essas outras pessoas seriam capazes de executar o código, mas, na prática, o código macro-expandido costuma ser difícil de compreender e geralmente difícil de modificar. Na verdade, é "mal escrito" no sentido em que o autor não adaptou o código expandido para os olhos humanos, eles adaptaram a fonte real. Tente dizer um programador Java você executar seu código Java através do pré-processador C e observar o que cor eles se transformam ;-)
Steve Jessop
1
Porém, as macros precisam ser executadas; nesse momento, você já está escrevendo um intérprete para o idioma.
Mehrdad 23/02

Respostas:

29

Muitos Lispers dirão a você que o que torna o Lisp especial é a homoiconicidade , o que significa que a sintaxe do código é representada usando as mesmas estruturas de dados que outros dados. Por exemplo, aqui está uma função simples (usando a sintaxe do esquema) para calcular a hipotenusa de um triângulo retângulo com os comprimentos laterais fornecidos:

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

Agora, a homoiconicidade diz que o código acima é realmente representável como uma estrutura de dados (especificamente, listas de listas) no código Lisp. Portanto, considere as seguintes listas e veja como elas se "colam":

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

As macros permitem que você trate o código fonte como exatamente isso: listas de coisas. Cada um desses 6 "sublists" conter ponteiros para outras listas, ou símbolos (neste exemplo: define, hypot, x, y, sqrt, +, square).


Então, como podemos usar a homoiconicidade para "separar" a sintaxe e criar macros? Aqui está um exemplo simples. Vamos reimplementar a letmacro, que chamaremos my-let. Como lembrete,

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

deve expandir para

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

Aqui está uma implementação usando as macros do esquema "renomeação explícita" :

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

O formparâmetro está vinculado à forma real, portanto, para o nosso exemplo, seria (my-let ((foo 1) (bar 2)) (+ foo bar)). Então, vamos trabalhar com o exemplo:

  1. Primeiro, recuperamos as ligações do formulário. cadragarra a ((foo 1) (bar 2))parte do formulário.
  2. Então, recuperamos o corpo do formulário. cddragarra a ((+ foo bar))parte do formulário. (Observe que isso se destina a pegar todos os subformulários após a ligação; portanto, se o formulário fosse

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

    então o corpo seria ((debug foo) (debug bar) (+ foo bar)).)

  3. Agora, na verdade, construímos a lambdaexpressão e a chamada resultantes usando as ligações e o corpo que coletamos. O backtick é chamado de "quasiquote", que significa tratar tudo dentro do quasiquote como dados literais, exceto os bits após as vírgulas ("unquote").
    • Os (rename 'lambda)meios para usar a lambdaligação em vigor quando essa macro é definida , em vez de qualquer lambdaligação existente quando essa macro é usada . (Isso é conhecido como higiene .)
    • (map car bindings)retorna (foo bar): o primeiro dado em cada uma das ligações.
    • (map cadr bindings)retorna (1 2): o segundo dado em cada uma das ligações.
    • ,@ faz "splicing", usado para expressões que retornam uma lista: faz com que os elementos da lista sejam colados no resultado, em vez da própria lista.
  4. Juntando tudo isso, obtemos, como resultado, a lista (($lambda (foo bar) (+ foo bar)) 1 2), onde $lambdaaqui se refere ao renomeado lambda.

Simples, certo? ;-) (Se não for fácil para você, imagine como seria difícil implementar um sistema de macros para outros idiomas.)


Portanto, você pode ter sistemas macro para outros idiomas, se você puder "separar" o código-fonte de uma maneira não desajeitada. Existem algumas tentativas para isso. Por exemplo, sweet.js faz isso para JavaScript.

† Para Schemers experientes lendo isso, eu escolhi intencionalmente usar macros de renomeação explícitas como um meio-termo entre defmacros usado por outros dialetos Lisp e syntax-rules(que seria a maneira padrão de implementar essa macro no esquema). Não quero escrever em outros dialetos do Lisp, mas não quero afastar os não-Schemers que não estão acostumados syntax-rules.

Para referência, aqui está a my-letmacro que usa syntax-rules:

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

A syntax-caseversão correspondente é muito parecida:

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

A diferença entre os dois é que tudo no syntax-rulestem um implícito #'aplicada, para que possa única têm pares padrão / modelo em syntax-rules, portanto, é totalmente declarativa. Por outro lado, em syntax-case, o bit após o padrão é um código real que, no final, precisa retornar um objeto de sintaxe ( #'(...)), mas também pode conter outro código.

Chris Jester-Young
fonte
2
Uma vantagem que você não mencionou: sim, existem tentativas em outros idiomas, como o sweet.js para JS. No entanto, em lisps, escrever uma macro é feito no mesmo idioma que escrever uma função.
Florian Margaine 23/02
Certo, você pode escrever macros procedurais (versus declarativas) em linguagens Lisp, que é o que permite fazer coisas realmente avançadas. BTW, é isso que eu gosto nos sistemas de macro Scheme: há vários para escolher. Para macros simples, eu uso syntax-rules, o que é puramente declarativo. Para macros complicadas, posso usar syntax-case, que é parcialmente declarativo e parcialmente processual. E depois há uma renomeação explícita, que é puramente processual. (Implementações mais Esquema irá fornecer quer syntax-caseou ER Eu não vi um que fornece tanto eles são equivalentes em poder...)
Chris Jester-Young
Por que as macros precisam modificar o AST? Por que eles não podem trabalhar em um nível superior?
Elliot Gorokhovsky,
1
Então, por que o LISP é melhor? O que torna o LISP especial? Se alguém pode implementar macros em js, certamente também pode implementá-las em qualquer outro idioma.
Elliot Gorokhovsky,
3
@ RenéG, como eu disse no meu primeiro comentário, uma grande vantagem é que você ainda está escrevendo no mesmo idioma.
Florian Margaine 24/02
23

Uma opinião divergente: a homoiconicidade de Lisp é muito menos útil do que você imagina.

Para entender macros sintáticas, é importante entender os compiladores. O trabalho de um compilador é transformar código legível por humanos em código executável. De uma perspectiva de alto nível, isso tem duas fases gerais: análise e geração de código .

A análise é o processo de ler o código, interpretá-lo de acordo com um conjunto de regras formais e transformá-lo em uma estrutura em árvore, geralmente conhecida como AST (Abstract Syntax Tree). Por toda a diversidade entre linguagens de programação, essa é uma notável semelhança: essencialmente toda linguagem de programação de uso geral analisa uma estrutura em árvore.

A geração de código toma o AST do analisador como entrada e o transforma em código executável por meio da aplicação de regras formais. Da perspectiva do desempenho, essa é uma tarefa muito mais simples; muitos compiladores de idiomas de alto nível gastam 75% ou mais de seu tempo analisando.

É importante lembrar que o Lisp é muito, muito antigo. Entre as linguagens de programação, apenas o FORTRAN é mais antigo que o Lisp. No passado, a análise (a parte lenta da compilação) era considerada uma arte sombria e misteriosa. Os trabalhos originais de John McCarthy sobre a teoria de Lisp (quando era apenas uma idéia que ele nunca pensou que pudesse realmente ser implementada como uma linguagem de programação de computador real) descrevem uma sintaxe um pouco mais complexa e expressiva do que as modernas "expressões S em todos os lugares para tudo" "notação. Isso aconteceu mais tarde, quando as pessoas estavam tentando implementá-lo. Como a análise não era bem compreendida na época, eles basicamente analisaram e transformaram a sintaxe em uma estrutura de árvore homoicônica, a fim de tornar o trabalho do analisador absolutamente trivial. O resultado final é que você (o desenvolvedor) precisa fazer muito do analisador ' s trabalha para isso escrevendo o AST formal diretamente no seu código. A homoiconicidade não "facilita tanto as macros" quanto torna mais difícil escrever todo o resto!

O problema disso é que, especialmente com a digitação dinâmica, é muito difícil para as expressões S levarem muitas informações semânticas com elas. Quando toda a sua sintaxe é do mesmo tipo (listas de listas), não há muito no contexto fornecido pela sintaxe e, portanto, o sistema macro tem muito pouco com o que trabalhar.

A teoria dos compiladores percorreu um longo caminho desde a década de 1960, quando o Lisp foi inventado e, embora as coisas que ele realizou fossem impressionantes para a época, elas parecem bastante primitivas agora. Para um exemplo de um sistema moderno de metaprogramação, dê uma olhada na linguagem Boo (infelizmente subestimada). O Boo é de tipo estatístico, orientado a objeto e de código aberto, portanto, todo nó AST possui um tipo com uma estrutura bem definida na qual um desenvolvedor de macros pode ler o código. A linguagem possui uma sintaxe relativamente simples, inspirada no Python, com várias palavras-chave que dão significado semântico intrínseco às estruturas em árvore construídas a partir delas, e sua metaprogramação possui uma sintaxe de quasiquote intuitiva para simplificar a criação de novos nós AST.

Aqui está uma macro que eu criei ontem quando percebi que estava aplicando o mesmo padrão a vários lugares diferentes no código da GUI, onde eu chamava BeginUpdate()um controle de interface do usuário, realizava uma atualização em um trybloco e depois chamava EndUpdate():

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

O macrocomando é, de fato, uma macro em si , que recebe um corpo da macro como entrada e gera uma classe para processar a macro. Ele usa o nome da macro como uma variável que representa o MacroStatementnó AST que representa a chamada da macro. O [| ... |] é um bloco de quasiquotas, gerando o AST que corresponde ao código dentro e dentro do bloco de quasiquotes, o símbolo $ fornece o recurso "sem aspas", substituindo em um nó conforme especificado.

Com isso, é possível escrever:

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

e expanda para:

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

Expressar a macro dessa maneira é mais simples e intuitivo do que em uma macro Lisp, porque o desenvolvedor conhece a estrutura MacroStatemente sabe como as propriedades Argumentse Bodyfuncionam, e esse conhecimento inerente pode ser usado para expressar os conceitos envolvidos de uma maneira muito intuitiva. maneira. Também é mais seguro, porque o compilador conhece a estrutura MacroStatemente, se você tentar codificar algo que não é válido para a MacroStatement, o compilador o capturará imediatamente e relatará o erro, em vez de você não saber até que algo exploda em você. tempo de execução.

Enxertar macros em Haskell, Python, Java, Scala etc. não é difícil, porque essas linguagens não são homoicônicas; é difícil porque os idiomas não foram projetados para eles e funciona melhor quando a hierarquia AST do seu idioma é projetada desde o início para ser examinada e manipulada por um sistema macro. Quando você trabalha com uma linguagem que foi projetada com metaprogramação desde o início, as macros são muito mais simples e fáceis de trabalhar!

Mason Wheeler
fonte
4
Alegria de ler, obrigado! As macros não-Lisp se estendem até a alteração da sintaxe? Como um dos pontos fortes do Lisp é a sintaxe é a mesma, portanto, é fácil adicionar uma função, instrução condicional, seja qual for, porque eles são todos iguais. Enquanto nas linguagens não-Lisp, uma coisa difere da outra - if...não se parece com uma chamada de função, por exemplo. Não conheço o Boo, mas imagine que o Boo não tivesse correspondência de padrões. Você poderia apresentá-lo com sua própria sintaxe como macro? O que quero dizer é: qualquer nova macro no Lisp parece 100% natural, em outros idiomas eles funcionam, mas você pode ver os pontos.
greenoldman
4
A história como sempre a li é um pouco diferente. Uma sintaxe alternativa à expressão s foi planejada, mas o trabalho foi atrasado porque os programadores já haviam começado a usar expressões s e as consideravam convenientes. Portanto, o trabalho sobre a nova sintaxe foi esquecido. Você pode citar a fonte que indica as deficiências da teoria dos compiladores como o motivo do uso de expressões s? Além disso, a família Lisp continuou evoluindo por muitas décadas (Scheme, Common Lisp, Clojure) e a maioria dos dialetos decidiu se ater às expressões s.
Giorgio
5
"mais simples e mais intuitivo": desculpe, mas não vejo como. "Updating.Arguments [0]" não é significativo, eu prefiro ter um argumento nomeado e deixar o compilador verificar a si próprio se o número de argumentos corresponder: pastebin.com/YtUf1FpG
coredump
8
"Do ponto de vista do desempenho, essa é uma tarefa muito mais simples; muitos compiladores de idiomas de alto nível gastam 75% ou mais do seu tempo analisando". Eu esperava que procurasse e aplicasse otimizações para ocupar a maior parte do tempo (mas nunca escrevi um compilador real ). Estou faltando alguma coisa aqui?
Doval
5
Infelizmente, seu exemplo não mostra isso. É primitivo implementar em qualquer Lisp com macros. Na verdade, essa é uma das macros mais primitivas a serem implementadas. Isso me faz suspeitar que você não sabe muito sobre macros no Lisp. "A sintaxe do Lisp está parada na década de 1960": na verdade, os sistemas macro no Lisp fizeram muito progresso desde 1960 (em 1960, o Lisp nem sequer tinha macros!).
Rainer Joswig 23/02
3

Estou aprendendo o Scheme com o SICP e estou tendo a impressão de que grande parte do que torna o Scheme e, mais ainda, o LISP especial é o sistema macro.

Como assim? Todo o código no SICP é escrito em estilo sem macro. Não há macros no SICP. Somente em uma nota de rodapé na página 373 as macros são mencionadas.

Mas, como as macros são expandidas em tempo de compilação

Eles não são necessariamente. O Lisp fornece macros em intérpretes e compiladores. Portanto, pode não haver um tempo de compilação. Se você tiver um intérprete Lisp, as macros serão expandidas no tempo de execução. Como muitos sistemas Lisp possuem um compilador a bordo, é possível gerar código e compilá-lo em tempo de execução.

Vamos testar isso usando o SBCL, uma implementação Common Lisp.

Vamos mudar o SBCL para o intérprete:

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

:INTERPRET

Agora vamos definir uma macro. A macro imprime algo quando é chamado para o código expandido. O código gerado não é impresso.

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

Agora vamos usar a macro:

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

FOO

Vejo. No caso acima, o Lisp não faz nada. A macro não é expandida no momento da definição.

* (foo t nil)

"macro my-and used"
NIL

Mas em tempo de execução, quando o código é usado, a macro é expandida.

* (foo t t)

"macro my-and used"
T

Novamente, em tempo de execução, quando o código é usado, a macro é expandida.

Observe que o SBCL seria expandido apenas uma vez ao usar um compilador. Mas várias implementações do Lisp também fornecem intérpretes - como o SBCL.

Por que as macros são fáceis no Lisp? Bem, eles não são realmente fáceis. Somente em Lisps, e há muitos que têm suporte a macro incorporado. Como muitos Lisps vêm com extensas máquinas para macros, parece fácil. Mas os mecanismos macro podem ser extremamente complicados.

Rainer Joswig
fonte
Eu tenho lido muito sobre o Scheme na web e também o SICP. Além disso, as expressões Lisp não são compiladas antes de serem interpretadas? Eles pelo menos devem ser analisados. Então eu acho que "tempo de compilação" deve ser "tempo de análise".
Elliot Gorokhovsky,
@ O argumento de RenéG Rainer, acredito, é que se você evalou loadcódigo em qualquer linguagem Lisp, as macros também serão processadas. Por outro lado, se você usar um sistema de pré-processador, conforme proposto em sua pergunta, evalnão se beneficiará da expansão da macro.
Chris Jester-Young
@ RenéG Além disso, "analisar" é chamado readno Lisp. Essa distinção é importante, porque evalfunciona em estruturas de dados reais da lista (como mencionado na minha resposta), e não no formulário textual. Então você pode usar (eval '(+ 1 1))e voltar 2, mas se você (eval "(+ 1 1)")voltar "(+ 1 1)"(a string). Você usa readpara ir de "(+ 1 1)"(uma sequência de 7 caracteres) a (+ 1 1)(uma lista com um símbolo e dois fixnums).
22815 Chris Jester-Young
@ RenéG Com esse entendimento, as macros não funcionam no momento read. Eles funcionam em tempo de compilação, no sentido de que, se você tiver um código semelhante (and (test1) (test2)), ele será expandido para (if (test1) (test2) #f)(no esquema) apenas uma vez, quando o código for carregado, em vez de cada vez que o código for executado, mas se você fizer algo como (eval '(and (test1) (test2))), que irá compilar (e expandir macro) essa expressão adequadamente, em tempo de execução.
Chris Jester-Young
@ RenéG Homoiconicity é o que permite que as linguagens Lisp avaliem nas estruturas da lista em vez da forma textual, e que essas estruturas da lista sejam transformadas (via macros) antes da execução. A maioria dos idiomas evalfunciona apenas em cadeias de texto, e seus recursos para modificação de sintaxe são muito mais sem brilho e / ou pesados.
22815 Chris Jester-Young
1

A homoiconicidade facilita muito a implementação de macros. A idéia de que código é dado e dado é código torna mais ou menos possível (exceto a captura acidental de identificadores, resolvida por macros higiênicas ) substituir livremente um pelo outro. Lisp e Scheme tornam isso mais fácil com sua sintaxe de expressões S, que são estruturadas uniformemente e, portanto, fáceis de se transformar em ASTs que formam a base das macros sintáticas .

Idiomas sem expressões S ou homoiconicidade terão problemas para implementar macros sintáticas, embora isso ainda possa ser feito. O Project Kepler está tentando apresentá-los ao Scala, por exemplo.

O maior problema com o uso de macros de sintaxe, além da não homoiconicidade, é o problema da sintaxe gerada arbitrariamente. Eles oferecem uma tremenda flexibilidade e poder, mas ao preço que seu código-fonte pode não ser mais tão fácil de entender ou manter.

Engenheiro Mundial
fonte