Programmers are not to be measured by their ingenuity and their logic but by the completeness of their case analysis.Alan Perlis
Let’s start with a simplified definition for
type= from Part I). This looks very similar to
(defmacro etypecase-of (type expr &body clauses) (let ((actual-type `(or ,@(mapcar #'first clauses)))) (unless (type= type actual-type) (warn "Type mismatch: ~a vs. ~a" type actual-type)) `(etypecase ,expr ,@clauses)))
ecase-of is, conceptually, a special case of
etypecase-of. You could define
ecase-of in terms of
(defmacro ecase-of (expr type &body clauses) `(etypecase-of ,expr ,type ,@(loop for (key . body) in clauses collect `((eql ,key) ,@body))))
Although this would probably compile to less efficient code.
How can you use this? Here’s a simple example (from Overlord). We use
deftype to define a type as a disjunction:
(deftype target-timestamp () "Possible formats for the timestamp of a target." '(or timestamp universal-time never far-future))
This defines a target timestamp as always being one of:
- A local-time timestamp.
- A universal time.
- The unit type (singleton)
- The unit type
It then becomes possible to define functions for handling timestamps that are statically checked to make sure they handle all the possible representations:
(defun round-down-to-nearest-second (ts) (etypecase-of target-timestamp ts ((or never far-future universal-time) ts) (timestamp (adjust-timestamp ts (set :nsec 0)))))
(Timestamps will be revisited when we discuss
This approach is useful any time you have multiple representations of the same thing.
There is a pattern in Common Lisp where functions on a type accept generalized “designators” of that type. Functions on packages accept package designators (packages, but also a string or symbol naming the package) and functions on pathnames accept both pathnames and strings. If you explicitly define your own designator types, you can use
etypecase-of to make sure they are handled completely:
(deftype key-designator () '(or null function-name function)) (defun canonicalize-key (k) (etypecase-of key-designator k (null #'identity) (function-name (fdefinition k)) (function k)))
One situation where
etypecase-of is uniquely useful is in data structures where memory usage is a concern. Say you have a data structure that uses different kinds of node. Rather than defining class hierarchies or ADTs, you can choose the most compact representation possible for each kind of node, like a symbol or cons cell, and rely on
etypecase-of to make sure you handle every representation.
One property of Common Lisp’s type system that is not always obvious to people new to Common Lisp is that types are not nullable unless specified as such. There is such a value as
nil, but it has its own type (
null) that is not a subtype of any other type except
t (top). (But watch out: there is also a type named
nil; this is Common Lisp’s bottom type. There is no value of type
nil, not even
etypecase-of can be used when explicitly making types nullable. This is similar in practice to the uses of Option or Maybe in MLs.
(etypecase-of (or null string) s ...)
This particular example is unidiomatic; since
nil is false, you might as well use if. But not every situation where a type is semantically nullable actually uses
Postmodern. In Postmodern
nil is reserved for the empty list, while SQL NULL is represented by the keyword
:null. (Similar rules are used by JSON parsers.)
Start by defining a type that represents a value that could be NULL:
(deftype db-value (type) `(and atom (or ,type pomo:db-null)))
You can then use that type to make sure you remember to handle
NULL in database queries:
(etypecase-of (db-value timestamp) next-update (timestamp next-update) (db-null nil))
Or even to make sure you remember to handle both
NULL and Lisp
(etypecase-of (or null (db-value string)) f (string (if (equal f "html") :html :xml)) ((or null db-null) :xml))
Common Lisp provides a “numeric tower” of number types with implicit rules for conversion and contagion. Sometimes, however, you need to be explicit about handling certain kinds of numbers. You can use
etypecase-of to make sure you don’t forget anything:
(etypecase-of (or real complex) z ((or zero (and negative integer)) (error 'undefined-value :operation 'log-gamma :operands (list z))) (single-float (lgammaf z)) (double-float (lgamma z)) (real (log-gamma (coerce z 'double-float))) ;; For complex values, use the Lanczos approximation. (complex (lgammaz z)))
It is straightforward to define a tuple type for heterogeneous lists of fixed length.
(deftype tuple (&rest types) (reduce (lambda (type1 type2) `(cons ,type1 ,type2)) types :initial-value nil :from-end t))
You can then describe heterogenous lists of fixed length to the type system:
(deftype var-alias () 'bindable-symbol) (deftype function-alias () '(tuple 'function bindable-symbol)) (deftype macro-alias () '(tuple 'macro-function bindable-symbol)) (deftype import-alias () '(or var-alias function-alias macro-alias))
You can then use these for dispatch:
(make-keyword (etypecase-of import-alias alias (var-alias alias) (function-alias (second alias)) (macro-alias (second alias))))
I would not generally recommend this approach for runtime code (use structs) but it can be useful when taming old-fashioned code that uses lists for everything, or at compile time for handling fragments of macros and macroexpansions. (The actual definition of
tuple in Serapeum is mostly designed for the macro fragment use case, so it has special handling for keywords and quoted symbols.)
Common Lisp has a parametric type,
(satisfies X), where X is the name of a predicate; anything that satisfies the predicate is considered to satisfy the type. This can be used by itself, but is most often used in conjunction with another type.
In Overlord there are
deftypes for such types as
(deftype absolute-pathname () '(and pathname (satisfies absolute-pathname-p))) (deftype relative-pathname () '(and pathname (satisfies relative-pathname-p))) (deftype directory-pathname () '(and pathname (satisfies directory-pathname-p)))
These types can then be used with
etypecase-of to make sure that pathnames are handled correctly:
(etypecase-of (or string directory-pathname relative-pathname) name (relative-pathname (directory-exists (ensure-absolute name))) (string (directory-exists (ensure-pathname name :want-pathname t))) (directory-pathname (make 'directory-exists :path name)))
This approach is useful when you need to make distinctions within a type – whether because the type is provided to you and you cannot subclass it, or because the distinctions are ones the type system cannot represent.
An afterthought about
etypecase-of. It can be useful even if you plan to ultimately make your representations extensible. I sometimes use it as scaffolding but remove it later. That is, I might use it during development to pin down part of the design, but once the code has stabilized, I might replace it with, say,
defgeneric or pattern matching.