0ad7006a154b

if-let post
[view raw] [browse files]
author Steve Losh <steve@stevelosh.com>
date Sun, 08 Jul 2018 20:11:41 +0000
parents 08265e9dcdd3
children 3afa83528189
branches/tags (none)
files content/blog/2018/07/fun-with-macros-if-let.markdown

Changes

--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/content/blog/2018/07/fun-with-macros-if-let.markdown	Sun Jul 08 20:11:41 2018 +0000
@@ -0,0 +1,607 @@
++++
+title = "Fun with Macros: If-Let and When-Let"
+snip = "Part 2 in a series of short posts about fun Common Lisp Macros."
+date = 2018-07-09T16:05:00Z
+draft = false
+
++++
+
+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).
+
+<div id="toc"></div>
+
+## Introduction
+
+I first heard of the [`when-let`][clojure-when-let] and
+[`if-let`][clojure-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:
+
+[clojure-if-let]: https://clojuredocs.org/clojure.core/if-let
+[clojure-when-let]: https://clojuredocs.org/clojure.core/when-let
+
+```lisp
+(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:
+
+```lisp
+(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:
+
+```lisp
+(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 `if`s 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`:
+
+```lisp
+(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`:
+
+```lisp
+(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:
+
+```lisp
+(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:
+
+```lisp
+(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:
+
+
+```lisp
+(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 `let`s and `if`s 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:
+
+```lisp
+(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:
+
+```lisp
+(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:
+
+```lisp
+(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 `let`s and
+`if`s, 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:
+
+```lisp
+(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:
+
+```lisp
+(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:
+
+
+```lisp
+(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 `let`s and
+`if`s 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:
+
+```lisp
+(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.
+
+```lisp
+(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.