Qu'en est-il de LISP, le cas échéant, facilite la mise en œuvre de macro-systèmes?

21

J'apprends Scheme du SICP et j'ai l'impression qu'une grande partie de ce qui rend Scheme et, plus encore, LISP spécial est le système macro. Mais, comme les macros sont développées au moment de la compilation, pourquoi les gens ne font-ils pas des systèmes de macros équivalents pour C / Python / Java / quoi que ce soit? Par exemple, on pourrait lier la pythoncommande à expand-macros | pythonou autre chose. Le code serait toujours portable pour les personnes qui n'utilisent pas le système de macros, il suffit de développer les macros avant de publier du code. Mais je ne connais rien de tel, sauf les modèles en C ++ / Haskell, que je suppose ne sont pas vraiment les mêmes. Qu'en est-il de LISP, le cas échéant, facilite la mise en œuvre de macro-systèmes?

Elliot Gorokhovsky
la source
3
"Le code serait toujours portable pour les personnes qui n'utilisent pas le système de macros, il suffit de développer les macros avant de publier le code." - juste pour vous avertir, cela a tendance à ne pas bien fonctionner. Ces autres personnes pourraient exécuter le code, mais dans la pratique, le code macro-étendu est souvent difficile à comprendre et généralement difficile à modifier. Il est en effet "mal écrit" dans le sens où l'auteur n'a pas adapté le code étendu aux yeux humains, il a adapté la vraie source. Essayez de dire à un programmeur Java que vous exécutez votre code Java via le préprocesseur C et regardez de quelle couleur ils tournent ;-)
Steve Jessop
1
Les macros doivent être exécutées cependant, à ce stade, vous écrivez déjà un interpréteur pour la langue.
Mehrdad

Réponses:

29

De nombreux Lispers vous diront que ce qui rend Lisp spécial, c'est l' homoiconicité , ce qui signifie que la syntaxe du code est représentée en utilisant les mêmes structures de données que les autres données. Par exemple, voici une fonction simple (utilisant la syntaxe Scheme) pour calculer l'hypoténuse d'un triangle rectangle avec les longueurs de côté données:

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

Maintenant, l'homoiconicité dit que le code ci-dessus est en fait représentable comme une structure de données (en particulier, des listes de listes) dans le code Lisp. Considérez donc les listes suivantes et voyez comment elles se «collent» ensemble:

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

Les macros vous permettent de traiter le code source comme cela: des listes de choses. Chacun de ces 6 « sous - listes » contiennent soit des pointeurs vers d' autres listes, ou à des symboles (dans cet exemple: define, hypot, x, y, sqrt, +, square).


Alors, comment pouvons-nous utiliser l'homoiconicité pour «séparer» la syntaxe et créer des macros? Voici un exemple simple. Réimplémentons la letmacro, que nous appellerons my-let. Pour rappel,

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

devrait se développer en

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

Voici une implémentation utilisant des macros Scheme "renommage explicite" :

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

