0
votes

In an attempt to better learn macros I've been playing around with a few simple examples including recreating a simplified thread-last. I'm having trouble understanding why one version below results in a stack-overflow and the other doesn't.

;; version one - blows up w/ stack overflow
(defmacro ->>> [e1 & exprs]
  `(if ~exprs
       (->>> ~(concat (first exprs) (list e1)) ~@(rest exprs))
       ~e1))

;; version two, works just fine
(defmacro ->>> [e1 & exprs]
  (if exprs
       `(->>> ~(concat (first exprs) (list e1)) ~@(rest exprs))
       e1))

My initial reaction was that it must be due to the fact that in the first example, although the resulting expansion looks as if it would run just fine if it were normal code, since it's a macro the recursive call continually expands and the if test never happens. In the second version, the if test happens before any list is returned for run-time evaluation giving a chance to break out.

However, I'm not sure if this mental model is correct because the following example (Clojure Brave & True) looks rather similar to the first version above, and works fine:

(defmacro our-and
  ([] true)
  ([x] x)
  ([x & next]
 `(if ~x (our-and ~@next) ~x)))

EDIT: To clarify I mean that the above our-and is similar structurally (not semantically) in that it returns a list which contains the recursive call to the macro, similar to version one of my thread-last replica above.

2

2 Answers

1
votes

Your mental model is correct. It might help to think of a macro as a function that takes code and returns code, and is run at compile-time. This should clear up the differences between the first example and our-and.

In the first example, we have a function that takes code and always return code that uses the ->>> macro, resulting in infinite macro expansion. Remember, the if expression in that quoted code will evaluate at run time, but you're getting the stack overflow at compile time when macro evaluation occurs.

In our-and, we have a function that has three clauses. In two of the clauses, evaluated first, it returns code that doesn't contain itself. In the third clause, it returns code containing itself. This makes it similar to example 2, not example 1.

1
votes

Sometimes it is easier to start with a plain function and vectors of data. Here is an example:

(ns tst.demo.core
  (:use tupelo.core demo.core tupelo.test))

(defn ->>>
  [val & exprs]
  (spyx val)
  (spyx exprs)
  (if (empty? exprs)
    val
    (let [[expr & others] exprs
          >>     (spyx expr)
          >>     (spyx others)
          [fn & args] expr
          >>     (spyx fn)
          >>     (spyx args)
          fncall (concat [fn val] args)
      >> (spyx fncall)
      result (concat ['->>> fncall] others)]
      (spyx result) )))

with output:

val => :val
exprs => ([:form1 1 2 3] [:form2 4 5])

expr => [:form1 1 2 3]
others => ([:form2 4 5])

fn => :form1
args => (1 2 3)

fncall => (:form1 :val 1 2 3)
result => (->>> (:form1 :val 1 2 3) [:form2 4 5])

(->>> :val [:form1 1 2 3] [:form2 4 5]) 

     => (->>> (:form1 :val 1 2 3) [:form2 4 5])

So you can see it threaded the :val into the right spot (thread-first style) and is set up for the recursive call. Getting closer to a macro, we make a helper fn:

(defn my-thread-first-impl
  [val & exprs]
  (spyx val)
  (spyx exprs)
  (if (empty? exprs)
    val
    (let [[expr & others] exprs
          >>     (spyx expr)
          >>     (spyx others)
          [fn & args] expr
          >>     (spyx fn)
          >>     (spyx args)
          fncall (concat [fn val] args)
          >>     (spyx fncall)
          result `(my-thread-first-impl ~fncall ~@others)]
      result)))

; (defmacro my-> [forms] )

(dotest
  (spyx (my-thread-first-impl :val
          '(fn-1 1 2 3)
          '(fn-2 4 5) ))

val => :val
exprs => ((fn-1 1 2 3) (fn-2 4 5))
expr => (fn-1 1 2 3)
others => ((fn-2 4 5))
fn => fn-1
args => (1 2 3)
fncall => (fn-1 :val 1 2 3)

  => (tst.demo.core/my-thread-first-impl (fn-1 :val 1 2 3) (fn-2 4 5))

Final version with macro & dummy fn's

(defn fn-1 [& args]
  (vec (cons :fn-1 args)))
(defn fn-2 [& args]
  (vec (cons :fn-2 args)))

(defn my-thread-first-impl
  [val & exprs]
  (spyx val)
  (spyx exprs)
  (if (empty? exprs)
    val
    (let [[expr & others] exprs
          >>     (spyx expr)
          >>     (spyx others)
          [fn & args] expr
          >>     (spyx fn)
          >>     (spyx args)
          fncall (concat [fn val] args)
          >>     (spyx fncall)
          result `(my-> ~fncall ~@others)]
      result)))

(defmacro my->
  [& forms]
  (apply my-thread-first-impl forms))

& result:

(my-> :val
  (fn-1 1 2 3)
  (fn-2 4 5))

 => [:fn-2 [:fn-1 :val 1 2 3] 4 5]