?assoc and ->>

8

I had a need today to take an existing map and put (assoc) a bunch of new key-value pairs into it. Additionally, many of these key-value pairs might have a nil value, in which case I’d prefer to skip the assoc. In particular, I didn’t want to overlay existing keys in the map with a nil, so it was not ok to just drop them and remove/ignore the nils later.

So I wrote this null-aware assoc called ?assoc (as ? is sometimes connected with null checking). Here was my cut:

(defn ?assoc
  "Same as assoc, but skip the assoc if v is nil"
  [m & kvs]
  (reduce
    #(let [[k v] %2]
       (if (not (nil? v))
         (assoc %1 k v)
         %1))
    m (partition 2 kvs)))

And you can see the difference in this simple example:

=> (assoc {} :a 1 :b nil :c 2)
{:c 2, :b nil, :a 1}
=> (?assoc {} :a 1 :b nil :c 2)
{:c 2, :a 1}

Since my colleagues are much smarter than me I threw it onto our chat room and Ryan came up with a much nicer solution to my eyes:

(defn ?assoc
  "Same as assoc, but skip the assoc if v is nil"
  [m & kvs]
  (->> kvs
    (partition 2)
    (filter second)
    flatten
    (apply assoc m)))

Understanding this requires being familiar with the ->> threading macro which I touched on a bit in the past. The threading macros are a nice way to “turn around” a series of function calls that share the same argument so they read forward instead of backward.

The second example is really just a rewrite of the following identical code:

(defn ?assoc [m & kv] 
  (apply assoc m 
    (flatten 
      (filter second 
        (partition 2 kvs)))))

In this version you read bottom-up (or right to left) to follow the data flow. With the threading macro, the result of each step is fed in as the last argument to the next step in the flow. Depending on context, this can be easier to read.

As far as the implementation goes it has the following steps:

  1. (partition 2) – take a list of key-value pairs like (:a 1 :b 2 :c 3) and (lazily) partition into groups: ((:a 1) (:b 2) (:c 3))
  2. (filter second) – filter items in the list where the function (second %) returns true (nicely getting rid of the ones with nil values)
  3. (flatten) – un-partition back into a list like the original
  4. (apply assoc m) – applies the function (assoc) to the args m and the kv pairs

Comments

8 Responses to “?assoc and ->>”
  1. It does have the unfortunate effect of filtering out if the value evaluates false for things other than nil. This may or may not be what you want.

    user> (?assoc {} :a true :b false :c true :d ())
    {:d nil, :c true, :a true}

  2. Alex says:

    @Glen: Yep, that’s true but that’s ok for my use case. I could use some variant of remove nil? there instead to be more precise.

  3. nickik says:

    (filter #(not (nil? (second %))) (partition 2 kvs))

    Thats easy but not as nice, but quite ok i guess.

  4. Anonymous says:

    Here’s another alternative, using for:

    (defn ?assoc
    [m & kvs]
    (for [[k v] (partition 2 kvs) :when v]
    (assoc m k v)))

    (This gets rid of nil and false values.)

  5. Michał Marczyk says:

    Nice function!

    There’s `(remove (comp nil? second))` for use with the `->>` version. Note that `flatten` will also flatten the values to be inserted into the map; it’s better to use `(apply concat)`.

    Also, the above version based on `for` will not work, but here’s a fix:

    (defn ?assoc [m & kvs]
    (into m
    (for [[k v :as e] (partition 2 kvs) :when v]
    (vec e))))

  6. Meikel says:

    Ui! Golf! :)

    (defn ?assoc
      [m & kvs]
      (->> kvs
        (partition 2)
        (remove (comp nil? second))
        (into m)))

  7. Meikel says:

    Urgs. Insert sufficiently many (map vec)’s. eg. between remove and into.

  8. Alex says:

    Thanks all, been meaning to catch up on all this. On further use and testing of the versions in the post I did indeed find that there were some issues. Most importantly the values I was checking weren’t actually values but actually vectors containing nils. That meant that both the flatten and the condition were incorrect. I’ve since rewritten my original version to instead be assoc-if and pass a fn that evaluates each [k v] per fn to see whether the pair should be assoc’ed.