Clojure, like many Lisps, sometimes struggles to attract newcomers who claim it's "hard to read". Any paradigm shift requires time, but I myself struggled to read Clojure I had written early on. Nested parentheses and REPL-driven development made the result come quickly, but it often looked ugly. However, the thread operator
-> and all of its cousins fix that.
A common pattern I found myself reapeating was a series of simple, composable function calls on a single value. After all, small useful functions is a big draw of Clojure. But many small functions next to one another often results in ugly and unclear code.
For example, in many ciphers, strings first need to be converted to lowercase, then stripped of the whitespace characters. The quick way to do that in Clojure is
(require '[clojure.string :as :str]) (defn preprocess-string [s] (str/replace (str/lower-case s) #"\s+" ""))
This isn't too hard to read, but it gets more difficult as functions are added. If it's determined that somehow the cipher is made stronger by reversing the string, this becomes
(defn preprocess-string [s] (str/reverse (str/replace (str/lower-case s) #"\s+" "")))
Already this is getting a bit unwieldy. The thread operator simplifies this to:
(defn preprocess-string [s] (-> s (str/lower-case) (str/replace #"\s+" "") (str/reverse)))
The thread operator here inserts
s as the argument to
str/lower-case, then inserts that entire form,
(str/lower-case s), as the first argument in
str/replace, and so on. As a result, it's functionally equivalent, but now any humans reading it can see clearly that you would take the string, first lower-case it, then replace the whitespace, then reverse.
But, when we start working with collections, we see that we need something new.
-> threads things as the first argument to a function, while most functions that deal with collections take the collection last. Here, we want to use the
->> operator, also known as the thread-last operator.
Again, using our cipher example, let's say we have a function that takes a string, filters all characters removing whitespace, converts a character to an integer, and applies an encoding function. In the inside-out style, this would be
(defn process-string [s] (map encode (map int (filter str/blank? s))))
However, with the
->> operator, we can simplify this to
(defn process-string [s] (->> s (filter str/blank?) (map int) (map encode)))
These two operators alone will simplify and clarify a lot of Clojure functions, but there are a few more obscure threading operators that can be very useful.
some->. The some threading macro can be thought of as a short-circuit, or nil-safe threading. With
some->, whenever the result of one line is nil, the expression immediately returns nil.
For example, code that would imperatively be written as
(defn maybe-nil-steps [n] (when n (when-let [n1 (maybe-nil-1 n)] (when-let [n2 (maybe-nil-2 n)] (when-let [n3 (maybe-nil-3 n)] n3)))))
can instead be refactored to be
(defn maybe-nil-steps [n] (some-> n (maybe-nil-1) (maybe-nil-2) (maybe-nil-3)))
Next on the obscure threading macros is
cond->. The conditional threading macro works much like
cond, but with the addition of threading. It takes an even number of forms, and for each pair, if the first is true, execute the second according to usual threading rules.
For example, if you have a series of functions that only need to be executed under certain circumstances, instead of
(defn if-run-1 [n] (if cond-1 (step-1 n) n)) (defn if-run-2 [n] (if cond-2 (step-2 n) n)) (defn if-run-3 [n] (if cond-3 (step-3 n) n)) (defn process-function [n] (-> n (if-run-1) (if-run-2) (if-run-3)))
we can instead write this as
(defn process-function [n] (cond-> n cond-1 (step-1) cond-2 (step-2) cond-3 (step-3)))
I personally like using
cond-> as a way to conditionally associate elements in a map. When building up a body for a request, a common pattern might be
(defn build-request [initial-map] (-> initial-map (assoc :for-sure-key-1 (get-key-1)) (assoc :for-sure-key-2 (get-key-2)) (cond-> should-add-key3? (assoc :for-sure-key-3 (get-key-3)))))
It is worth noting that both
some-> have thread-last versions as well,
The last threading macro is
as->, which only comes in one type.
as-> is useful if you want to mix thread first and thread last macros.
For example, let's say we don't know about the ability to filter, as we did in the
->> example. That might lead us to write the function as
(defn process-string [s] (-> s (str/replace #"\s+" "") (->> (map int) (map encode)))
It would not take much to make this even more complex. Instead, we can use
as->, which instead of passing things as the first or last argument, assigns the previous form to a symbol for use in future expressions. For example, the previous function can be rewritten as
(defn process-string [s] (as-> s x (str/replace x #"\s+" "") (map int x) (map encode x)))
Now, you're fully equipped to simplify your code with threading macros. It's worth noting that I left out one big detail for stylistic reasons, namely that parenthesis are optional when referring to a single function. I prefer always adding them, as it results in the expressions lining up.
As with any technique, you can overuse this and make your code just as unreadable through long and complicated threading macros. But when used judiciously, they enhance readability.
Kevin is a Senior Software Engineer, and the primary math nerd. After brief stints in physics and academic math, he realized the creation of things was just as important as the theory, and became a full time programmer. Possessing a hodge podge of tools for any job, he particularly enjoys Clojure, Elixir, Kotlin, and pretty much every data store in existence.
If you ever want to math out, Kevin would love to talk about cryptography, algebra, graph theory, or number theory. In his spare time, his preferences and hobbies tend to change with the moon, but generally board games, RPGs, and some form of creation lie at the heart of them all.