The path of least resistance in Clojure
(Edit 2: Standard disclaimer: Clojure is a new hobby.)
Immutable data structures seem like an odd idea, but they are on the path of least resistance in Clojure. This is not the case in languages like Java. In this post I will rewrite a snippet of Java into Clojure and explain the major differences.
The snippet of Java comes from Martin Fowler's bliki posting on Fluent Interfaces. I picked this code snippet because it is concise, reads easily, and fills a common need.
customer.newOrder()
.with(6, "TAL")
.with(5, "HPK").skippable()
.with(3, "LGV")
.priorityRush();
}
This code clearly links an existing customer to a new, rush order of three items. Consider the "skippable" method... that is probably on the Order object yet works on an OrderLine. The Order code probably finds the newest OrderLine and updates its skippable property. Modifying objects is the path of least resistance in Java, or rather creating objects in an incomplete state and updating them until complete.
Modifying objects in Clojure is not on the path of least resistance. Many Clojure methods return a new, lazy version of the input. Working with return values like this encourages creating complete objects and then using them. Here's an example -- please temporarily ignore the unfamiliar functions and low level of abstraction.
(let [lines [(struct order-line 6, "TAL")
(struct-map order-line :quantity 5,
:code "HPK", :skippable? true)
(struct order-line 3, "LGV")]
order (struct-map order :lines lines,
:rush? true)]
(merge customer
{:orders (conj (get customer
:orders []) order)})))
- (struct foo 123 "abc") is basically calling the constructor for the foo class with two arguments.
- (struct-map foo :num 123, :text "abc") calls the constructor with named params rather than positional params
- (let) is how you define local variables, lines and order in the above example.
- (merge old-map new-map) creates a new map by merging existing maps
- (conj) creates a new collection from its arguments
- (get) returns the value for a key in a hash map, or a default value if specified.
It's not obvious but the return value is the output from (merge). That is the last statement in the (let) block, which is the only statement in the body of the (add-order) function. Values flowing like this is in the path of least resistance in Clojure.
Besides doing things in a different order than Java, there is one more very important but subtle difference. Let's say a customer wants two orders:
Some quick code to show how it works:
(def me (add-order (struct customer "seth")))
;; how many orders?
(count (me :orders)) ;; 1
;; I'd like another order, please...
(add-order me)
;; Hey, where'd that go?
(count (me :orders)) ;; 1
;; Is the function even working?
(count (:orders (add-order me))) ;; 2, so yes
The new order has been lost because the return value from the function was not captured. This is a silent runtime failure -- not a fun type of problem to chase down. One solution is to rebind "me":
(count (me :orders)) ;; 2
I've been a little dishonest here talking about "objects" in Clojure. As seen here they are little different than hash maps.
clojure.lang.PersistentArrayMap
example> (defstruct foo :bar)
#'example/foo
example> (. (struct foo) getClass)
clojure.lang.PersistentStructMap
For anyone who is interested, the entire source listing for this example follows. Thanks for reading!
(ns example)
(defstruct customer :name :orders)
(defstruct order :lines :rush?)
(defstruct order-line :quantity :code :skippable?)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn add-order [customer]
(let [lines [(struct order-line 6, "TAL")
(struct-map order-line :quantity 5, :code "HPK", :skippable? true)
(struct order-line 3, "LGV")]
order (struct-map order :lines lines, :rush? true)]
(merge customer
{:orders (conj (get customer :orders []) order)})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def me (add-order (struct customer "seth")))
me
(count (me :orders))
(first (me :orders))
(:lines (first (me :orders)))
(count (:lines (first (me :orders))))
(def new-me (add-order me))
(count (me :orders))
(first (me :orders))
(:lines (first (me :orders)))
(count (:lines (first (me :orders))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;



November 24th, 2009 - 10:46
FYI, you really should not be rebinding new references to data in top level vars (which is what def does). That’s fine for playing at the REPL, but such global state is best reserved for functions, etc.
Further, you don’t need to use structs — regular maps will do, unless you have a good reason to use structs’ specific talents. e.g.:
(update-in customer [:orders] conj {:rush? true :lines [{:qty 6 :code "TAL"} {:qty 5 :code "HPK", :skippable? true} {:qty 3 :code "LGV"}]})
There’s no way that formatting is going to come through!
November 24th, 2009 - 10:54
Hmm, redefining ‘me’ doesn’t seem like a good idea to me either. Why not use STM to safely modify the value?
Unfortunately, I actually find the Java code more readable in this example as well
November 24th, 2009 - 11:44
When you are first getting using to working with immutable data structures it does seem counter-intuitive at first, but once you learn to think differently about it, it makes sense. Here’s the key difference between Clojure and Java.
In your Java example, you have a reference (the customer variable) to a mutable object (an instance of Customer). You call methods on that object to modify the state of the object. If you have multiple references to that instance of Customer, all those references see those changes. They may be what you want, but often times, especially in the context of a multithreaded application where multiple threads are trying to update the Customer, that could be a big problem.
In Clojure, instead of modifying the Customer instance that your reference customer points to, you create a new “Customer instance” and modify the reference to point at the new “Customer instance”. I put that in quotes because you will use a Map to store the Customer data, not an object, but either way, it’s the data structure that holds the customer data. This still has the same effect of modifying the Customer and does it in a way where multiple threads could do it concurrently without a problem.
I’ve modified your example to use a ref and you can see that calling add-order twice results in adding two orders to the customer:
http://gist.github.com/242000
November 25th, 2009 - 06:16
Wow, this was amazingly helpful. Thank you so much! I was surprised that so little code needed to change. This will be very helpful when I start digging into the concurrency parts of Clojure.
November 30th, 2009 - 18:33
Sorry not to have responded in more detail after my earlier tweet–the iPhone is not the best for long-winded replies. It’s great to post about your experiences when learning Clojure, and I think you will find the community, such as the previous commenters here, to be helpful resources.
As written, and probably unintentionally, your original post suggests that immutability leads to silent runtime failures, while mutation is easy to follow and understand. This is exactly backwards, and is critical to understanding Clojure.
I hope you will continue to post your experiences, and make the tentative, learning nature of these posts clear in the body of the text as you go. Also, if you haven’t already, give the IRC a try, there is almost always someone there to talk to.
December 1st, 2009 - 08:37
Yes, it was unintentional to suggest that immutability leads to silent runtime failures. The intent was to a.) demonstrate one subtle reason it’s bad to think in Java while coding in Clojure (you’re not changing the object you think you’re changing), and b.) provide a very small band-aid to patch up the problem (rebind the output). I wish I had thought how rotten a band-aid that suggestion was… but I was rushing to meet a self-imposed deadline.
Did the actual output end up anywhere near where I wanted it? Sadly no: it was worse than silence. So that’s a good lesson for me to keep in mind as I continue to blog.
I appreciate the detailed feedback!