Le formparamètre est lié à la forme réelle, donc pour notre exemple, il le serait (my-let ((foo 1) (bar 2)) (+ foo bar)). Voyons donc l'exemple:

  1. Tout d'abord, nous récupérons les liaisons du formulaire. cadrsaisit la ((foo 1) (bar 2))partie du formulaire.
  2. Ensuite, nous récupérons le corps du formulaire. cddrsaisit la ((+ foo bar))partie du formulaire. (Notez que cela est destiné à récupérer tous les sous-formulaires après la liaison; donc si le formulaire était

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

    alors le corps serait ((debug foo) (debug bar) (+ foo bar)).)

  3. Maintenant, nous construisons réellement l' lambdaexpression et l'appel résultants en utilisant les liaisons et le corps que nous avons collectés. Le backtick est appelé une "quasiquote", ce qui signifie de traiter tout ce qui se trouve à l'intérieur de la quasiquote comme des références littérales, à l' exception des bits après les virgules ("unquote").
    • Les (rename 'lambda)moyens d'utiliser la lambdaliaison en vigueur lorsque cette macro est définie , plutôt que toute lambdaliaison qui pourrait exister lorsque cette macro est utilisée . (C'est ce qu'on appelle l' hygiène .)
    • (map car bindings)renvoie (foo bar): la première donnée dans chacune des liaisons.
    • (map cadr bindings)renvoie (1 2): la deuxième donnée dans chacune des liaisons.
    • ,@ fait le "splicing", qui est utilisé pour les expressions qui retournent une liste: il fait coller les éléments de la liste dans le résultat, plutôt que la liste elle-même.
  4. En rassemblant tout cela, nous obtenons, par conséquent, la liste (($lambda (foo bar) (+ foo bar)) 1 2), où $lambdaici se réfère au renommé lambda.

Simple, non? ;-) (Si ce n'est pas simple pour vous, imaginez à quel point il serait difficile d'implémenter un système de macro pour d'autres langues.)


Ainsi, vous pouvez avoir des systèmes de macros pour d'autres langues, si vous avez un moyen de pouvoir "séparer" le code source de manière non maladroite. Il y a quelques tentatives. Par exemple, sweet.js le fait pour JavaScript.

† Pour les Schemers chevronnés lisant ceci, j'ai intentionnellement choisi d'utiliser des macros de changement de nom explicites comme compromis moyen entre les defmacros utilisés par d'autres dialectes Lisp et syntax-rules(ce qui serait la manière standard d'implémenter une telle macro dans Scheme). Je ne veux pas écrire dans d'autres dialectes lisp, mais je ne veux pas aliéner les non-Schémers qui n'y sont pas habitués syntax-rules.

Pour référence, voici la my-letmacro qui utilise syntax-rules:

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

La syntax-caseversion correspondante est très similaire:

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

La différence entre les deux est que tout dans syntax-rulesa un implicite #'appliqué, donc vous ne pouvez avoir que des paires modèle / modèle syntax-rules, donc c'est entièrement déclaratif. En revanche, dans syntax-case, le bit après le modèle est un code réel qui, en fin de compte, doit renvoyer un objet de syntaxe ( #'(...)), mais peut également contenir un autre code.

Chris Jester-Young
la source
2
Un avantage que vous n'avez pas mentionné: oui, il y a des tentatives dans d'autres langues, comme sweet.js pour JS. Cependant, en lisps, l'écriture d'une macro se fait dans le même langage que l'écriture d'une fonction.
Florian Margaine
À droite, vous pouvez écrire des macros procédurales (par opposition à déclaratives) dans les langages Lisp, ce qui vous permet de faire des choses vraiment avancées. BTW, c'est ce que j'aime dans les systèmes de macro Scheme: il y en a plusieurs parmi lesquels choisir. Pour les macros simples, j'utilise syntax-rules, qui est purement déclarative. Pour les macros compliquées, je peux utiliser syntax-casece qui est en partie déclaratif et en partie procédural. Et puis il y a le renommage explicite, qui est purement procédural. (La plupart des implémentations de Scheme fourniront l'un syntax-caseou l' autre ER. Je n'en ai pas vu un qui fournit les deux. Ils sont équivalents en puissance.)
Chris Jester-Young
Pourquoi les macros doivent-elles modifier l'AST? Pourquoi ne peuvent-ils pas travailler à un niveau supérieur?
Elliot Gorokhovsky
1
Alors pourquoi LISP est-il meilleur? Qu'est-ce qui rend LISP spécial? Si l'on peut implémenter des macros dans js, on peut sûrement aussi les implémenter dans n'importe quel autre langage.
Elliot Gorokhovsky
3
@ RenéG comme je l'ai dit dans mon premier commentaire, un gros avantage est que vous écrivez toujours dans la même langue.
Florian Margaine
23

Une opinion dissidente: l'homoiconicité de Lisp est beaucoup moins utile que la plupart des fans de Lisp ne le croient.

Pour comprendre les macros syntaxiques, il est important de comprendre les compilateurs. Le travail d'un compilateur est de transformer du code lisible par l'homme en code exécutable. D'un point de vue de très haut niveau, cela comporte deux phases globales: l' analyse et la génération de code .

L'analyse est le processus de lecture du code, de son interprétation selon un ensemble de règles formelles et de sa transformation en une structure arborescente, généralement connue sous le nom d'AST (Abstract Syntax Tree). Malgré toute la diversité des langages de programmation, il s'agit d'un point commun remarquable: essentiellement chaque langage de programmation à usage général est analysé dans une arborescence.

