5 features of Clojure let

From OO to Clojure Workshop!
Watch my free workshop to help you learn Clojure faster and shift your paradigm to functional.
Clojure let
is used to define new variables in a local
scope. These local variables give names to values. In
Clojure, they cannot be re-assigned, so we call them
immutable.
Here are a few things you probably know about let
, and a
few you don't.
Variable shadowing
In a Clojure let
, You can name your variables however you
want—even reusing the name of an existing variable. Of
course, when you do that, your code can only refer to the
most local definition. There is no way to access the outer
one. Doing this is called shadowing. It's like the outer
variable is in the shadow of the local variable.
Example:
(def db "postgres://localhost:3456")
(defn fetch-users []
(let [db "postgres://my-server.com:2232"] ;; this variable shadows the global
(jdbc/query db "SELECT * FROM users"))) ;; refers to local db
Bonus:
You can even shadow variables within the same let
:
(let [s "Eric Normand"
s (str/upper-case s)
s (str/trim s)
s (str/replace s #" +" "-")]
(println s))
We are defining and redefining s
, which shadows the one
just above it. Notice, we are also referring to variables
above, which is the next topic.
Refer to variables defined above
Clojure let
allows you to define multiple variables. A
common question is whether they can refer to each other. So
can they?
The variables you define in a let
can refer to each other,
but only in the order they are defined in. This one should
work:
(let [x 1
y (* 2 x) ;; refer to x above
z (+ 4 y)] ;; refer to y above
(println z))
That's cool. But this one doesn't work:
;; this doesn't work
(let [person {:first-name first-name ; defined below
:last-name last-name} ; defined below
first-name "Eric"
last-name "Normand]
(prn person))
Why not? What's the difference? Well, you can only refer to
variables after they've been defined. let
s define
variables in order, so you can only refer "up", not "down".
Even with that limitation, Clojure let
's ability to refer
to variables you just defined is very useful, especially for
multi-step actions.
Destructuring
The vector (square brackets []
) just after the let
in a
let
expression is called the bindings. In Clojure,
anywhere you have bindings, you get to use
destructuring. Destructuring is a convenient way of
defining multiple variables with values from a data
structure. It's much shorter than doing it all by hand.
Destructuring is a bigger topic that I can't go into in detail here. There's enough to learn about it that I made a short video course called Destructuring. Do check it out because it can seriously make your code cleaner and shorter.
Here is an example of how much shorter destructuring can really be:
(let [match (re-matches #"ab(c+)" "abccc")
s (get match 0)
g1 (get match 1)]
(println "string:" s "cs:" g1))
Now, with destructuring:
(let [[s g1] (re-matches #"ab(c+)" "abccc")]
(println "string:" s "cs:" g1))
We're able to pull out two values from within the vector on
a single line of let
binding, as opposed to three lines
without destructuring.
Multiple expressions in the body
Clojure let
expressions can have multiple expressions
after the binding. Those expressions are called the body
of the let
. The body expressions are executed in
order. The value of the let
is the value of the last
expression in the body.
Using the value of the last expression is useful because it lets you do related actions just before. Take this example of logging before a request goes out.
(let [url "https://purelyfunctional.tv"
data {:session "12345"}]
(log/info "Posting data to api" data)
(http/post url data))
All of the expressions in the body can refer to variables
defined in the binding form. The let
is like a neat
package of variables and expressions.
Local variables disappear after the closing parenthesis
Once you close the paren after the last expression in the
body, those variables you defined in your let
do not exist
anymore. You cannot refer to them. In fact, anything you
shadowed is now unshadowed. You can get at the pre-shadowed
values.
Here's an example
(def my-name "Eric Normand")
(println my-name) ; refers to global `my-name`
(defn print-person-name [person]
(let [my-name (:name person)] ; shadows the global
(println my-name)) ; prints whatever name is in the person
(println my-name)) ; prints the global!
Again, this is really nice because it makes the let
a nice
little package. Shadowing is not the same as re-assigning.
Bonus: let
println
debugging trick
Sometimes you need to do a little println
debugging. Let's
say your code is failing somewhere inside of a let
binding. Here's your code:
(let [user (get-user user-id)
account (get-account user) ; this line fails
transaction (make-deposit account 100)]
(save-transaction transaction))
When you run it, you get an error on the line marked
above. get-user
works, but get-account
is failing. But
what is being passed to get-account
? Maybe it's wrong.
You try to add a println
to inspect the value, but that
doesn't work:
(let [user (get-user user-id)
(println user) ; doesn't compile
account (get-account user) ; this line fails
transaction (make-deposit account 100)]
(save-transaction transaction))
The compiler complains that you need an even number of forms in the binding, which makes sense. Every binding needs a variable and a value.
So how do you fix it? There's a trick. Just give it a
throwaway variable name. The most common one to use is _
(underscore).
(let [user (get-user user-id)
_ (println user) ; does compile
account (get-account user) ; this line fails
transaction (make-deposit account 100)]
(save-transaction transaction))
In fact, because you can reuse variable names, you can do the same trick all over.
(let [user (get-user user-id)
_ (println user) ; does compile
account (get-account user) ; this line fails
_ (println account)
transaction (make-deposit account 100)
_ (println transaction)]
(save-transaction transaction))
Don't forget to remove those before you go to production. You don't want to be printing everywhere.
I made a video lesson about this and other tricks for doing
println
debugging
in the Repl-Driven Development in
Clojure
course.
Scope is such an important topic, which is why I made a short video course called Clojure Scope all about it. In that course, we go over global, let, and dynamic scope.