macroscommon-lisppractical-common-lisp

Using the `once-only` macro


At the end of Ch 8 in Practical Common Lisp, Peter Seibel presents the once-only macro. Its purpose is to mitigate a number of subtle problems with variable evaluation in user-defined macros. Note I'm not trying to understand at this point how this macro works, as in some other posts, but just how to use it properly:

(defmacro once-only ((&rest names) &body body)
  (let ((gensyms (loop for n in names collect (gensym))))
    `(let (,@(loop for g in gensyms collect `(,g (gensym))))
      `(let (,,@(loop for g in gensyms for n in names collect ``(,,g ,,n)))
        ,(let (,@(loop for n in names for g in gensyms collect `(,n ,g)))
           ,@body)))))

The following is a sample (incorrect) contrived macro that attempts to exhibit several variable evaluation problems. It purports to iterate over a range of integers by some delta, returning the range:

(defmacro do-range ((var start stop delta) &body body)
  "Sample macro with faulty variable evaluations."
  `(do ((,var ,start (+ ,var ,delta))
        (limit ,stop))
       ((> ,var limit) (- ,stop ,start))
     ,@body))

For example, (do-range (i 1 15 3) (format t "~A " i)) should print 1 4 7 10 13 and then return 14.

The problems include 1) potential capture of the second occurrence of limit, since it occurs as a free variable, 2) potential capture of the initial occurrence of the bound variable limit, since it occurs in an expression along with other variables appearing in the macro parameters, 3) out of order evaluation, since delta will be evaluated before stop, even though stop appears before delta in the parameter list, and 4) multiple variable evaluations, since stop and start are evaluated more than once. As I understand it, once-only should fix these problems:

(defmacro do-range ((var start stop delta) &body body)
  (once-only (start stop delta limit)
    `(do ((,var ,start (+ ,var ,delta))
          (limit ,stop))
         ((> ,var limit) (- ,stop ,start))
       ,@body)))

However, (macroexpand '(do-range (i 1 15 3) (format t "~A " i))) complains about limit being an unbound variable. If I switch instead to with-gensyms, which should take care of problems 1 & 2 above only, the expansion proceeds without incident.

Is this an issue with the once-only macro? And does once-only really solve all the problems outlined above (and perhaps others)?


Solution

  • The ONCE-ONLY macro

    To get rid of a warning that N is unused, I would change the macro to:

    (defmacro once-only ((&rest names) &body body)
      (let ((gensyms (loop for nil in names collect (gensym))))
                               ; changed N to NIL, NIL is ignored
        `(let (,@(loop for g in gensyms collect `(,g (gensym))))
          `(let (,,@(loop for g in gensyms for n in names collect ``(,,g ,,n)))
            ,(let (,@(loop for n in names for g in gensyms collect `(,n ,g)))
               ,@body)))))
    

    The purpose of this macro is to make sure that expressions are only evaluated once and in a defined order. For that it will introduce new uninterned variables and will bind the evaluation results to those. Inside he macro the new variables are available. The macro itself is provided to make writing macros easier.

    Using ONCE-ONLY in DO-RANGE

    Your example use of ONCE-ONLY:

    (defmacro do-range ((var start stop delta) &body body)
      (once-only (start stop delta limit)
        `(do ((,var ,start (+ ,var ,delta))
              (limit ,stop))
             ((> ,var limit) (- ,stop ,start))
             ,@body)))
    

    Why is there LIMIT in the once-only list? limit is undefined there. LIMIT is used inside the ONCE-ONLY form as a symbol, but outside there is no binding.

    ONCE-ONLY expects that the list of names is a list of symbols and that these names are bound to forms. In your case limit is a symbol, but it is undefined.

    We need to remove limit from the list of names:

    (defmacro do-range ((var start stop delta) &body body)
      (once-only (start stop delta)
        `(do ((,var ,start (+ ,var ,delta))
              (limit ,stop))
             ((> ,var limit) (- ,stop ,start))
             ,@body)))
    

    Now, what to do about LIMIT? Given that once-only provides bindings for the names, including for STOP, we can eliminate the symbol LIMIT and replace its use with ,stop:

    (defmacro do-range ((var start stop delta) &body body)
      (once-only (start stop delta)
        `(do ((,var ,start (+ ,var ,delta)))
             ((> ,var ,stop) (- ,stop ,start))
           ,@body)))
    

    Example:

    CL-USER 137 > (pprint
                   (macroexpand
                    '(do-range (i 4 10 2)
                       (print i))))
    
    (LET ((#1=#:G2170 4)
          (#3=#:G2171 10)
          (#2=#:G2172 2))
      (DO ((I #1# (+ I #2#)))
          ((> I #3#) (- #3# #1#))
       (PRINT I)))