How I Write Generics
By Artyom Bologov
Generic functions are beautiful as an idea. But they are also somewhat ugly as a syntax.
My (ab)use of generics led to a certain programming style. This style, I conjecture, ends up in more readable and correct functions. Although it's up to you whether to believe me.
This post will be structured around what generics are. This overview will explain intuition behind my style of writing generics. With examples and reasoning, of course. In the end, I'll share a small macro that nicely abstracts away some of my conventions.
Don't expect some eye-opening experience, it's mostly about formatting and ordering of generic options. Still, helps in setting up style guides.
Structure of Generics
Generics are
- functions
- with multiple methods,
- type dispatch,
- and metadata (like documentation and method combinations.)
Let's go through these one by one.
Generics Are Ugly Functions
That's the reason a lot of people prefer
defmethod
over defgeneric
: Generics are ugly.
They require a lot of keywords and don't even look like functions.
Even though they are ones.
So my style aims to bring generics as close to regular functions as possible.
Which means putting documentation and declarations first, and methods last
(taken from
CL-BLC):
(defgeneric compile (expr &optional stack)
(:documentation "Compile Lispy EXPR into binary lambdas...")
(:method ((expr list) &optional stack)
#|...|#))
Why not just use defmethod
?
Because defining a generic via defmethod
doesn't attach source location info.
So xref
/edit-definition
/swank:find-source-location
on generics often gets confused by this.
Define your generics with defgeneric
.
And enjoy the increased transparency it brings!
A note on arglists: it's useful listing all the keyword/optional arguments in the generic definition. Instead of splitting these into separate methods. This way, even the dumbest tooling (and most inattentive reader) knows what keywords/optionals to expect. Yes, SLIME/SLY infers the arglist somehow. But there's no such a thing as too much caring for the user.
Generics Have Methods
That's the main feature of generics: they have methods. Generics dispatch over argument types to call most relevant method(s).
While most methods are born equal, some have a generic-related role. Like termination/dispatch/default ones. These usually represent the empty state or some stub for the actual implementation to replace.
One can put all the methods as :method
options in generic.
One can also define them all as separate defmethod
-s.
The former keeps things together in one place.
The latter gives methods more metadata and pins them to certain source locations.
My preference is to define termination/default methods in generics.
And other methods on their own.
This way, one can immediately see the default behavior of the generic.
And only after that can they get to the actual implementation/extension with defmethod
-s.
Here's a bit of (old and somewhat orthogonal to the style I'm suggesting, but nonetheless exemplary in default method case)
code from
NJSON:
(defgeneric decode-from-stream (stream)
(:method (stream)
(declare (ignore stream))
(signal 'decode-from-stream-not-implemented))
(:documentation "Decode JSON from STREAM..."))
I also tend to write all the other methods in terms of transforming data to a proper format and calling next/default method on it. This way, most of the actual logic is concentrated in one method. While other methods provide nice wrappers and validators for it.
Generics Dispatch Over Types
This is somewhat tautologic with methods, but hear me out:
Generics dispatch over almost all built-in and user-defined types.
Including the eql
types.
And this type-exact nature makes them extremely composable.
One can encode a lot in argument lists.
It's easy enough to re-implement
documentation
-like control args with eql
specializers:
(defmethod coerce ((term list) (type (eql 'list)) &optional inner-type final-type)
#|...|#)
(defmethod coerce ((term list) (type (eql t)) &optional inner-type final-type)
#|...|#)
One can also dispatch over type hierarchies, like list
/cons
/null
.
This way, typed edge cases are all covered without extra branching in the actual methods.
So yes, I abuse this power of specializers for common good.
And you should too!
But method dispatch is expensive!
No.
One can use something like (declare (optimize speed))
in generic declaration.
This should get one 90% there.
And the rest is up to implementation magic
(which is pretty good even by default, at least on SBCL) to figure out.
Don't over-optimize, focus on designing a clear contract in your generics!
Meta(data): combinations and classes
Finally, a bit of cryptic stuff: method combinations and generic classes.
You'll be surprized with how mendable the whole system is.
For example, here's a simple method combination
Trivial Inspect
uses to reorder and index returned properties:
(defun reverse-append-index (&rest lists)
(let ((fields (remove-duplicates (reduce #'append (nreverse lists))
:key #'first
:from-end t)))
(mapcar #'cons (field-indices fields)
fields)))
(define-method-combination reverse-append-index
:identity-with-one-argument t)
(defgeneric fields (object &key &allow-other-keys)
(:method-combination reverse-append-index)
(:documentation "Return a list of OBJECT fields to inspect..."))
The logic is:
- Get the list of properties returned by other methods.
- Reverse the list so that most specific methods come last (and thus the default-ish/universal properties come at the top)
- And then index it with nice and meaningful numbers.
I might've used an
:around
method to sort things.
But you may not need that around method.
And I prefer to not touch auxiliary methods.
Downstream user might want to use :around
to override generic behavior.
So custom method combination it is!
Generic function and method classes are even more obscure.
I didn't encounter a use-case for them yet
(except for command
class in Nyxt.)
But once there's such a case, only these classes can save the situation, so better be prepared!
I tend to put this meta-data (pun intended) before or after the docstring, so that it resembles regular functions. Functions allow declarations after docstring, after all. But you can see (in the code listing above) that I'm somewhat inconsistent in that. Still, put metadata/docs at the top and implementation at the bottom—will be clearer this way!
A New Syntax, Maybe?
But hey, it's much harder to write out generics the way I'm suggesting, even if it's actually useful!
Luckily, we have macros in CL.
One can define a macro to shorten generics and typical use patterns like mine.
define-generic from Nclasses does that
(Do check out Nclasses version, it clocks at 100 lines of useful macro code.
I've gone to great length to make it work nicely!)
Or use this simplistic macro from my
Graven Image project:
(defmacro define-generic (name (&rest method-args) &body (documentation . body))
(check-type documentation string "Documentation string")
`(let ((generic (defgeneric ,name (,@(mapcar #'first (mapcar #'uiop:ensure-list method-args)))
(:method (,@method-args)
,@body)
(:documentation ,documentation))))
(setf (documentation (fdefinition ',name) t) ,documentation)
(ignore-errors
(setf (documentation ',name 'function) ,documentation))
generic))
Used as
(define-generic (setf function-lambda-expression*) ((value list) function &optional force)
"Setter for `function-lambda-expression*'."
(declare (ignorable force))
(let ((name (if (symbolp function)
function
(function-name* function))))
(function-lambda-expression* (compile name value))))
Lots of niceties there:
- Mandatory docstring.
- Method lambda list with type specializers in place of generic arglist to not repeat myself.
- Default method as the only method in body.
- No need for
:documentation
or:method
keywords—they are implied.
Which all compounds to a good and readable generic definition.
It also looks almost like defmethod
without sacrificing any benefits of generics.
It's not some generic-writing Swiss knife, but it gets one 90% there.
Leaving the complex cases to Nclasses or whatever else one prefers.
Wrapping Up: Write Generics Like I Do
Just kidding!
I'm not forcing you to any type of style.
You might even ignore generics altogether.
But, hopefully, this post has shown you some useful techniques of working with generics.
Here's a full reference for how I suggest writing generics.
(defgeneric generic-name (arg1 arg2 &optional opt)
(:documentation "Generic function docs
See https://nyxt.atlas.engineer/article/lisp-documentation-patterns.org for additional context")
(:generic-function-class meta-data-meta-class)
(:method-class meta-data-method-class)
(:method-combination meta-combination)
(declare (optimize (speed 3)))
(:method ((arg1 list) (arg2 (eql 't)) &optional (opt 8))
"Simple default/termination method."
t))
(defmethod generic-name ((arg1 cons) (arg2 null) &optional opt)
"Implementation method documentation.
Not strictly necessary, but nice for extra implementation details."
#|...|#)
But this full form is unlikely to ever get useful, because you usually don't need that much meta.
Here's a more realistic reference:
(defgeneric generic-name (arg1 arg2 &optional opt)
(:documentation "Generic function docs
See https://nyxt.atlas.engineer/article/lisp-documentation-patterns.org")
(:method ((arg1 list) (arg2 (eql 't)) &optional (opt 8))
"Simple default/termination method."
t))
(defmethod generic-name ((arg1 4) arg2 &optional opt)
"A less simple method."
(generic-name (list arg1) t opt))
I hope that I also motivated you to try more of generics in your next project. And maybe even use them as an architectural device! Thanks for following me up until here!