La génération de code prend l'AST de l'analyseur comme entrée et le transforme en code exécutable via l'application de règles formelles. Du point de vue des performances, il s'agit d'une tâche beaucoup plus simple; de nombreux compilateurs de langage de haut niveau consacrent 75% ou plus de leur temps à l'analyse.

La chose à retenir à propos de Lisp est que c'est très, très vieux. Parmi les langages de programmation, seul FORTRAN est plus ancien que Lisp. Il y a bien longtemps, l'analyse (la partie lente de la compilation) était considérée comme un art sombre et mystérieux. Les articles originaux de John McCarthy sur la théorie du Lisp (à l'époque où ce n'était qu'une idée qu'il n'aurait jamais pensé pouvoir être implémenté comme un véritable langage de programmation informatique) décrivent une syntaxe un peu plus complexe et expressive que les expressions S modernes partout pour tout. "notation. Cela est arrivé plus tard, lorsque les gens essayaient de le mettre en œuvre. Parce que l'analyse syntaxique n'était pas bien comprise à l'époque, ils ont fondamentalement punté dessus et abrégé la syntaxe dans une structure arborescente homoiconique afin de rendre le travail de l'analyseur totalement trivial. Le résultat final est que vous (le développeur) devez faire beaucoup de l'analyseur » s y travailler en écrivant l'AST formel directement dans votre code. L'homoiconicité "ne rend pas les macros tellement plus faciles" qu'elle rend l'écriture de tout le reste beaucoup plus difficile!

Le problème avec cela est que, en particulier avec le typage dynamique, il est très difficile pour les expressions S de transporter beaucoup d'informations sémantiques avec elles. Lorsque toute votre syntaxe est le même type de chose (listes de listes), il n'y a pas beaucoup de contexte fourni par la syntaxe, et donc le système de macros a très peu de choses à travailler.

La théorie du compilateur a parcouru un long chemin depuis les années 1960, lorsque Lisp a été inventé, et bien que les choses qu'il ait accomplies étaient impressionnantes à l'époque, elles semblent plutôt primitives maintenant. Pour un exemple d'un système de métaprogrammation moderne, jetez un œil à la langue Boo (malheureusement sous-estimée). Boo est typé statiquement, orienté objet et open source, donc chaque nœud AST a un type avec une structure bien définie dans laquelle un développeur de macros peut lire le code. Le langage a une syntaxe relativement simple inspirée de Python, avec divers mots clés qui donnent une signification sémantique intrinsèque aux structures arborescentes construites à partir d'eux, et sa métaprogrammation a une syntaxe de quasiquote intuitive pour simplifier la création de nouveaux nœuds AST.

Voici une macro que j'ai créée hier lorsque j'ai réalisé que j'appliquais le même modèle à un tas d'endroits différents dans le code GUI, où j'appellerais BeginUpdate()un contrôle d'interface utilisateur, effectuerais une mise à jour dans un trybloc, puis appellerais EndUpdate():

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

La macrocommande est, en fait, une macro elle - même , qui prend un corps de macro en entrée et génère une classe pour traiter la macro. Il utilise le nom de la macro comme variable qui MacroStatementremplace le nœud AST qui représente l'invocation de macro. Le [| ... |] est un bloc de quasiquote, générant l'AST qui correspond au code à l'intérieur et à l'intérieur du bloc de quasiquote, le symbole $ fournit la fonction "unquote", se substituant dans un nœud comme spécifié.

Avec cela, il est possible d'écrire:

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

et le faire s'étendre à:

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

Exprimant la macro de cette façon est plus simple et plus intuitive que ce serait dans une macro Lisp, parce que le développeur connaît la structure MacroStatementet sait comment les Argumentset Bodypropriétés de travail, et que la connaissance inhérente peut être utilisé pour exprimer les concepts impliqués dans une très intuitive façon. Il est également plus sûr, car le compilateur connaît la structure de MacroStatement, et si vous essayez de coder quelque chose qui n'est pas valide pour un MacroStatement, le compilateur le détectera immédiatement et signalera l'erreur au lieu que vous ne le sachiez jusqu'à ce que quelque chose vous explose à Durée.

Greffer des macros sur Haskell, Python, Java, Scala, etc. n'est pas difficile car ces langages ne sont pas homoiconiques; c'est difficile parce que les langues ne sont pas conçues pour eux, et cela fonctionne mieux lorsque la hiérarchie AST de votre langue est conçue à partir de zéro pour être examinée et manipulée par un macro système. Lorsque vous travaillez avec un langage conçu avec la métaprogrammation à l'esprit dès le début, les macros sont beaucoup plus simples et plus faciles à utiliser!

Mason Wheeler
la source
4
Joie de lire, merci! Les macros non Lisp s'étendent-elles jusqu'à changer la syntaxe? Parce que l'un des points forts de Lisp est que la syntaxe est la même, il est donc facile d'ajouter une fonction, une instruction conditionnelle, peu importe parce qu'elles sont toutes les mêmes. Alors qu'avec les langages non lisp une chose diffère d'une autre - if...ne ressemble pas à un appel de fonction par exemple. Je ne connais pas Boo, mais imaginez que Boo n'avait pas de correspondance de motifs, pourriez-vous l'introduire avec sa propre syntaxe en tant que macro? Mon point est - toute nouvelle macro en Lisp semble 100% naturelle, dans d'autres langues, elles fonctionnent, mais vous pouvez voir les points.
greenoldman
4
L'histoire telle que je l'ai toujours lue est un peu différente. Une syntaxe alternative à l'expression s était prévue, mais son travail a été retardé car les programmeurs avaient déjà commencé à utiliser les expressions s et les trouvaient pratiques. Le travail sur la nouvelle syntaxe a donc finalement été oublié. Pouvez-vous s'il vous plaît citer la source qui indique les lacunes de la théorie du compilateur comme raison de l'utilisation d'expressions s? De plus, la famille Lisp a continué d'évoluer pendant de nombreuses décennies (Scheme, Common Lisp, Clojure) et la plupart des dialectes ont décidé de s'en tenir aux expressions s.
Giorgio
5
"plus simple et plus intuitif": désolé, mais je ne vois pas comment. "Updating.Arguments [0]" n'a pas de sens, je préfère avoir un argument nommé et laisser le compilateur vérifier lui-même si le nombre d'arguments correspond: pastebin.com/YtUf1FpG
coredump
8
"Du point de vue des performances, il s'agit d'une tâche beaucoup plus simple; de ​​nombreux compilateurs de langage de haut niveau consacrent 75% ou plus de leur temps à l'analyse." Je m'attendais à rechercher et à appliquer des optimisations pour prendre la plupart du temps (mais je n'ai jamais écrit de véritable compilateur). Est-ce que j'ai râté quelque chose?
Doval
5
Malheureusement, votre exemple ne le montre pas. Il est primitif à implémenter dans n'importe quel Lisp avec des macros. En fait, c'est l'une des macros les plus primitives à implémenter. Cela me fait soupçonner que vous ne savez pas grand-chose sur les macros en Lisp. "La syntaxe de Lisp est bloquée dans les années 1960": en réalité, les systèmes de macro de Lisp ont fait beaucoup de progrès depuis 1960 (en 1960, Lisp n'avait même pas de macros!).
Rainer Joswig
3

