5 features of Clojure let

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. lets 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.