Welcome! Please see the About page for a little more info on how this works.

+2 votes
ago in Macros by
edited ago by

The bodies of doseq and for seem to be duplicated in their expansions.

$ clojure
user=> (defmacro a [] (prn :expand))
#'user/a
user=> (doseq [_ nil] (a))
:expand
:expand
nil
user=> (for [_ nil] (a))
:expand
:expand
()

I found the same problem in ClojureScript. I could not find an existing discussion about this so I'm not sure if this is by design, apologies if so. I'm aware that these macros have a large code footprint and that doseq cannot use closures (the usual way to prevent exponential expansion), but I missed this detail.

This leads to exponential code blowup if these forms are nested. Artificial demonstration:

$ clojure
Clojure 1.12.0
user=> (def counter (atom -1))
#'user/counter
user=> (defmacro a [] (prn :expand (swap! counter inc)))
#'user/a
user=> #(doseq [_ 1] (doseq [_ 2] (doseq [_ 3] (doseq [_ 4] (a)))))
:expand 0
:expand 1
:expand 2
:expand 3
:expand 4
:expand 5
:expand 6
:expand 7
:expand 8
:expand 9
:expand 10
:expand 11
:expand 12
:expand 13
:expand 14
:expand 15
#object[user$eval926$fn__927 0x76563d26 "user$eval926$fn__927@76563d26"]

This might be especially relevant to core.async, where placing go under doseq is idiomatic, since go is expensive in both expansion time and code size.

user=> (time (eval '(doseq [_ nil] (a/go (doseq [_ nil] (a/go (doseq [_ nil] (a/go))))))))
"Elapsed time: 837.230917 msecs"
nil

It also leads to duplicated reflection warnings from the fully expanded forms, which is how I found this problem.

user=> (doseq [_ nil] (Thread/sleep (identity 1)))
Reflection warning, NO_SOURCE_PATH:1:16 - call to static method sleep on java.lang.Thread can't be resolved (argument types: unknown).
Reflection warning, NO_SOURCE_PATH:1:16 - call to static method sleep on java.lang.Thread can't be resolved (argument types: unknown).
nil

I believe the exponential expansion for doseq was introduced in Clojure 1.1.0 with support for chunked seqs with this commit.

$ clojure -Sdeps '{:deps {org.clojure/clojure {:mvn/version "1.0.0"}}}' 
Downloading: org/clojure/clojure/1.0.0/clojure-1.0.0.pom from central
Downloading: org/clojure/clojure/1.0.0/clojure-1.0.0.jar from central
Clojure 1.0.0-
user=> (defmacro a [] (prn :expand))
#'user/a
user=> (doseq [_ nil] (a))
:expand
nil
user=> ^D
$ clojure -Sdeps '{:deps {org.clojure/clojure {:mvn/version "1.1.0"}}}'
Downloading: org/clojure/clojure/1.1.0/clojure-1.1.0.pom from central
Downloading: org/clojure/clojure/1.1.0/clojure-1.1.0.jar from central
Clojure 1.1.0
user=> (defmacro a [] (prn :expand))
#'user/a
user=> (doseq [_ nil] (a))
:expand
:expand
nil
user=> 

At first I wasn't sure if this was hopeless, but I think doseq is fixable fusing the chunked and non-chunked cases like so:

(defmacro doseq
  "Repeatedly executes body (presumably for side-effects) with
  bindings and filtering as provided by \"for\".  Does not retain
  the head of the sequence. Returns nil.
  
  Unlike clojure.core/doseq, does not cause exponential macro expansion
  of expressions in bindings or body."
  [seq-exprs & body]
  (#'clojure.core/assert-args
     (vector? seq-exprs) "a vector for its binding"
     (even? (count seq-exprs)) "an even number of forms in binding vector")
  (let [step (fn step [recform exprs]
               (if-not exprs
                 [true `(do ~@body)]
                 (let [k (first exprs)
                       v (second exprs)]
                   (if (keyword? k)
                     (let [steppair (step recform (nnext exprs))
                           needrec (steppair 0)
                           subform (steppair 1)]
                       (cond
                         (= k :let) [needrec `(let ~v ~subform)]
                         (= k :while) [false `(when ~v
                                                ~subform
                                                ~@(when needrec [recform]))]
                         (= k :when) [false `(if ~v
                                               (do
                                                 ~subform
                                                 ~@(when needrec [recform]))
                                               ~recform)]))
                     (let [seq- (gensym "seq_")
                           chunk- (with-meta (gensym "chunk_")
                                             {:tag 'clojure.lang.IChunk})
                           count- (gensym "count_")
                           i- (gensym "i_")
                           in-chunk- (gensym "in-chunk_")
                           recform `(if ~in-chunk-
                                      (recur ~seq- ~chunk- ~count- (unchecked-inc ~i-))
                                      (recur (next ~seq-) nil 0 0))
                           steppair (step recform (nnext exprs))
                           needrec (steppair 0)
                           subform (steppair 1)]
                       [true
                        `(loop [~seq- (seq ~v), ~chunk- nil,
                                ~count- 0, ~i- 0]
                           (let [~in-chunk- (< ~i- ~count-)
                                 ~seq- (if ~in-chunk- ~seq- (seq ~seq-))]
                             (when (if ~in-chunk- true ~seq-)
                               (let [chunked?# (if ~in-chunk- false (chunked-seq? ~seq-))
                                     ~k (if ~in-chunk-
                                          (.nth ~chunk- ~i-)
                                          (if chunked?# nil (first ~seq-)))]
                                 (if (if ~in-chunk- false chunked?#)
                                   (let [c# (chunk-first ~seq-)]
                                     (recur (chunk-rest ~seq-) c#
                                            (int (count c#)) (int 0)))
                                   (do ~subform
                                       ~@(when needrec [recform])))))))])))))]
    (nth (step nil (seq seq-exprs)) 1)))

Please log in or register to answer this question.

...