bobbicodes / bobbi-lisp

Interactive Lisp environment for learning Clojure
https://bobbicodes.github.io/bobbi-lisp/
0 stars 0 forks source link

Multiarity functions #7

Closed bobbicodes closed 12 months ago

bobbicodes commented 12 months ago

This was previously handled by defn when it was a special form, but now it is a macro like it should be, because with the exception of docstrings, everything about defn needs to be handled by functions created any other way.

What made me realize this was the Clojure source for juxt:

(defn juxt 
  "Takes a set of functions and returns a fn that is the juxtaposition
  of those fns.  The returned fn takes a variable number of args, and
  returns a vector containing the result of applying each fn to the
  args (left-to-right).
  ((juxt a b c) x) => [(a x) (b x) (c x)]"
  {:added "1.1"
   :static true}
  ([f] 
     (fn
       ([] [(f)])
       ([x] [(f x)])
       ([x y] [(f x y)])
       ([x y z] [(f x y z)])
       ([x y z & args] [(apply f x y z args)])))
  ([f g] 
     (fn
       ([] [(f) (g)])
       ([x] [(f x) (g x)])
       ([x y] [(f x y) (g x y)])
       ([x y z] [(f x y z) (g x y z)])
       ([x y z & args] [(apply f x y z args) (apply g x y z args)])))
  ([f g h] 
     (fn
       ([] [(f) (g) (h)])
       ([x] [(f x) (g x) (h x)])
       ([x y] [(f x y) (g x y) (h x y)])
       ([x y z] [(f x y z) (g x y z) (h x y z)])
       ([x y z & args] [(apply f x y z args) (apply g x y z args) (apply h x y z args)])))
  ([f g h & fs]
     (let [fs (list* f g h fs)]
       (fn
         ([] (reduce1 #(conj %1 (%2)) [] fs))
         ([x] (reduce1 #(conj %1 (%2 x)) [] fs))
         ([x y] (reduce1 #(conj %1 (%2 x y)) [] fs))
         ([x y z] (reduce1 #(conj %1 (%2 x y z)) [] fs))
         ([x y z & args] (reduce1 #(conj %1 (apply %2 x y z args)) [] fs))))))

First of all... wow. A bit more involved than I would have imagined. But the part that shocked me is that the function dispatches on arity and returns an anonymous function that dispatches on arity. Yikes... I had no idea that was a thing.

This answers the question that was at the back of my mind, whether arities should be something managed on the function definition level or at the calling level, the latter being a feature baked into the function type itself. And it seems that needs to be the case. Instead of a function being, well, a function, it needs to be a group of functions.

Of course, the defn macro needs to also handle it because it fundamentally changes the structure of the form the macro has to deal with.

bobbicodes commented 12 months ago

While the support for multiarity functions went away with the defn special form, I do have an implementation of map that works on multiple collections. The way I'm handling it is like this:

(defn map1 [f coll]
  (loop [s (seq coll) res []]
    (if (empty? s) res
        (recur (rest s) (conj res (f (first s)))))))

(defn map2 [f c1 c2]
  (loop [s1 (seq c1) s2 (seq c2) res []]
    (if (or (empty? s1) (empty? s2)) res
        (recur (rest s1) (rest s2)
               (conj res (f (first s1) (first s2)))))))

(defn map3 [f c1 c2 c3]
  (loop [s1 (seq c1) s2 (seq c2) s3 (seq c3) res []]
    (if (or (empty? s1) (empty? s2) (empty? s3)) res
        (recur (rest s1) (rest s2) (rest s3)
               (conj res (f (first s1) (first s2) (first s3)))))))

(defn map [f & colls]
  (cond
    (empty? (first colls)) '()
    (= 1 (count colls)) (map1 f (first colls))
    (= 2 (count colls)) (map2 f (first colls) (second colls))
    (= 3 (count colls)) (map3 f (first colls) (second colls) (last colls))
    :else (throw (str "Map not implemented on " (count colls) " colls"))))

While this is obviously an ad-hoc solution, it illustrates the process that could be performed by a macro, if we were to go with the path of arities being handled on the definition side. The defn macro could expand to code that defines a function for each arity, and then a dispatch function.

bobbicodes commented 12 months ago

But the juxt example leads me to think that we actually need a different kind of function, something like an arity map. I saw something like this in SCI:

(defn fn-arity-map [ctx enclosed-array fn-name macro? fn-bodies]
  (reduce
   (fn [arity-map fn-body]
     (let [f (fun ctx enclosed-array fn-body fn-name macro?)
           var-arg? (:var-arg-name fn-body)
           fixed-arity (:fixed-arity fn-body)]
       (if var-arg?
         (assoc arity-map :variadic f)
         (assoc arity-map fixed-arity f))))
   {}
   fn-bodies))

https://github.com/babashka/sci/blob/292431c7fd13e5ce9cdc20cb78be742dabd15d8d/src/sci/impl/fns.cljc#L133

I don't actually get it. The whole codebase honestly seems rather incomprehensible to me. But I guess it reveals the basic strategy, although I have no idea where this map is used or created, and reading the source seems to make me more confused rather than less...

But the general idea is that arities are like a first class thing. There can be a step in the interpreter that detects if the function has multiple arity bodies, and call the correct one based on the number of args.

bobbicodes commented 12 months ago

This was partially fixed by #8 but unfortunately I neglected to fully test it before I got really excited that I actually completed a feature without having a nervous breakdown.

In my test case, the unary arity doesn't work on the outer fn:

(defn a
  ([]
  (fn
    ([] "no args")
    ([n] (str "one arg: " n))))
  ([n]
   (fn
    ([] "no args")
    ([n] (str "one arg: " n)))))

((a 1)) => 
Error: 'n' not found 

But both arities of the nullary arity of the outer fn work, which are the ones I tested:

((a)) => "no args" 
((a) "hi") => "one arg: hi"
bobbicodes commented 12 months ago

But check it out: It works perfectly if we remove the defn:

(((fn
  ([] (fn ([] "outer: 0 args, inner: 0 args")
          ([n] (str "outer: 0 args, inner: 1 arg: " n))))
  ([n] (fn ([] "outer: 1 arg, inner: 0 args")
           ([n] (str "outer: 1 arg, inner: 1 arg: " n)))))))
=> "outer: 0 args, inner: 0 args"

(((fn
  ([] (fn ([] "outer: 0 args, inner: 0 args")
          ([n] (str "outer: 0 args, inner: 1 arg: " n))))
  ([n] (fn ([] "outer: 1 arg, inner: 0 args")
           ([n] (str "outer: 1 arg, inner: 1 arg: " n)))))
  1)) => "outer: 1 arg, inner: 0 args" 

(((fn
  ([] (fn ([] "outer: 0 args, inner: 0 args")
          ([n] (str "outer: 0 args, inner: 1 arg: " n))))
  ([n] (fn ([] "outer: 1 arg, inner: 0 args")
           ([n] (str "outer: 1 arg, inner: 1 arg: " n)))))
 1) "hi") => "outer: 1 arg, inner: 1 arg: hi" 

(((fn
  ([] (fn ([] "outer: 0 args, inner: 0 args")
          ([n] (str "outer: 0 args, inner: 1 arg: " n))))
  ([n] (fn ([] "outer: 1 arg, inner: 0 args")
           ([n] (str "outer: 1 arg, inner: 1 arg: " n))))))
 "hi") => "outer: 0 args, inner: 1 arg: hi" 

Tested against Clojure. Nice! So what's up with defn that is swallowing the args or whatever?

bobbicodes commented 12 months ago

OMG I think it's actually so incredibly simple it's embarrassing. Ready for this?

The reason it's not being correctly handled by defn is I never implemented arities in defn. That's right. Not at all.

Here is defn:

(defmacro defn [name & fdecl]
  (if (string? (first fdecl))
    `(def ~name (with-meta (fn ~(second fdecl) (do ~@(nnext fdecl)))
                  ~{:name (str name) :doc (first fdecl)}))
    `(def ~name (with-meta (fn ~(first fdecl) (do ~@(rest fdecl)))
                  ~{:name (str name)}))))

Do you see anything in there about handling arity bodies? Nope, nope, nope. Is handling arity bodies an essential part of defn? Yup, yup, yup!

bobbicodes commented 12 months ago

Done! Here is the new defn macro:

(defmacro defn [name & fdecl]
  (if (string? (first fdecl))
    (if (list? (second fdecl))
      `(def ~name (with-meta (fn ~@(rest fdecl))
                    ~{:name (str name) :doc (first fdecl)}))
      `(def ~name (with-meta (fn ~(second fdecl) (do ~@(nnext fdecl)))
                    ~{:name (str name) :doc (first fdecl)})))
    (if (list? (first fdecl))
      `(def ~name (with-meta (fn ~@fdecl)
                    ~{:name (str name)}))
      `(def ~name (with-meta (fn ~(first fdecl) (do ~@(rest fdecl)))
                    ~{:name (str name)})))))

Now, it could be made simpler by doing what the actual Clojure defn does, which is conditionally redefine fdecl as it goes. But whatevs... it works!

If at some point I decide to add support for adding arbitrary metadata (besides docstrings), I might rethink it because it will get twice as hairy. But right now, I don't care.