Search code examples
clojuremacros

Macro composition


I'm playing with Clojure macros a bit, specifically trying a macro version of map, which I imaginatively named "mmap":

(defmacro mmap [f coll]
    `(vector ~@(map #(list f %) coll)))

Please don't delve upon the reasons why I may be doing this. It's just a minimal reproducible example.

It works fine as long as a collection is passed to it, but when I try to compose it with another macro, mmap slices and dices the sexp with the macro itself, not its expansion.

I would like "mmap" to work like this:

(mmap inc [1 2 3 4])            => [2 3 4 5]
(mmap dec (mmap inc [1 2 3 4])) => [1 2 3 4] NOPE. See the edit.

So the question is: is there a way to force the expansion of a macro passed as an argument to another macro? In which case, how?

Thank you for any insight!

EDIT I just noticed that my example doesn't make sense, passing the result of the mmap macro would be like calling:

(mmap inc (vector 1 2 3 4))

which makes no sense in this specific case, but I still want to know if it can be done somehow

EDIT2 the use of a [1 2 3 4] array instead of a symbol, like "arg" was meant to convey that the whole thing is done at compile time, not runtime.


Solution

  • Functions always evaluate their arguments before invoking the function, leading expressions to be evaluated in the familiar "inside out" pattern:

    (/ (inc 3) (dec 3))  ; orig
    (/ 4 2)              ; args sent to `/` function
    2                    ; final result
    

    The purpose of a macro is to avoid evaluating the arguments before calling the function, leading to expressions being evaluated "outside in". This allows one to compose macros as if each were a new language feature:

    (ns tst.demo.core
      (:use demo.core tupelo.core tupelo.test))
    
    (defmacro infix
      [[l op r]]
      `(~op ~l ~r))
    
    (defmacro snoop
      [& forms]
      `(let [result# ~@forms]
         (do
           (println "snoop => " result#)
           result#)))
    
    (dotest
      (spyx (infix (2 + 3)))
      (spyx (infix (5 - 3)))
      (newline)
    
      (snoop (+ 1 2))
      (snoop (infix (4 + 5)))
      (newline)
    
      (println :last
        (infix ((snoop 4) + (snoop 5)))))
    

    with result

    (infix (2 + 3)) => 5
    (infix (5 - 3)) => 2
    
    snoop =>  3
    snoop =>  9
    
    snoop =>  4
    snoop =>  5
    :last 9
    

    So that final example has an intermediate stage that looks like:

    (println :last
      (+ (snoop 4) (snoop 5)))
    

    where can see that infix has been evaluated before the 2 snoop calls. This is exactly the strength of macros, and they would be useless without it.

    You could maybe create a new macro that worked like:

    (defmacro mmap
      [fns coll]
      `(let [comp-fn# (comp ~@fns)]
         (mapv comp-fn# ~coll)))
    
    (mmap [inc inc inc] [1 2 3]) => [4 5 6]
    

    While the above mmap works, it is really an abuse of the macro system, and might fail if you give it something other than compile-time constants.

    A better alternative for basic functions is simple memoization of a normal function:

    (def do-stuff
      (memoize ; will only execute on the first usage
        (fn
          [coll]
          (mapv #(* 2 %) (mapv inc coll)))))
    
    (do-stuff [1 2 3 4]) => [4 6 8 10]
    

    If you think even a single call at runtime is too much, I would suggest manually running the "macro-like" code in a pre-compilation step and saving the result in new "source file" that could then be compiled as usual (perhaps you are pre-computing a bunch of prime numbers, for example).

    The upshot is that the macro system is intended only for manipulating code in order to add new language features. Any other goal is better served using a different technique.


    Regarding mapv

    I also discovered mapv by accident and only after many months.

    Be sure to bookmark the Clojure CheatSheet and always keep a browser tab open to it. Study it regularly until you can remember each of the functions. :)

    Also be aware that some more obscure items are not listed there!


    Update:

    The "killer feature" of macros is to add new language features. In Java, et al, one can only add new libraries. As an example, spyx above is a macro very similar to the snoop example. For other examples see with-exception-default, vals->map, and it-> from the Tupelo library. In fact, many "core" Clojure features like the for expression or the and & or logical operators are actually macros that any application program could write (or modify!).