My Favorite Parts of Clojure
I started writing code in Clojure around 2013.
I first took an interest in it because I'd been interested in Lisps ever since completing the "Structure and Interpretation of Computer Programs" on Open Courseware - the early 2010s were when I realized that you could get a fantastic education in computer science without paying for it.
What I love about Clojure now is about the same as what I loved about Clojure then:
Immutable Data Structures
Functional programming is all about programming as the application of functions rather than the modification of state. Functional code is "referentially transparent," which means that you can replace a function call with the output of that function call with no change in behavior.
As an example, the function *
is referentially transparent because we can replace (* 2 2)
with 4
.
A function like sql/insert!
, however, is not referentially transparent. It might return 1
, indicating that we've added on row to the database. But if you replace your call to sql/insert!
with 1
, your program will behave differently!
(Another facet of referential transparency, implied by the definition, is that the function must return the same output given the same input every time it's called. If you replace all your calls to (time/now)
with 2023-06-01T05:31:31
, your program will behave differently than it did before!)
A common issue with functional programming is that a lot of the data structures we know and love, like arrays, vectors, and maps, don't necessarily play nicely with referential transparency. Most data structures are:
- difficult to copy (for example, many programming language have the concept of a "deep copy" of a map, distinct from and more expensive than a "shallow copy", which is still more expensive than just using a reference to the map)
- easy to modify in place, e.g.
coll << 'a'
orcoll.append([1, 2, 3])
The most commonly used data structures in Clojure are the opposite. For example, a map ({:a "to-be"}
) is:
- impossible to modify in place! It's immutable! You can pass your map to any function you want - nothing can change it.
- trivial to copy - all the classic things that would "change" a data structure in the land of imperative programming (adding an element to a list, changing the nth element of a vector, adding or removing a value from a map) just return new versions of the immutable data structure you passed in.
You can read Purely Functional Data Structures, by Chris Okasaki, for a dive into possible implementations of data structures with these properties.
Immutability is one of my favorite distinguishing features of Clojure, because it makes programming much easier.
(I've always thought it's funny that "functional programming" is seen among programmers as this advanced and slightly arcane technology. Personally, I feel like I'm too dumb to be good at imperative programming. An old code base with spooky action at a distance happening everywhere? Where I don't know whether some function I call will change its argument? That's scary, and the iteration cycle is going to be slow in that environment. I'm not smart enough to be good at that - functional programming is a way of simplifying the whole business.)
Concurrent Programming
Another one of my favorite bits of Clojure is its support for concurrent programming.
There are four different tools for this: atoms, dynamic vars, refs, and agents. Atoms and dynamic vars are by far the most common in my experience. I'll just briefly talk about each one.
Atoms
Atoms are cross-thread bits of shared state. You can atomically (get it?) update them, read them, or reset them. You can do this across many threads, and every modification of the atom will be free of race conditions.
(def state (atom 0))
;; these could happen in different threads!
(swap! state inc) ;; state is now 1
(swap! state inc) ;; state is now 2
(swap! state dec) ;; state is now 1
;; "deref" the atom using `@` to read it
@state ;; => 1
Dynamic Vars
Dynamic vars allow for isolated (thread-local) changing of state. By convention, dynamic vars are named with (adorable) "earmuffs", like *foo-bar*
.
(def ^:dynamic *x* 0)
(defn add-x [y] (+ *x* y))
(add-x 5) ;; => 5
(binding [*x* 5]
(add-x 5)) ;; => 10
Note that dynamic vars do not work cross-thread. This can be a bit of a gotcha. For example, some libraries use dynamic vars for settings:
(binding [some-lib/*value* :something]
(lib/do-a-thing))
But if you're say, starting up a web server:
(binding [some-lib/*value* :something]
(start-server! app {:port 8080}))
Then your app's handler, which will run in a different thread, will not see your updated value of some-lib/*value*
.
Standing on the Shoulders of Java Giants
The last big selling point for Clojure is that easy interop with the host language allows you to use the most boring technology around - and one of my favorite lessons of my software engineering career has been that boring technology is awesome.
You might not need to use the underlying libraries directly - there are often nice Clojure wrappers for the Java libraries you'll want to use. But this feature means that even though Clojure is a relatively uncommon language, it's trivial to just bolt on these incredibly battle-tested libraries.
For example:
These are just a few of my favorite things about Clojure. Of course, no language is perfect, and Clojure has its warts too! But that's a topic for another day.