Recurse.se Yada yada on Software Development

28Jun/130

Multimethods and default arguments

I had a situation the other day on my Clojure project, where I wanted to refactor a (parsing-related) function that was becoming too complex (ironically named "simplify") that looked like this;

(defn simplify 
    ([tokens]
        (simplify tokens 1)) ; provide a default weight of 1
    ([tokens weight]
        (case (first tokens)
            :choice
                ; several lines of :choice-specific code

            :list 
                ; several lines of :list-specific code

            ; and several more lines of implementations for other token types
            )))

Default values for arguments

Notice that "simplify" uses a Clojure idiom for providing default argments, providing several versions of the function with different arity, where the lower arity versions calls the higher arity versions with some default values for the omitted parameters. An example:

(defn add-some
  ([num]        (add-some num 10))      ; 1-argument version
  ([num some]   (+ num some)))          ; 2-argument version

Here the 1-argument version of "add-some" simply calls the 2-argument version with a default value for "some" (here "some" defaults to 10). We could even have a 0-argument version if we thought that made sense.

Enter multimethods

I was planning to break "simplify" apart using a Clojure multimethod, defining one version of simplify (a "handler") for :choice, another for :list and so on. I found that using the above default value idiom didn't map as cleanly as I expected to multimethods.

First, I wasn't sure if multimethods even supported several arities (it turned out they do), and I didn't find anything relevant online about it, so I banged out the following to start with:

; define simplify as a multimethod
(defmulti simplify 
    ; the dispatch function just selects the first token in the list 
    ; as the dispatch value
    (fn [tokens weight] (first tokens)))

; define a version of simplify that is called for the dispatch value :choice 
(defmethod simplify :choice 
    [tokens weight]
    ; some impl for handling :choice tokens
    )

; define a version of simplify that is called for the dispatch value :list 
(defmethod simplify :list 
    [tokens weight]
    ; some impl for handling :list tokens
    )

This looked like an improvement. But inside these functions I had several cases where I just wanted to simplify a list of tokens, where the weight was irrelevant, so a default weight of 1 would work fine. The old version just did

(map simplify tokens)

which now didn't work (as simplify is strictly a two-argument function). I could of course re-write all such instances into something like:

(map #(simplify % 1) tokens)

but #()-lambdas makes the code less readable in my opinion. I then briefly tried to provide a one argument version of simplify as a normal function using defn, hoping Clojure had a way to separate the two, but you cannot have a multimenthod and a normal method with the same name - rather obvious in hindsight - so that try didn't work out. I then renamed it "simplify-1" which didn't collide, but wasn't particularily beautiful.

(defn simplify-1 [token] (simplify token 1))

But at least then I could write:

(map simplify-1 tokens)

which I then just wrapped-up as `simplify-all, taking a list of tokens, which was a slight improvement as the code became a bit shorter.

Multi-arity multimethods

Later I read up on the arity of multimethods in my copy of "Clojure Programming" (as neither clojure.org nor ClojureDocs mentioned anything about multiple arity support in multimethods). It turns out they support the same arities as the multimethod dispatch function. I now thought the problem was solved, and came up with this variation:

(defmulti simplify 
    ; all versions of the dispatch function just selects 
    ; the first token in the list as the dispatch value
    (fn 
        ([tokens] (first tokens))
        ([tokens weight] (first tokens))))

But that means that if we call the 1-argument version of simplify, having a first token of, say, :list, we will end up calling the 1-argument version of simplify for :list, which we then have to implement.

(defmethod simplify :list
    ; call the 2-argument version (via the dispatch function) 
    ; with a default weight
    ([tokens] (simplify tokens 1))  
    ([tokens weight]
        ; some impl for handling :list tokens
    )

We also must implement it for all the other token types, such as :choice

(defmethod simplify :choice
    ([tokens] (simplify tokens 1))  ; call the 2-argument version
    ([tokens weight]
        ; some impl for handling :choice tokens
    ))

This approach quickly seemed much worse than having a simplify-1 function due to all the duplication.

The breakthrough

Then I realized that there was a much simpler solution! My mistake was to think the dispatch function should always do the "right thing" (TM) and dispatch to the token-specific version of simplify directly. My new insight was that the 1-argument dispatch function should statically dispatch to a single function that just called simplify again, providing the default value, which then would call the 2-argument version of the dispatch function to select the proper 2-argument handler!

Something like this:

(defmulti simplify 
    (fn 
        ; statically dispatch 1-argument calls to the :add-default handler 
        ([tokens] :add-default) 
        ([tokens weight] (first tokens))))

(defmethod simplify :add-default
    ; call the proper 2-argument handler through the dispatch function
    [tokens] (simplify tokens 1))

Then there's no need to provide other 1-argument versions of simplify, as no other handler beside :add-default will ever be called with just one argument!

(defmethod simplify :list
    [tokens weight]
        ; some impl for handling :list tokens
    )

So just to make it very clear, here's the step by step resolution of this process:

  • a call to the multimethod (simplify [:list 1 2 3]) ...
  • results in a call to simplify's 1-argument dispatch fn, which recommends the :add-default handler.
  • the :add-default handler will be called with [:list 1 2 3],
  • and will add our much-needed default value, and call the 2-argument multimethod (simplify [:list 1 2 3] 1),
  • resulting in a call to the 2-argument dispatch fn, which recommends the :list handler,
  • the :list handler is then finally called with two arguments, [:list 1 2 3] and our meticulously provided default weight of 1.

This might seem like huge overhead compared to what could have been a single function call, but I think the improved clarity of the code is totally worth it. Especially since simplify is just called relatively few times after parsing when breaking down the initial parse tree into a more appropriate data structure. Thus simplify is not worthy of micro-optimizations of removing a few function calls.

Defaults using variable arity or map destructuring

When writing this post, I came up with two more variations, using Clojures support for variable arity functions, and destructuring.

(defmulti simplify 
    ; allow, but ignore additional arguments
    (fn [tokens & _] (first tokens)))

(defmethod simplify :choice
    ; allow additional arguments, and unpack the first additional arg
    [tokens & [maybe_weight]]
        ; if a second arg was provided, it's in maybe_weight, else nil,
        ; in which case 'or' selects 1
        (let [weight (or maybe_weight 1)]
            ; some impl
        ))

(defmethod simplify :list
    ; allow additional arguments, and unpack the first additional arg
    [tokens & [maybe_weight]] 
        ; but we need to repeat the same code for providing defaults 
        ; in every defmethod
        (let [weight (or maybe_weight 1)]
            ; some impl
        ))

This would work, but has some obvious drawbacks.

  • A need to repeat the code for destructuring, and providing defaults
  • You can call the method using more than two parameters, but the additional parameters will just be silently ignored.

It would also be possible to use Clojures map destructuring support, to add a keyword argument for weight, with a default value of 1, something like;

; the param w takes its value from the keyword arg :weight, or 1 if not provided
(defn alt-simplify [tokens & {w :weight :or {w 1}}] 
    ; do something with w
    )

; called as
(alt-simplify some-tokens :weight 2)

I don't think this is a good use of keyword arguments, and the destructuring would still need to be repeated for all versions of the function. So even if the version using map destructuring is a tad shorter than the one using variable arity, I'm not very fond of any of them. At this point, I prefer the version using :add-default.