An S-expression is the fundamental unit of storage in Lisp. By the original definition, an S-expression is one of two things.
An atom is the base case. In classical Lisp (the original language proposed by John McCarthy), an atom is just a distinct unit, that we conventionally designate with a name. Conceptually, you can think of it as a string, even though that's not how any modern Lisp would store it internally. So foobar
is an atom, and so is potato
. They're just strings that are atomic, in the sense that they don't recursively contain any more S-expressions.
Note that modern Lisp dialects extend the definition of "atom" to include things like numbers, so in Common Lisp, 1.0
would be a valid atom which represents a number.
A cons cell is the fundamental unit of composition in Lisp. A cons cell is a structure that points to two other S-expressions. We call the first of these S-expressions the car and the second the cdr. These names are archaic and were originally references to how cons cells were stored on old computers, but Lisp programmers today still use them. You'll hear some people refer to the car as the "first" or "head", and you'll hear some people refer to the cdr as the "tail" or "rest". (Try not to refer to the cdr as the "second" term, as that's ambiguous and could be interpreted as something else, which we'll talk about in a minute)
Now, we write cons cells in parentheses with a dot between them. So a cons cell where the car and cdr are both atoms would look like
(foo . bar)
This is a cons cell whose car is the atom foo
and whose cdr is the atom bar
. We can also nest cons cells.
((foo . bar) . (baz . potato))
And then we end up with a sort of binary-tree-like structure, where each branch has a left and a right (a car and a cdr, in our terminology), and each leaf is an atom.
So what can we do with this? Well, for one, we can store linked lists. There are several ways to do this, but the prevailing convention in the Lisp community is to use the car to store the current value and the cdr to store the cons cell pointing to the rest of the list. Then, when we reach the end of the list (where we might store a null
pointer if we were doing this in C or Java), we pick out a particular atom, called NIL
. There's nothing special about the NIL
atom in the definition above; we just picked one out because we needed one to use as a convention.
So to represent the list [a, b, c, d]
, we would store it as
(a . (b . (c . (d . NIL))))
The car of the outermost cons cell is the first element of the list, or a
. The cdr stores the rest of the list. The car of the cdr is the second element, b
, and so on. (This is why I said not to call the cdr the "second" element, as "second" is often used to mean "car of cdr")
In fact, we do this so often that there's another notational convention in Lisp. If the cdr is another cons cell, then we simply drop the .
and the parentheses and understand what it means. So, in general, we say that the following two are equivalent, for any S-expressions a
, b
, and c
.
(a . (b . c)) === (a b . c)
Again, I haven't changed the definition. There's still only two valid S-expressions: atoms and cons cells. I've just invented a more compact way to write them.
Likewise, since we're going to be using NIL
a lot to end lists, we simply drop it. If we have a NIL
as the cdr of a cons cell, then by convention we remove the .
and the NIL
. So the following are equivalent for any S-expression a
.
(a . NIL) === (a)
Again, I'm just inventing new, compact ways to write things, not changing the definitions.
Finally, as a notational convenience, we might sometimes write the atom NIL
as a pair of empty parentheses, since it's supposed to look like the empty list.
NIL === ()
Now, looking at our list from earlier
(a . (b . (c . (d . NIL))))
we can use these rules to simplify it
(a . (b . (c . (d . NIL))))
(a b . (c . (d . NIL)))
(a b c . (d . NIL))
(a b c d . NIL)
(a b c d)
And now this looks remarkably like Lisp syntax. And that's the beauty of S-expressions. The Lisp code you're writing is just a bunch of S-expressions. For example, consider the following Lisp code
(mapcar (lambda (x) (+ x 1)) my-list)
This is ordinary Lisp code, the kind you would see in any everyday program. In Common Lisp, it adds one to each element of my-list
. But the beauty is that it's just a big S-expression. If we remove all of the syntax sugar, we get
(mapcar . ((lambda . ((x . NIL) . ((+ . (x . (1 . NIL))) . NIL))) . (my-list . NIL)))
Not pretty, at least aesthetically, but now it's easier to see how this really is just a bunch of cons cells terminated in atoms. Your entire Lisp syntax tree is just that: a binary tree full of code. And you can manipulate it as such. You can write macros that take this tree, as a data structure, and do whatever on earth they want with it. The abstract syntax tree of your Lisp program isn't some opaque construct internal to the language; it's just a tree: an incredibly simple data structure that you already use in everyday programming anyway. The same lists and other structures that you use to store data in your Lisp program are also used to store code.
Modern Lisp dialects extend this with new conventions and, in some cases, new data types. Common Lisp, for instance, adds an array type, so #(1 2 3 4 5)
is an array of five elements. It's not a linked list (since, in practice, linked lists are slow for random access), it's something else entirely. Likewise, Lisp dialects add new conventions on top of the NIL
ones we've already discussed. In most Lisp dialects, the apostrophe, or single quote, is used to represent a call to the quote
special form. That is,
'x === (quote x) (quote . (x . NIL))
For any S-expression x
. Different dialects add different features to the original McCarthy definition, but the core concept is: What is the absolute minimum definition we need to be able to comfortably store the code and data of our Lisp program.