Enhancing Clojure’s case to evaluate dispatch values

Update: Christophe Grand has pointed out a flaw in the case+ implementation below that affects usage of Java enums and classes in code that is AOT-compiled.  My bad.  I’m working on an update to case+ that will address the problem for enums – classes will likely revert to being unsupported.

For those that aren’t familiar, Clojure’s case macro is an über-switch statement that allows one to dispatch on any compile-time literal in constant time. This includes essentially all scalars (strings, keywords, symbols, numbers) and “composites thereof” (as the docs put it): literal sets, maps, and vectors. The latter feature pushes case a fair ways into pattern-matching territory.

Brief factoid interlude

Clojure’s defrecord would not exist as we know it without case, which backs the implementation of defrecord‘s keyword lookup mechanism.  Without that, records would be without the congruence with regular maps that they currently enjoy: each slot access would either need to run through get (e.g. (get some-record :slot-name)) or direct field access would be required (which would rule out function composition with keyword “accessors”, the use of the -> and ->> families of threading macros, etc).

The only downside of case is that it doesn’t evaluate the dispatch values that are provided. Only compile-time constants are matched, so if one has values that are defined in a var, accessible as (almost always static) Java class fields, or Java enums, you can’t use them as case dispatch values:

user=> (case javax.swing.JFrame/NORMAL
         javax.swing.JFrame/NORMAL "normal!")
java.lang.IllegalArgumentException: No matching clause: 0 (repl:78)
user=> (case 'javax.swing.JFrame/NORMAL
         javax.swing.JFrame/NORMAL "normal!")

As you can see, the dispatch value referring to the NORMAL static int field is being matched as a symbol, rather than the field it names. The same thing happens when attempting to refer to Java enums, values held by vars, classes, and so on. This is a problem insofar as I really don’t want to fall back to the sequential dispatch of cond or condp, but I need to use dispatch values that are more than just compile-time literals. My current solution is the poorly named case+ macro:

(defmacro case+
  "Same as case, but evaluates dispatch values, needed for referring to
   class and def'ed constants as well as java.util.Enum instances."
  [value & clauses]
  (let [clauses (partition 2 2 nil clauses)
        default (when (-> clauses last count (== 1))
                  (last clauses))
        clauses (if default (drop-last clauses) clauses)
        eval-dispatch (fn [d]
                        (if (list? d)
                          (map eval d)
                          (eval d)))]
    `(case ~value
       ~@(concat (->> clauses
                   (map #(-> % first eval-dispatch (list (second %))))
                   (mapcat identity))

With this available, the interop love flows like butter, and all of the dispatch literals already supported by case are unaffected because they all evaluate to themselves (FYI, Direction is a pre-existing enum class of mine):

user=> (case+ javax.swing.JFrame/NORMAL
         javax.swing.JFrame/NORMAL "normal!")
user=> (case Direction/East Direction/East "east!")
user=> java.lang.IllegalArgumentException: No matching clause: East (repl-1:88)
user=> (case+ Direction/East
         Direction/East "east!")
user=> (case String
         String "it's a string class")
java.lang.IllegalArgumentException: No matching clause: class java.lang.String (repl-1:90)
user=> (case+ String
         String "it's a string class")
"it's a string class"
user=> (def some-constant 42)
user=> (case 42
         some-constant "Towels for everyone!")
java.lang.IllegalArgumentException: No matching clause: 42 (repl-1:93)
user=> (case+ 42
         some-constant "Towels for everyone!")
"Towels for everyone!"

The big caveat is that the values used for dispatch in case+ are still resolved at compile-time – it is a macro, after all.  So, you can’t put case+ in a let form and dispatch on a let-bound value (attempting to do this will lead to an error, as locals cannot be eval‘ed).  Dispatch only on values available at compile-time, such as classes and class fields (which include enum instances).

I’ve used this extensively with good results (case+ accounts for perhaps a 50% of my total case usage), but I am just slightly concerned about my doing an end-around the key behaviour of case in not evaluating dispatch values. I didn’t happen to be paying attention in irc when the case design discussions were ongoing, and I don’t yet fully understand the motivation of the restriction. So, I offer this up somewhat tentatively for now – your mileage may vary. Do pipe up if you find a situation were case+ falls down.

2 thoughts on “Enhancing Clojure’s case to evaluate dispatch values

    1. Thank you, Christophe, that’s what I was looking for. I should have caught that earlier, as it was staring me straight in the face (I even wrote “the values used for dispatch in case+ are still resolved at compile-time” :-/). Java enums don’t have stable hashcodes either, which I never caught because I’ve not been using case+ in an AOT’d project yet.

      References to fields containing stable-hash-code values thankfully work without a hitch (which was my primary motivator in the first place), but ensuring that it works with enums is important to me as well (the only place where case is exceeded by Java’s switch). Falling back on the dispatch value enums’ ordinals will do (as is done in switch – and doing the same with the result of the first expression to case), probably raising an exception if all of the dispatch values aren’t enums of the same type.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s