J'apprends Scheme du SICP et j'ai l'impression qu'une grande partie de ce qui rend Scheme et, plus encore, LISP spécial est le système macro.

Comment? Tout le code dans SICP est écrit dans un style sans macro. Il n'y a pas de macros dans SICP. Ce n'est que dans une note de bas de page à la page 373 que les macros sont mentionnées.

Mais, puisque les macros sont développées au moment de la compilation

Ils ne le sont pas nécessairement. Lisp fournit des macros dans les interprètes et les compilateurs. Ainsi, il pourrait ne pas y avoir de temps de compilation. Si vous avez un interpréteur Lisp, les macros sont développées au moment de l'exécution. Étant donné que de nombreux systèmes Lisp ont un compilateur intégré, on peut générer du code et le compiler au moment de l'exécution.

Testons cela en utilisant SBCL, une implémentation Common Lisp.

Passons SBCL à l'interpréteur:

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

:INTERPRET

Nous définissons maintenant une macro. La macro imprime quelque chose lorsqu'elle est appelée pour du code développé. Le code généré ne s'imprime pas.

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

Utilisons maintenant la macro:

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

FOO

Voir. Dans le cas ci-dessus, Lisp ne fait rien. La macro n'est pas développée au moment de la définition.

* (foo t nil)

"macro my-and used"
NIL

