Lookup tables in Common Lisp - alists and plists
Common Lisp provides a variety of data structures for mapping key/value pairs.
Two of the basic lookup tables supported by Common Lisp and implemented in terms of cons cells are the association lists (also known as alists) and property lists (also known as plists). In addition to alists and plists we can also use hash tables.
Using alists and plists are good enough for small number of key/value pairs and can perform better than hash tables in some situations, but in general if you need to work with a large enough lookup table it’s better to stick with hash tables instead.
Using alists and plists with large number of key/value pairs can be slow, mainly because testing for presence of a key is determined by the size of the items stored in them.
An alist is made up of cons cells, where each key/value pair is represented as a dotted pair, unless the value associated with the key is a list.
Here’s an example of an alist with three key/value pairs.
CL-USER> (defparameter *alist* (list (cons 'a 1)
(cons 'b 2)
(cons 'c 3)))
*ALIST*
We can retrieve a key/value pair from an alist using the ASSOC, ASSOC-IF and ASSOC-IF-NOT functions, e.g.
CL-USER> (assoc 'a *alist*)
(A . 1)
If we are interested in the value part of the mapping we just need to
pass the result of ASSOC
to CDR
in order to extract it, e.g.
CL-USER> (cdr (assoc 'a *alist*))
1
Association lists also support reverse querying, which allows us to find the key/value mapping based on the value of the pair. For that we need to use the RASSOC, RASSOC-IF and RASSOC-IF-NOT family of functions, e.g.
CL-USER> (rassoc 1 *alist*)
(A . 1)
Pass the result to CAR
, if you are interested in only the key part
of the mapping, e.g.
CL-USER> (car (rassoc 1 *alist*))
A
Adding new mappings can be done either by PUSHing a new pair or simply CONSing onto it.
CL-USER> (cons '(d . 4) *alist*)
((D . 4) (A . 1) (B . 2) (C . 3))
CL-USER> *alist*
((A . 1) (B . 2) (C . 3))
Note that CONS
ing onto the alist does not modify it, which basically
returns a new list instead. If you need to persist the new mapping
in the alist you can SETF
the original place with the value
returned by above expression.
A convinience function when working with alists is ACONS, which creates a new pair and CONSes it onto the given place.
Previous example would be better written like this instead.
CL-USER> (acons 'd 4 *alist*)
((D . 4) (A . 1) (B . 2) (C . 3))
Using PUSH
on other hand will modify the original alist, e.g.
CL-USER> (push (cons 'D 4) *alist*)
((D . 4) (A . 1) (B . 2) (C . 3))
CL-USER> *alist*
((D . 4) (A . 1) (B . 2) (C . 3))
Note that when using alists where the keys are strings we need to
be aware to use the correct test function such as STRING=
.
Consider the following example.
CL-USER> (assoc "foo" '(("foo" . 1) ("bar" . 2) ("qux" . 3)))
NIL
The result is NIL
because ASSOC
by default will use EQL
as test predicate, and two strings with same values are not
neccessary EQL
. In order to fix above example we would
use this expression instead.
CL-USER> (assoc "foo" '(("foo" . 1) ("bar" . 2) ("qux" . 3)) :test #'string=)
("foo" . 1)
Another useful function for creating alists is the PAIRLIS, which given an even number of sequences creates an alist with the respective keys and values, e.g.
CL-USER> (pairlis '(a b c) '(1 2 3))
((C . 3) (B . 2) (A . 1))
In order to remove a pair from an alist we can use the REMOVE, REMOVE-IF, REMOVE-IF-NOT, DELETE, DELETE-IF, DELETE-IF-NOT family of functions, e.g.
CL-USER> (remove 'a *alist* :key #'car)
((B . 2) (C . 3))
In above example we are specifying that CAR
function should be used
to extract the key from the pairs when searching for the key.
An interesting thing about alists is that when searching for a given
key using ASSOC
or RASSOC
the first matching pair will be
returned, which in some situations might be useful, e.g.
we can shadow a previous pair by having another one
with different value before it.
CL-USER> (assoc 'A '((A . 100) (A . 1) (B . 2) (C . 3)))
(A . 100)
If you are using using CL-JSON for parsing and writing JSON objects the result from parsing a JSON object is an alist. Consider the following example JSON object.
{
"a": 1,
"b": 2,
"c": 3,
"foo": {
"bar": {
"baz": {
"qux": 42
}
}
}
}
We can parse this JSON object using cl-json:decode-from-source
function, e.g.
CL-USER> (ql:quickload :cl-json)
To load "cl-json":
Load 1 ASDF system:
cl-json
; Loading "cl-json"
(:CL-JSON)
Once we’ve loaded the cl-json
system we can parse the object.
CL-USER> (cl-json:decode-json-from-source #P"/path/to/some/file.json")
((:A . 1) (:B . 2) (:C . 3) (:FOO (:BAR (:BAZ (:QUX . 42)))))
The result is an alist. Also note that the JSON object contains nested
hash tables. Suppose that we want to get the value associated with the :QUX
key from above JSON. We can do that using the ASSOC
functions like this.
First, lets save the parsed object so we can refer to it.
CL-USER> (defparameter *json* (cl-json:decode-json-from-source #P"/tmp/test.json"))
*JSON*
One way to extract the :foo :bar :baz :qux
key would be to use ASSOC
along with
CDR
, e.g.
CL-USER> (cdr (assoc :qux
(cdr (assoc :baz
(cdr (assoc :bar
(cdr (assoc :foo *json*))))))))
42
But this looks really ugly to be honest. The arrows library can help a bit here instead.
CL-USER> (ql:quickload :arrows)
To load "arrows":
Load 1 ASDF system:
arrows
; Loading "arrows"
(:ARROWS)
CL-USER> (use-package :arrows)
T
CL-USER> (->> *json*
(assoc :foo)
cdr
(assoc :bar)
cdr
(assoc :baz)
cdr
(assoc :qux)
cdr)
42
But still extracting the value of a nested key does not look as nice as I’d like to. Instead, we can write our own function to retrieve the value of a nested key in an alist. Here’s one possible implementation.
(defun assoc-path (alist path &key (key #'identity) (test #'eql) (default nil))
"Retrieve the value in the given ALIST represented by the given PATH"
(or (reduce (lambda (alist k)
(cdr (assoc k alist :key key :test test)))
path
:initial-value alist)
default))
Retrieving the nested value is now an easy thing, e.g.
CL-USER> (assoc-path *json* '(:foo :bar :baz :qux))
42
This looks much better. The ASSOC-PATH
function we’ve implemented
takes the usual :test
and :key
keyword parameters just like
ASSOC
does, and also adds support for specifying a default value via
the :default
keyword parameter, if no such path exists.
Property lists are another form of lookup tables, which are made up of cons cells. They are a bit more primitive than alists, but they do have their purpose as well. A property list is a regular list with even number of items representing the keys and values of the mappings. This is an example of a plist.
CL-USER> (list :a 1 :b 2 :c 3)
(:A 1 :B 2 :C 3)
In order to retrieve a value associated with a given key in a plist we can use GETF.
CL-USER> (defparameter *plist* (list :a 1 :b 2 :c 3))
*PLIST*
CL-USER> (getf *plist* :a)
1
You can also supply a default value to be returned, if the key is not fonud in the plist, e.g.
CL-USER> (getf *plist* :x 'default-value)
DEFAULT-VALUE
Adding a new mapping to a plist is done via SETF
.
CL-USER> (setf (getf *plist* :new-key) 'new-value)
NEW-VALUE
CL-USER> *plist*
(:NEW-KEY NEW-VALUE :A 1 :B 2 :C 3)
In order to remove a key/value pair from a plist we need to use the REMF macro.
CL-USER> (remf *plist* :new-key)
T
CL-USER> *plist*
(:A 1 :B 2 :C 3)
If the key exists and is removed the result from REMF
will be true,
and NIL
otherwise.
An interesting thing about plists is how they relate to symbols. Each symbol in Common Lisp contains a plist associated with it, which can be used to attach metadata to a symbol. In order to access the plist associated with a symbol we can use SYMBOL-PLIST.
CL-USER> (defparameter *my-list* (list 1 2 3))
*MY-LIST*
CL-USER> (symbol-plist '*my-list*)
NIL
Adding a key/value pair to a symbol’s plist can be done in the following way.
CL-USER> (setf (getf (symbol-plist '*my-list*) :some-key) :some-value)
:SOME-VALUE
CL-USER> (symbol-plist '*my-list*)
(:SOME-KEY :SOME-VALUE)
A convenient function we can use when looking up a symbol’s plist is GET. Above example can be shortened a bit like this.
CL-USER> (setf (get '*my-list* :some-key) :some-value)
:SOME-VALUE
CL-USER> (get '*my-list* :some-key)
:SOME-VALUE
For removing mappings from a plist we can still use REMF
, or the more convenient
REMPROP function.
CL-USER> (remprop '*my-list* :some-key)
(:SOME-KEY :SOME-VALUE)
CL-USER> (symbol-plist '*my-list*)
NIL
For more information about lookup tables and other uses of cons cells, make sure to check the Practical Common Lisp book.