Skip to content

Les macros en Common Lisp: fonctions anonymes raccourcies

Cet article a pour but de présenter les macros de Common Lisp au travers d'un exemple concret, qui permettra peut-être à certaines personnes de mieux comprendre quand les utiliser, ainsi que leur intérêt.

Si vous avez déjà touché à clojure ou à arc, vous aurez sûrement apprécié leurs raccourcis pour écrire des fonctions anonymes. Par exemple en Clojure, les deux s-exps suivantes sont équivalentes:

(map (fn [x] (println "Hello, ")) '("world!" "you!"))
(map #(println "Hello, " %) '("world!" "you!"))

Avec arc, c'est le même principe (ça sera [prn "Hello, " _]), sauf qu'on a pas la possiblité d'utiliser ce mécanisme avec plusieurs arguments, tandis qu'avec clojure il est possible d'utiliser %1, %2 pour indiquer le premier argument, le second argument, …

Comme cette fonctionnalitée n'est pas présente dans Common Lisp, nous allons donc l'ajouter grâce aux macros.

Titre

La première chose à faire avant d'écrire une macro est d'écrire un ou deux exemples d'utilisation de cette macro, afin de clarifier nos idées. Comme la syntaxe '#(...)' est déjà utilisée pour les vecteurs, nous allons utiliser des crochets pour délimiter les fonction anonymes raccourcies, comme dans arc, mais nous allons garder le symbole pourcent suivit d'un nombre de clojure pour identifier les paramètres. Ainsi, l'exemple précédent s'écrira comme suit en Common Lisp grâce à notre macro:

(mapcar [format t "Hello, ~a~%" %] '("world!" "you!"))

Et sera équivalent à:

(mapcar (lambda (x) (format t "Hello, ~a~%" x)) '("world!" "you!"))

Mais premièrement, on va laisser les crochets de côté et écrire une macros qui sera équivalente. On verra à la fin de cet article comment faire pour manipuler le code avant le reader et donc pouvoir utiliser les crochets. Contentons nous pour le moment de manipuler le code après le reader et avant l'évaluation.

Première version simplifiée

Dans le développement de macros, il est préférable de coder la macros en plusieurs étapes. Nous allons donc écrire une première version de fn (notre macro), qui ne prendra pas en compte le nombre et l'ordre de ses arguments (le premier qui apparaîtra dans le code sera le premier argument, peu importe son numéro, et (fn (1+ %3)) ne prendra qu'un argument plutôt que trois).

Pour récupérer tous les symboles commençant par un % au sein de notre macro, nous auront besoin des fonctions flatten (voir On Lisp, p. 49) et percent-symbol-p. La première, très utile pour les macros, « aplatit » une liste, par exemple:

> (flatten '(1 (2 (3 4) 5) 6))
(1 2 3 4 5 6)

La seconde permet simplement de savoir si un symbole commence par un %:

> (percent-symbol-p '%foo)
T
> (percent-symbol-p 'foo)
NIL

Voici leur définitions:

(defun flatten (x)
  (labels ((rec (x acc)
             (cond ((null x) acc)
                   ((atom x) (cons x acc))
                   (t (rec
                       (car x)
                       (rec (cdr x) acc))))))
    (rec x nil)))

(defun percent-symbol-p (sym)
  (string= (subseq (symbol-name sym) 0 1)
           "%"))

On peut alors définir fn assez simplement: on récupère tous les symboles commençant par %, et on encapsule le code dans un lambda qui prend les symboles récupérés comme arguments:

(defmacro fn (code)
  (let ((args
         (remove-duplicates
          (remove-if-not #'percent-symbol-p
                         (flatten code)))))
    `(lambda ,args
       ,code)))

Il est donc déjà possible d'utiliser cette macro:

> (mapcar (fn (1+ %)) '(1 2 3))
(2 3 4)

Une version plus correcte

Maintenant que l'on a une macro de base qui fonctionne, on peut commencer à régler ses défauts petit à petit. La façon la plus simple pour gérer l'ordre des symboles et prendre en argument des symboles qui n'apparaissent pas dans le code et de récupérer l'argument maximal et de générer la liste de tous les arguments inférieurs.

On va donc avoir besoin de quelques fonctions supplémentaires:

  • mkstr (On Lisp, p. 58) qui transforme tous ses arguments en string
  • symb (On Lisp, p. 58) qui convertit la chaîne de caracère formé par ses arguments en symbol
  • percent-value qui permet de connaître la valeur d'un symbole commençant par % (par exemple, % et %1 valent 1 et %3 vaut 3)
  • gen-percents-syms qui génère tous les symboles pourcents entre 1 et la valeur maximale
(defun mkstr (&rest args)
  (with-output-to-string (s)
    (dolist (a args) (princ a s))))

(defun symb (&rest args)
  (values (intern (apply #'mkstr args))))

(defun percent-value (sym)
  (if (symbolp sym)
    (let ((sym-name (symbol-name sym)))
      (handler-case
          (if (string= (subseq sym-name 0 1) "%")
              (if (= (length sym-name) 1)
                  1
                  (parse-integer (subseq sym-name 1)))
              0)
        (parse-error () 0)))
    0))

(defun gen-percents-syms (max)
  (loop for i from 1 to max
       collect (symb "%" i)))

La nouvelle macro se définit alors:

(defmacro fn (code)
  (let ((arg-max (loop for sym in (flatten code)
                      maximize (percent-value sym))))
    `(lambda ,(gen-percents-syms arg-max)
         ,code)))

Finalisation

Notre macro est presque complète, il ne nous reste plus que deux cas à gérer: - clojure permet aussi d'utiliser %& pour définir des arguments de type &rest - il faut que % soit équivalent à %1

Cela se fait assez facilement:

(defun replace-symbol (symbol1 symbol2 list)
  (typecase list
    (list (mapcar (lambda (x) (replace-symbol symbol1 symbol2 x))
                  list))
    (t (if (eq list symbol1)
           symbol2
           list))))

(defun gen-percents-and-rest (max restp)
  (if restp
    (append (gen-percents-syms max) '(&rest %&))
    (gen-percents-syms max)))

(defmacro fn (code)
  (let ((arg-max (loop for sym in (flatten code)
                      maximize (percent-value sym)))
        (restp (find '%& (flatten code))))
    `(lambda ,(gen-percents-and-rest arg-max restp)
       ,(replace-symbol '% '%1 code))))

Ajout des crochets

Nous allons maintenant avoir recours aux read-macros pour modifier le code avant que le reader ne le lise. Tout ce que notre read-macro a à faire ici, c'est de transformer une expression du type [...] en (fn (...)). On définit donc une fonction qui sera appelée chaque fois qu'un crochet ouvrant est rencontré dans le code, qui se charge de lire le code jusqu'au crochet fermant et de retourner le code lu. Le premier argument d'une telle fonction est le flux depuis lequel lire le code, et le second correspond au caractère qui a déclenché son appel. Comme nous ne l'utilisons que pour un [, on peut ignorer ce second argument.

(defun bracket-reader (stream c)
  (declare (ignore c))
  (let (chars)
    (do ((char (read-char stream) (read-char stream)))
        ((char= char #\]))
      (push char chars))
    (read-from-string
     (format nil "(fn (~a))" (coerce (nreverse chars) 'string)))))

Et on définit finalement [ comme macro-character:

(set-macro-character #\[ #'bracket-reader)

Conclusion

Voilà, au travers de cet exemple j'espère avoir permis à certaines personnes de mieux comprendre l'intérêt des macros et en quoi elles sont si pratique. Le code final pour cette macro est disponible ici

Notez que les read-macros ne sont pas tellement utilisées en pratiques (on aurait très bien pu se limiter à la forme (fn (…)), qui n'est pas tellement plus longue à taper). Néanmoins elles peuvent être assez utiles, par exemple dans CLSQL, où elles permettent de distinguer le code Lisp et les requêtes SQL.

Si vous souhaitez lire plus à propos des macros, je vous recommande la lecture de On Lisp de Paul Graham et de Let Over Lambda de Doug Hoyte.

 

Add A Comment

Name:
Email:
Website:
Your Comment

Your submission will be ignored if the name, email, or comment field is left blank.

Your email address will never be displayed, but your homepage will be.