January 18, 2016

Update Nested Data Structures - Part 1

Updating a value nested deeply within a data structure can be a little tricky in my experience.

Clojure, and functional programming in general, provides some really neat strategies for manipulating nested immutable data structures.

In this post, I'll try to use the real world example of adding radio buttons to a html form to give an overview of the basic functions and techniques clojure provides for updating nested data structures.

In Part 2, I'll try to build on what we come up with here and explore some more advance functions for manipulating nested data structures.

Total Calories Burned per Day

In a previous blog post, I made a BMR calculator. Let's try to add radio buttons to the BMR calculator in order to calculate total calories burned per day. By doing so, we'll be forced to think about how to update values that are deeply nested inside the data structure that represents the calculator's state.

Your BMR only gives an estimate of the amount of calories your body burns when it's at rest. So, BMR is really only half the equation. In addition to BMR, we need to know how many calories our bodies burn during activities.

In order to get a complete estimate of the total number of calories your body burns each day (in order to maintain current weight), we can take the BMR from the last post, and multiply it as follows:

  • Little to to no exercise, BMR * 1.2
  • Light exercise (1-3 days per week), BMR * 1.375
  • Moderate exercise (3-5 days per week), BMR * 1.55
  • Heavy exercise (6-7 days per week), BMR * 1.725
  • Very Heavy exercise (twice per day, extra heavy workouts), BMR * 1.9

(These multipliers are part of the Harris-Benedict equation)

The Current Application State

The BMR Calculator uses a data structure that looks like this:

(def state (atom
 {:gender "m"
  :weight {:value 220 :unit "lbs"} 
  :height {:ft 6 :in 2}
  :age 36}))

You can see a live example of how the calculator updates this state here

At this point, this is a pretty simple hash map. We can use swap!, and assoc to easily replace the value of 220 with 180. But don't worry! If you're not quite comfortable with this yet, don't stop reading, we're going to break it down even simpler.

(swap! state assoc-in [:weight :value] 180)

Simpler Map Updates

Let's forget about the atom for a moment and start with a simple map named state-map:

user> (def state-map
           {:gender "m"
            :weight {:value 220 :unit "lbs"} 
            :height {:ft 6 :in 2}
            :age 36})

We can use assoc-in to transform this map into a new map with the value updated to 180.

user> (assoc-in state-map [:weight :value] 180)

{:gender "m",
 :weight {:value 180, :unit "lbs"},
 :height {:ft 6, :in 2},
 :age 36}

The assoc-in function requires 3 arguments:

  • The first is the map that you want to transform (in this casestate-map).
  • The second is a vector that represents the "path" to the value youwant to replace (in this case [:weight :value]).
  • And the third is the value you'd like to change it to (in this case, 180).

Still confused? Yeah, I was too when I first saw this stuff. Let's back up even more ...

Even Simpler Map Updates

The get Function

First, remember that we can use the get method like this

user> (get {:value 220 :unit "lbs"} :value)
220

Keywords are like get

Also, remember that clojure keywords (the things that start with colons, :) can be used to look up themselves. (I know, crazy, right?) Sort of like a shorthand for get function. So we also could have also done this:

user> (:value {:value 220 :unit "lbs"})
220

The assoc Function

We can use the assoc method to replace (or insert) values like this:

user> (assoc {:value 220 :unit "lbs"} :value 180)
{:value 180, :unit "lbs"}

The update

And the update function is also very handy. It's almost exactly like assoc. Except, it takes a function instead of a value. The function you pass to update has to look like something like this:

(fn [old-value] ;; return new value here )

So, we can use update like this:

user> (update {:value 220 :unit "lbs"}
              :value
              (fn [old] (println "Goodbye, " old) 180))
Goodbye,  220
{:value 180, :unit "lbs"}

See how that works? The return value of 180 is used to replace the old value of 220. (I used println just to show that the old value is available just in case you need it for anything). Pretty neat, eh?

Ok, so far, so good. Those are the basic functions. We can start with these basic functions and build on them. So, lets do it.

It gets complicated quickly

We really want to work with the entire state-map instead of little pieces of it at a time. So, let's try to use get and assoc together in order to process the whole state-map:

user> (assoc state-map :weight              ;;--> #3
             (assoc (get state-map :weight) ;;--> #1 
                    :value 180              ;;--> #2
                    ))
                    
{:gender "m",
 :weight {:value 180, :unit "lbs"},
 :height {:ft 6, :in 2},
 :age 36}

Wow, sort of confusing, right? We have to get the nested map associated with key :weight (#1), and then associate that with the :value of 180 (#2), and then assoc that whole result back to the key :weight (#3).

If you think that's a little painful, then you're in luck.

assoc-in to the Rescue!

Now we've come full circle back to assoc-in. assoc-in is like assoc but, instead of passing a single key, we can pass a vector of keys. The vector of keys describes the path into the map. If you know css, you can think of it sort of like css selector paths. So, we can rewrite the last expression like this:

user> (assoc-in state-map [:weight :value] 180)

{:gender "m",
 :weight {:value 180, :unit "lbs"},
 :height {:ft 6, :in 2},
 :age 36}

The update-in function corresponds to the assoc-in function just like update corresponds to the assoc function. Instead of a value, we need to pass a function (which takes the old value and return a new value.

user> (update-in state-map [:weight :value] (fn [_] 180)

{:gender "m",
 :weight {:value 180, :unit "lbs"},
 :height {:ft 6, :in 2},
 :age 36}

(Note that since we don't really care about the old value in this case, we can just use _ as the argument variable. That _ symbol is just a nice way to show that even though an argument is required, we really don't care what it is)

Now that we've grokked assoc-in and update-in, we can finally get to some radio buttons.

The Radio Buttons add Even more nesting

I chose to represent the exercise levels as another map where the keys are the exercise levels (like :sedentary, :light, etc) and the values are maps which contain more keys that describe attributes of each level. So, now state-map looks like this:

(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}
               }})

When a user clicks on a radio button, we'll need to update the corresponding radio button so that :checked is true.

Using assoc-in, that's easy enough. For example, let's pretend a user clicked on the "light" radio button. Let's set the :checked key in the :light map to true.

user> (assoc-in state-map [:levels :light :checked] true)

(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 true}
               :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}
               }})

Easy Peasy. But now see the problem? "sedentary" is still set to true as well. To fix that, we also need to make sure that checked is set to false for all the other radio buttons. This is really going to test our nested map updating skills.

List Comprehensions on Maps

First off, we can processes key value pairs in a map as a list using the following pattern:

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

([: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}])

That's called a list comprehension (in functional speak). And this fits perfectly with what we need. By treating the map as a list, we can easily process each level and make sure the :checked key is "false". Let's give it a shot:

user> (into {}                           ;;=> #3
        (for [[k v] (:levels state-map)] ;;=> #1
          [k (assoc v :checked false)]   ;;=> #2
          ))

{: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}}

Beautiful. We use the trick of using a for list comprehension to treat a map like a list of things (#1). Next, we replace :checked with false for each level in the list using our good friend assoc (#2). And finally, transform the list back to a map using into {}.

All Together Now

Drum roll, please. Let's put all the pieces together to update the :light exercise level so that :checked is true, while ensuring that all the other exercise levels have :checked false:

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

{: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 false},
  :light
  {:multiplier 1.375,
   :desc "Light exercise (1-3 days/week)",
   :checked true},
  :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}}}

Voila!

Next Up

If you think that was cool, in my next post, I'll keep banging on this same use case to describe some cool alternative functions that can do this same sort of thing.

Tags: clojure software clojurescript