January 26, 2016

Update Nested Data Structures - Part 2

The last post described the basics of how to update maps inside nested data structures. The problem we wanted to solve was to be able to write a function to update a data structure representing fields on an html form. Specifically, we were interested in setting a radio button as checked while ensuring all other radio buttons are unchecked.

In this post, I'd like to show a few other ways to solve the same problem.

Quick Recap

Here's the data structure that we'll be using. It represents a bunch of fields on a form to calculate BMR with radio buttons for selecting the level of exercise someone does per week.

(def state-map
     {:gender "m",
      :weight {:value 180, :unit "lbs"},
      :height {:ft 6, :in 2},
      :age 36
      :levels {:sedentary {:multiplier 1.2
                           :desc "Little or no exercise"
                           :checked true}
               :light     {:multiplier 1.375
                           :desc "Light exercise (1-3 days/week)"
                           :checked false}
               :moderate  {:multiplier 1.55
                           :desc "Moderate exercise (3-5 days/week)"
                           :checked false}
               :active    {:multiplier 1.725
                           :desc "Hard exercise (6-7 days/week)"
                           :checked false}
               :heavy     {:multiplier 1.9
                           :desc "Very hard exercise (2x training)"
                           :checked false}
               }})

In the last post, we came up with this:

(update-in state-map [:levels]
           #(-> (into {} (for [[k v] %]
                           [k (assoc v :checked false)])) 
                (assoc-in [:light :checked] true)))

This works great in order to set the :light radio button to true and ensure all the others are set to false.

For this post, I'd like to show a few other ways to do this same thing.

Treating Maps as Sequences

Take a closer look at this:

user> (for [[k v] (:levels state-map)] [k (assoc v :checked false)])

That returns a data structure that looks like this (I removed some keys to save space):

([:sedentary {... :checked false}]
 [:light     {... :checked false}]
 [:moderate  {... :checked false}]
 [:active    {... :checked false}]
 [:heavy     {... :checked false}])

I think this is pretty cool. By using a for comprehension, we can treat the map of :levels as a list of items. Each item is a vector containing a key and a value. Each value is a map containing 3 keys: :checked, :multiplier, and desc. This allows us to manipulate the map as a list of things. This way we can use all our favorite list manipulating functions!

The only gotchya is that the for form returns a list (instead of a map). One way to convert the list of vectors of key/values back into a map is to use into:

user> into {} (for [[k v] (:levels state-map)] [k (assoc v :checked false)])

Just keep in mind that when you treat maps as vectors of pairs of key/values, you usually need to convert the result back into a map.

Ok, now check this out: we can make this concept more generic. If you think about it, what we really want to do is to apply a function (in this case assoc) to each value in the list of key/value pairs. So, let's try to make a function that does just that:

user> (defn update-vals [m f & args]
        (into {} (for [[k v] m] [k (apply f v args)])))

user> (update-vals (:levels state-map) assoc :checked false)
{:sedentary
 {:multiplier 1.2, :desc "Little or no exercise", :checked false},
 :light
 {:multiplier 1.375,
  :desc "Light exercise (1-3 days/week)",
  :checked false},
 :moderate
 {:multiplier 1.55,
  :desc "Moderate exercise (3-5 days/week)",
  :checked false},
 :active
 {:multiplier 1.725,
  :desc "Hard exercise (6-7 days/week)",
  :checked false},
 :heavy
 {:multiplier 1.9,
  :desc "Very hard exercise (2x training)",
  :checked false}}

Too cool!

Alternative #1 - Reduce

Instead of using a for comprehension, let's try re-implementing the same function using reduce!

user> (defn update-vals [m f & args]
        (reduce (fn [acc [k v]] (assoc acc k (apply f v args))) {} m))

That'll work the same as our for version, pretty sweet!

Alternative #2 - reduce-kv

Guess what? There's already a function available that sort of acts like our reduce version of update-vals. It's called reduce-kv.

Here's what update-vals looks like implemented using reduce-kv

user> (defn update-vals [m f & args]
        (reduce-kv (fn [acc k v] (assoc acc k (apply f v args))) {} m))

It's almost identical to the last version, but we saved a few keystrokes at least.

How 'bout another?

Alternative #3 - map

Now that we're treating maps as vectors of pairs, we can use our trusty friend map:

user> (defn update-vals [m f & args]
        (into {} (map (fn [[k v]] [k (apply f v args)]) m)))

Alternative #4 - zipmap

