Sure, it’s nice to have exhaustiveness checking when dispatching on the type of a single object. But this is Lisp. What about multiple dispatch?
Lisp already has a syntax for multiple dispatch: generic functions. Could you layer exhaustiveness checking on top of generic functions? Of course. But while many Lisp types have corresponding built-in classes, being restricted to classes would still be limiting. Not all built in types have (portable) classes. And of course no
satisfies types, no shortcuts with
or, or so forth.
You can nest
etypecase-of. But this is much harder to read and produces unhelpful errors and warnings. (What if more than one step has the same type?)
dispatch-case provides is something that resembles
defgeneric, but with exhaustiveness checking, and over any type.
Here is a simple example: using
dispatch-case to compare different representations of timestamps.
(defun timestamp-newer? (ts1 ts2) "Is TS1 greater than TS2?" (dispatch-case ((ts1 target-timestamp) (ts2 target-timestamp)) ((target-timestamp never) t) ((target-timestamp far-future) nil) ((never target-timestamp) nil) ((far-future target-timestamp) t) ((timestamp timestamp) (timestamp> ts1 ts2)) ((timestamp universal-time) (> (timestamp-to-universal ts1) ts2)) ((universal-time universal-time) (> ts1 ts2)) ((universal-time timestamp) (> ts1 (timestamp-to-universal ts2)))))
dispatch-case shines is in complex problems.
One complex piece of code where
dispatch-case was extremely helpful is the implementation of
range in Serapeum.
The syntax of
range is probably familiar from Python.
(range 10) => #(0 1 2 3 4 5 6 7 8 9) (range 2 5) => #(2 3 4) (range 0 10 2) => #(0 2 4 6 8)
range function is not obviously useful in Lisp. Most of the time, you do not want to realize a sequence of numbers: both
Iterate have clauses to generate numbers as needed.
range function is not even obviously useful in Python; Python 2 had
xrange to avoid it, and Python 3
range just returns an iterator.)
But sometimes you do want the actual sequence. Serapeum itself uses
range to generate test data, as a replacement
alexandria:iota, for a huge improvement in performance.
Why is it faster? Calling
range doesn’t return a list of numbers; it returns a vector of numbers. But not just a vector; a vector specialized to the smallest type that can contain the numbers.
(range 256) returns an octet vector.
(type-of (range 256)) => (SIMPLE-ARRAY (UNSIGNED-BYTE 8) (256))
(range 2) returns a bit vector (no, really):
(type-of (range 1)) => (SIMPLE-BIT-VECTOR 1)
Exhaustiveness checking is helpful here because a thorough implementation of
range needs to take great care to do the right thing when the arguments are mixed – mixed rationals and floats, or mixed single and double precision floats, or both. In particular, because of the rules of floating point contagion, no one argument by itself is enough to tell you the output type. And the output type has to be known in advance so a vector of the right kind can be allocated.
range is particularly fast on SBCL, because SBCL (to my pleasant surprise) does a fantastic job of compiling calls to
range with constant arguments: it is able to recognize that if the arguments are constant, so is the element type of the resulting vector, and specializes the calls to