Fun with Macros: If-Let and When-Let

Posted on July 9th, 2018.

I haven't been writing much lately because I've been in the process of switching my life over to Linux from OS X. I finally managed to get my blog infrastructure running again, so let's take a look at some deceptively-tricky Common Lisp macros: when-let and if-let (and their let* counterparts).

  1. Introduction
  2. A First Attempt
  3. Multiple Bindings
  4. Adding Some Stars
  5. Consistency
  6. Declarations
  7. Result

Introduction

I first heard of the when-let and if-let macros when I learned Clojure a few years ago. They're used like let to bind values to variables, but if a value is nil then when-let will return nil without running the body, and if-let will execute its alternative branch.

Let's implement them in Common Lisp. Once we're done writing them we'll use them like this:

(when-let ((name 'steve))
  (format t "Hello, ~A" name))

; => Hello, STEVE

(when-let ((name nil))
  (format t "Hello, ~A" name))

; => Nothing printed, nil returned

(if-let ((name nil))
  (format t "Hello, ~A" name)
  (format t "Hello, unnamed person!"))

; => Hello, unnamed person!

A First Attempt

A simple first attempt at writing when-let might look something like this:

(defmacro when-let (binding &body body)
  "Bind `binding` and execute `body`, short-circuiting on `nil`.

  This macro combines `when` and `let`.  It takes a binding and binds
  it like `let` before executing `body`, but if the binding's value
  evaluates to `nil`, then `nil` is returned.

  Examples:

    (when-let ((a 1))
      (list a))
    ; =>
    (1)

    (when-let ((a nil))
      (list a))
    ; =>
    NIL

  "
  (destructuring-bind ((symbol value)) binding
    `(let ((,symbol ,value))
       (when ,symbol
         ,@body))))

A first attempt at if-let looks similar:

(defmacro if-let (binding then else)
  "Bind `binding` and execute `then` if true, or `else` otherwise.

  This macro combines `if` and `let`.  It takes a binding and binds
  it like `let` before executing `then`, but if the binding's value
  evaluates to `nil` the `else` branch is executed (with no binding
  in effect).

  Examples:

    (if-let ((a 1))
      (list a)
      'nope)
    ; =>
    (1)

    (if-let ((a nil))
      (list a)
      'nope)
    ; =>
    NOPE

  "
  (destructuring-bind ((symbol value)) binding
    `(let ((,symbol ,value))
       (if ,symbol
         ,then
         ,else))))

This is a decent attempt at a first pass, but already there are a couple of things to note.

First: we have documentation, with examples! The docstrings are longer than the macros themselves, and this is fine! I always prefer to err on the side of being more clear than saving space. If you're a little more verbose than necessary some experts might have to flick their scroll wheels, but if you're too terse you can leave someone wallowing in confusion. Experts should know how to skim documentation quickly if they're really experts, so help out the newer people and be clear!

I didn't make the else branch optional like it is in Common Lisp's if because one-armed ifs are a stylistic abomination.

We could have added some check-type statements to make sure the symbol is actually a symbol, but since it's getting compiled to a let the error will be caught there immediately anyway.

Multiple Bindings

Our first attempt works, but only supports a single binding. Clojure's versions of these macros quit here, but we can do better. Let's make our macros support multiple bindings. First we'll upgrade when-let:

(defmacro when-let (bindings &body body)
  "Bind `bindings` and execute `body`, short-circuiting on `nil`.

  This macro combines `when` and `let`.  It takes a list of bindings
  and binds them like `let` before executing `body`, but if any
  binding's value evaluates to `nil`, then `nil` is returned.

  Examples:

    (when-let ((a 1)
               (b 2))
      (list a b))
    ; =>
    (1 2)

    (when-let ((a nil)
               (b 2))
      (list a b))
    ; =>
    NIL

  "
  (let ((symbols (mapcar #'first bindings)))
    `(let ,bindings
       (when (and ,@symbols)
         ,@body))))

And now if-let:

(defmacro if-let (bindings then else)
  "Bind `bindings` and execute `then` if all are true, or `else` otherwise.

  This macro combines `if` and `let`.  It takes a list of bindings and
  binds them like `let` before executing `then`, but if any binding's
  value evaluates to `nil` the `else` branch is executed (with no
  bindings in effect).

  Examples:

    (if-let ((a 1)
             (b 2))
      (list a b)
      'nope)
    ; =>
    (1 2)

    (if-let ((a nil)
             (b 2))
      (list a b)
      'nope)
    ; =>
    NOPE

  "
  (let ((symbols (mapcar #'first bindings)))
    `(let ,bindings
       (if (and ,@symbols)
         ,then
         ,else))))

Note how we've updated the docstrings to be clear about the new behavior: if any binding is nil, the alternate case takes over.

We've also updated the parameter names to be bindings (plural). One thing I love about Common Lisp is that it's a Lisp 2, so you can almost always use nice names for function parameters instead of mangling them to avoid shadowing functions (e.g. (defun filter (function list) ...) instead of (defun filter (fn lst) ...)). Take advantage of this and give your parameters descriptive, pronounceable names. You'll thank yourself every time your editor shows you the arglist.

If you've read other blog posts about implementing these macros, this is where they probably stopped. But let's keep going, there's still much more to dig into!

Adding Some Stars

Now that we've got if-let and when-let, the obvious next step is to add if-let* and when-let*. We could do this by changing the let each macro emits to a let*, but before we rush ahead let's think about how people will use these macros to see if that change would make sense.

The point of using a let* instead of a let is so that later variables can refer back to earlier ones:

(let* ((name (read-string))
       (length (length name)))
  ; ...
  )

The point of using our when- and if- variants is to short-circuit and escape on nil. With the way our macros are currently written, all the variables get bound before they all get checked for nil in the and. This works for the -let variants but isn't ideal for the new -let* variants. If we're using when-let* it would be nice if the later variables could assume the earlier ones are non-nil.

This means we'll want to bail out immediately after the first nil value is detected. This is a little bit trickier than what we've currently got. There are a number of ways we could do it, but I'll save us from hitting a dead-end rabbit hole later and implement when-let* like this:

(defmacro when-let* (bindings &body body)
  "Bind `bindings` serially and execute `body`, short-circuiting on `nil`.

  This macro combines `when` and `let*`.  It takes a list of bindings
  and binds them like `let*` before executing `body`, but if any
  binding's value evaluates to `nil` the process stops and `nil` is
  immediately returned.

  Examples:

    (when-let* ((a (progn (print :a) 1))
                (b (progn (print :b) (1+ a)))
      (list a b))
    ; =>
    :A
    :B
    (1 2)

    (when-let* ((a (progn (print :a) nil))
                (b (progn (print :b) (1+ a))))
      (list a b))
    ; =>
    :A
    NIL

  "
  (alexandria:with-gensyms (block)
    `(block ,block
       (let* ,(loop :for (symbol value) :in bindings
                    :collect `(,symbol (or ,value
                                           (return-from ,block nil))))
         ,@body))))

There are a few things we can talk about here before we move on to if-let*.

First: we've documented the macro. The examples are a little more verbose than the previous ones, but the added side effects explicitly show the short-circuiting evaluation.

This implementation is much less trivial than the ones we've got so far, so let's look at a macroexpansion to see what's happening:

(macroexpand-1
  '(when-let* ((a nil)
               (b (1+ a)))
    (list a b)))
; =>
(BLOCK #:BLOCK563
  (LET* ((A (OR NIL    (RETURN-FROM #:BLOCK563 NIL)))
         (B (OR (1+ A) (RETURN-FROM #:BLOCK563 NIL))))
    (LIST A B)))

The when-let* expands into a block wrapped around a let*. As we're binding each variable it's checking for nil with or. If it ever sees nil it will return from the block immediately to escape. If it never sees nil it will eventually reach the body and return normally.

We could have used a series of nested lets and ifs here, and it would have been easier to read. But the fact that all the variables are bound in a single let* will be important later, so you're just going to have to trust me for now.

We've named the block with a gensym to avoid clobbering any nil block the user might already have set up. I explicitly specified the nil return value in the return-from, but this isn't required because it's optional.

if-let* is a more difficult, because we need to make sure the appropriate branch gets evaluated:

(defmacro if-let* (bindings then else)
  "Bind `bindings` serially and execute `then` if all are true, or `else` otherwise.

  This macro combines `if` and `let*`.  It takes a list of bindings and
  binds them like `let*` before executing `then`, but if any binding's
  value evaluates to `nil` the process stops and the `else` branch is
  immediately executed (with no bindings in effect).

  Examples:

    (if-let* ((a (progn (print :a) 1))
              (b (progn (print :b) (1+ a)))
      (list a b)
      'nope)
    ; =>
    :A
    :B
    (1 2)

    (if-let* ((a (progn (print :a) nil))
              (b (progn (print :b) (1+ a))))
      (list a b)
      'nope)
    ; =>
    :A
    NOPE

  "
  (alexandria:with-gensyms (outer inner)
    `(block ,outer
       (block ,inner
         (let* ,(loop :for (symbol value) :in bindings
                      :collect `(,symbol (or ,value
                                             (return-from ,inner nil))))
           (return-from ,outer ,then)))
       ,else)))

This is a little hairy, so let's break down what's happening. An if-let* will macroexpand into something like:

(block outer
  (block inner
    (let* (...bindings...)
      (return-from outer then)))
  else)

We set up a pair of blocks and begin binding the variables. If all the bindings succeed we return the then branch from the outermost block (and yes, before you go check: return-from works fine with multiple values).

If any of the bindings fail we return from the inner block immediately. This skips all the remaining bindings plus the then and continues along to the else, which executes and returns normally.

A full macroexpansion ends up looking like this:

(macroexpand-1
  '(if-let* ((a nil)
             (b (1+ a)))
     (list a b)
     'nope))
; =>
(BLOCK #:OUTER568
  (BLOCK #:INNER569
    (LET* ((A (OR NIL    (RETURN-FROM #:INNER569)))
           (B (OR (1+ A) (RETURN-FROM #:INNER569))))
      (RETURN-FROM #:OUTER568 (LIST A B))))
  'NOPE)

It wouldn't be ideal to implement if-let* as a nested series of lets and ifs, because you'd need to duplicate the else code at each level. The nested pair of blocks might be a little harder to understand at first, but they only include the else in a single place (and will be important for another reason soon).

Consistency

Now that we've got when-let* and if-let* short-circuiting on each binding, it probably makes sense to change when-let and if-let to behave the same way, instead of checking after all the variables are bound. Although the later variables don't rely on the earlier ones for these variants, it would be good if the behavior were consistent.

To do this we can take our -let* versions and change the let* inside to a let, update the documentation, and that's it:

(defmacro when-let (bindings &body body)
  "Bind `bindings` in parallel and execute `body`, short-circuiting on `nil`.

  This macro combines `when` and `let`.  It takes a list of bindings and
  binds them like `let` before executing `body`, but if any binding's value
  evaluates to `nil` the process stops and `nil` is immediately returned.

  Examples:

    (when-let ((a (progn (print :a) 1))
               (b (progn (print :b) 2))
      (list a b))
    ; =>
    :A
    :B
    (1 2)

    (when-let ((a (progn (print :a) nil))
               (b (progn (print :b) 2)))
      (list a b))
    ; =>
    :A
    NIL

  "
  (alexandria:with-gensyms (block)
    `(block ,block
       (let ,(loop :for (symbol value) :in bindings
                   :collect `(,symbol (or ,value
                                          (return-from ,block nil))))
         ,@body))))

(defmacro if-let (bindings then else)
  "Bind `bindings` in parallel and execute `then` if all are true, or `else` otherwise.

  This macro combines `if` and `let`.  It takes a list of bindings and
  binds them like `let` before executing `then`, but if any binding's value
  evaluates to `nil` the process stops and the `else` branch is immediately
  executed (with no bindings in effect).

  Examples:

    (if-let ((a (progn (print :a) 1))
             (b (progn (print :b) 2))
      (list a b)
      'nope)
    ; =>
    :A
    :B
    (1 2)

    (if-let ((a (progn (print :a) nil))
             (b (progn (print :b) 2)))
      (list a b)
      'nope)
    ; =>
    :A
    NOPE

  "
  (alexandria:with-gensyms (outer inner)
    `(block ,outer
       (block ,inner
         (let ,(loop :for (symbol value) :in bindings
                     :collect `(,symbol (or ,value
                                            (return-from ,inner nil))))
           (return-from ,outer ,then)))
       ,else)))

Declarations

Before we finish, we should make sure we've done things right. Something that's often forgotten when making new control structures with macros is handling declarations properly. When writing a normal let, you can put declarations immediately inside the body, like this:

(let ((foo (some-function))
      (bar (some-other-function)))
  (declare (optimize safety)
           (type integer foo)
           (type string bar))
  (do-something foo bar))

If we think about how our when-let (and the -let* version) macroexpands we'll see that we don't need to do anything — it will work fine the way we've written it:

(macroexpand-1
  '(when-let ((foo (some-function))
              (bar (some-other-function)))
     (declare (optimize safety)
              (type integer foo)
              (type string bar))
     (do-something foo bar)))
; =>
(BLOCK #:BLOCK586
  (LET ((FOO (OR (SOME-FUNCTION) (RETURN-FROM #:BLOCK586 NIL)))
        (BAR (OR (SOME-OTHER-FUNCTION) (RETURN-FROM #:BLOCK586 NIL))))
    (DECLARE (OPTIMIZE SAFETY)
             (TYPE INTEGER FOO)
             (TYPE STRING BAR))
    (DO-SOMETHING FOO BAR)))

This is why I insisted on implementing the macros with a single let binding all the variables. If we tried to do this with a series of nested lets and ifs we'd have to try to parse the declarations and put the appropriate ones for each variable under the corresponding let, and this would be an absolute nightmare (plus you wouldn't even be able to exclude nil from the type, because the if wouldn't happen until after the declaration!).

Unfortunately if-let is going to be some more work. Let's think about an example:

(if-let ((foo (some-function))
         (bar (some-other-function)))
  (declare (optimize safety)
           (type integer foo)
           (type string bar))
  (do-something foo bar)
  (do-something-else))

We're going to want the declarations to only apply to the then branch, because that's the branch that has the variables whose types we might want to declare. If the user wants some declarations in the else branch they can wrap that branch in a locally and add them there.

We're going to need a way to grab any declarations the user has given out of the body of the if-let. Luckily Alexandria has a function called parse-body that will do this for us.

(defmacro if-let (bindings &body body)
  "Bind `bindings` in parallel and execute `then` if all are true, or `else` otherwise.

  `body` must be of the form `(...optional-declarations... then else)`.

  This macro combines `if` and `let`.  It takes a list of bindings and
  binds them like `let` before executing the `then` branch of `body`, but
  if any binding's value evaluates to `nil` the process stops there and the
  `else` branch is immediately executed (with no bindings in effect).

  If any `optional-declarations` are included they will only be in effect
  for the `then` branch.

  Examples:

    (if-let ((a (progn (print :a) 1))
             (b (progn (print :b) 2)))
      (list a b)
      'nope)
    ; =>
    :A
    :B
    (1 2)

    (if-let ((a (progn (print :a) nil))
             (b (progn (print :b) 2)))
      (list a b)
      'nope)
    ; =>
    :A
    NOPE

  "
  (alexandria:with-gensyms (outer inner)
    (multiple-value-bind (body declarations) (alexandria:parse-body body)
      (destructuring-bind (then else) body
        `(block ,outer
           (block ,inner
             (let ,(loop :for (symbol value) :in bindings
                         :collect `(,symbol (or ,value
                                                (return-from ,inner nil))))
               ,@declarations
               (return-from ,outer ,then)))
           ,else)))))

Whew! We parse the body with parse-body, destructure what's left of it (to make sure we have our two branches), and shove the declarations where they belong.

if-let* is exactly the same, but with a let* in the macro. I'll let you write that one yourself.

Result

We've now got when-let, when-let*, if-let, and if-let* working properly. They all support multiple bindings, short-circuit appropriately, handle declarations correctly, and are documented clearly.