Here's another cool function: zipmap.

zipmap takes a list of keys and a list of values and "zips" them up into a map.

(Think of a zipper on a jacket, where the teeth of the zipper on one side is made from the keys from the map and the zipper on the other side is made from the values from the map)

(defn update-vals [m f & args]
  (zipmap (keys m) (for [v (vals m)] (apply f v args))))

What about the keys?

So far, we've used for, map, reduce, reduce-kv, and zipmap to change multiple values inside a map. But, what if we don't want to change the values? What if we want to change the keys?

We can use the same pattern, except instead of applying the function to values, we'll apply it to keys:

user> (defn update-keys [m f & args]
        (into {} (for [[k v] m] [(apply f k args) v])))

user> (defn update-keys [m f & args]
        (reduce (fn [acc [k v]] (assoc acc (apply f k args) v)) {} m))
        
user> (defn update-keys [m f & args]
        (reduce-kv (fn [acc k v] (assoc acc (apply f k args) v)) {} m))

user> (defn update-keys [m f & args]
        (into {} (map (fn [[k v]] [(apply f k args) v]) m)))
        
user > (defn update-keys [m f & args]
        (zipmap (for [k (keys m)] (apply f k args)) (vals m)))

Now we can use any of those implementations to apply functions to all the keys. For example, we can change the keywords to strings:

user> (update-keys (:levels state-map) name)
{"sedentary"
 {:multiplier 1.2, :desc "Little or no exercise", :checked true},
 "light"
 {:multiplier 1.375,
  :desc "Light exercise (1-3 days/week)",
  :checked false},
 "moderate"
 {:multiplier 1.55,
  :desc "Moderate exercise (3-5 days/week)",
  :checked false},
 "active"
 {:multiplier 1.725,
  :desc "Hard exercise (6-7 days/week)",
  :checked false},
 "heavy"
 {:multiplier 1.9,
  :desc "Very hard exercise (2x training)",
  :checked false}}

Very Nice!

Ok, smarty pants, how about both keys and values?

You want to be able to change keywords AND values? Man, you're needy!

user> (defn update-items [m f & args]
        (into {} (for [[k v] m] (apply f k v args))))

user> (defn update-items [m f & args]
        (reduce (fn [acc [k v]] (let [[k' v'] (apply f k v args)] (assoc acc k' v') )) {} m))
        
user> (defn update-items [m f & args]
        (reduce-kv (fn [acc k v] (let [[k' v'] (apply f k v args)] (assoc acc k' v') )) {} m))

user> (defn update-items [m f & args]
        (into {} (map (fn [[k v]] (apply f k v args)) m)))

In each of these implementations, you must pass a function f that accepts two arguments: the first is the key and the second is the value. The f function must return a vector with 2 items (a.k.a a pair, or a tuple).

Note that zipmap is not a great candidate for this case.

Here's how we might use update-items to change both the key and value of each item at the same time:

user> (update-items (:levels state-map) #(vector (name %1) (assoc %2 :checked false)))
{"sedentary"
 {:multiplier 1.2, :desc "Little or no exercise", :checked false},
 "light"
 {:multiplier 1.375,
  :desc "Light exercise (1-3 days/week)",
  :checked false},
 "moderate"
 {:multiplier 1.55,
  :desc "Moderate exercise (3-5 days/week)",
  :checked false},
 "active"
 {:multiplier 1.725,
  :desc "Hard exercise (6-7 days/week)",
  :checked false},
 "heavy"
 {:multiplier 1.9,
  :desc "Very hard exercise (2x training)",
  :checked false}}

swap!

As a final note, I think it's a real thing of beauty to see how well all of these functions can be used along with swap!.

If you need a quick refresher about atoms and swap!, here's a great article: http://www.lispcast.com/atom-problem.

Let's put our state-map into an atom named state.

user> (def state (atom state-map))

And now, look how it all just fits together with swap! and update-in:

;; use update-vals to set all :checked to false
user> (swap! state update-in [:levels] update-vals assoc :checked false)

;; use update-keys to change keywords to strings
user> (swap! state update-in [:levels] update-keys name)

;; use update-items change strings back to keywords and set all :checked to true
user> (swap! state update-in [:levels] update-items #(vector (keyword %1) (assoc %2 :checked true)))

Beautiful!

(I'll leave it to you to prove to yourself that those work as expected!)

Credits

Much thanks to this post by Jay Fields and this example from the clojure cookbook.

Tags: clojure software clojurescript