Mais à l'exécution, lorsque le code est utilisé, la macro est développée.

* (foo t t)

"macro my-and used"
T

Encore une fois, lors de l'exécution, lorsque le code est utilisé, la macro est développée.

Notez que SBCL ne se développerait qu'une seule fois lors de l'utilisation d'un compilateur. Mais diverses implémentations Lisp fournissent également des interprètes - comme SBCL.

Pourquoi les macros sont-elles faciles en Lisp? Eh bien, ce n'est pas vraiment facile. Seulement en Lisps, et il y en a beaucoup, qui ont un support de macro intégré. Comme beaucoup de Lisps sont livrés avec une machinerie étendue pour les macros, il semble que ce soit facile. Mais les macro-mécanismes peuvent être extrêmement compliqués.

Rainer Joswig
la source
J'ai lu beaucoup de choses sur Scheme sur le Web ainsi que sur SICP. De plus, les expressions Lisp ne sont-elles pas compilées avant d'être interprétées? Ils doivent au moins être analysés. Donc je suppose que le "temps de compilation" devrait être le "temps d'analyse".
Elliot Gorokhovsky
@ Le point de RenéG Rainer, je crois, est que si vous evalou le loadcode dans n'importe quel langage Lisp, les macros de ceux-ci seront également traitées. Alors que si vous utilisez un système de préprocesseur tel que proposé dans votre question, evalet similaire ne bénéficiera pas de l'expansion de macro.
Chris Jester-Young,
@ RenéG Aussi, "parse" est appelé readen Lisp. Cette distinction est importante, car elle evalfonctionne sur les structures de données de la liste (comme mentionné dans ma réponse), et non sur la forme textuelle. Vous pouvez donc utiliser (eval '(+ 1 1))et récupérer 2, mais si vous (eval "(+ 1 1)"), vous récupérez "(+ 1 1)"(la chaîne). Vous utilisez readpour passer de "(+ 1 1)"(une chaîne de 7 caractères) à (+ 1 1)(une liste avec un symbole et deux fixnums).
Chris Jester-Young,
@ RenéG Avec cette compréhension, les macros ne fonctionnent pas à l' readheure. Ils fonctionnent au moment de la compilation dans le sens où si vous avez du code comme (and (test1) (test2)), il sera développé dans (if (test1) (test2) #f)(dans Scheme) juste une fois, lorsque le code est chargé, plutôt qu'à chaque fois qu'il est exécuté, mais si vous faites quelque chose comme (eval '(and (test1) (test2))), qui compilera (et développera macro) cette expression de manière appropriée, au moment de l'exécution.
Chris Jester-Young,
@ RenéG L'homoiconicité est ce qui permet aux langages Lisp de s'évaluer sur les structures de liste au lieu de la forme textuelle, et de transformer ces structures de liste (via des macros) avant l'exécution. La plupart des langues ne evalfonctionnent que sur les chaînes de texte, et leurs capacités de modification de syntaxe sont beaucoup plus ternes et / ou encombrantes.
Chris Jester-Young,
1

L'homoiconicité facilite considérablement la mise en œuvre des macros. L'idée que le code est une donnée et que la donnée est un code permet plus ou moins (sauf capture accidentelle d'identifiants, résolue par des macros hygiéniques ) de se substituer librement l'un à l'autre. Lisp et Scheme rendent cela plus facile avec leur syntaxe d'expressions S qui sont uniformément structurées et donc faciles à transformer en ASTs qui forment la base des macros syntaxiques .

Les langages sans S-Expressions ni homoiconicité rencontreront des problèmes lors de la mise en œuvre des macros syntaxiques, même si cela peut certainement être fait. Le projet Kepler tente par exemple de leur faire découvrir Scala.

Le plus gros problème avec l'utilisation des macros de syntaxe en dehors de la non-homoiconicité, est le problème de la syntaxe générée arbitrairement. Ils offrent une flexibilité et une puissance considérables, mais au prix que votre code source ne soit plus aussi facile à comprendre ou à maintenir.

Ingénieur du monde
la source