Is the cons cell really such a good idea?
What is so cool about the cons cell idea? Representing all lists that way is horrible for computers (so bad that even back then they had to invent tricks over that, like cdr-coded lists) and also horrible for humans (and indeed explicit code is required for not showing you or parsing explicitly all those chains).
From a conceptual point of view I don't see the cons cell really simpler than a fixed size dynamically allocated array. If I can understand a 2-cells entity then why not 3 or 4, or 1, or 0? Indeed a cons cell is just a special case of a fixed size array.
Linked lists are great (and fantastic for some use cases), but they're not as powerful as arrays providing O(1) indexed access. Building efficient complex structures is a lot easier with atoms and fixed-sized arrays than with just atoms and cons cells.
What am I missing?
Posted Aug 23, 2020, 4:35 PM UTC
cons cells are the simplest example of a persistent data structure. Not persistent in the sense of mass storage, but with that other meaning: a persistent data structure is one we can extend using a pure functional operation. That means we efficiently calculate/construct a new object based on the existing object, without altering the existing one (and thus without having any effect on any code that holds the existing one).
cons cells are probably the only persistent data structure that can be implemented in a few simple lines of assembly language, which explains why they were discovered so early by the Lisp researchers.
If you have cons-cell based list like
(1 2 3), not only does it cost next to nothing to turn it into(0 1 2 3), but the(1 2 3)part is not touched at all. New memory is allocated for just the cell that holds the0.Now persistent vectors and other structures are possible also, but those came decades after the cons cell, and have much more complicated internals, and use tradeoffs and costs that are not always clear. The cons cell has simple internals. The cost of everything you do with cons cells is obvious, especially if you open-code it yourself instead of relying on complex operations in library functions.
The other nice thing about cons cells is that given
(0 1 2 3), you can obtain the suffix(1 2 3)without allocating any memory, just by chasing one pointer. This is very nice for recursion. A Lisp code walker that recurses over code hardly has to allocate any memory, if it isn't transforming the code.Incidentally, null terminate C strings also have this property. The C library function
strchr(look for a character in a string and return a pointer to it) could be written like this:If
sis a non-empty string in C, thens + 1is also a string: the suffix of that string which removes one character. That's just like iflis a non-empty list in Lisp, then(cdr l)is the rest of that list. The above is a good way to dispatch clueless people who criticize null-terminated strings from an uninformed, one-sided perspective.Yet one more advantage of cons cells is that they are all of the same size. This means that heap structures which are dedicated to cons cells are completely devoid of internal fragmentation. A Lisp image can churn cons cells all day long, and never fragment its memory by doing so.
cons cells can be made lazy, in fairly easy, straightforward ways.
So that is to say, you can express lists that are too large to fit into memory all at once, or that are even infinite.
For instance, this operation doesn't read all of
/var/log/syslog:it generates a lazy list of the lines of
/var/log/syslog. Then the first five are taken from that.Similarly, we can take the first five from the infinite list of integers starting with 1.
The following expression runs in constant memory. It's looking for a particular large number in an infinite lazy list of integers, which is too large to fit into memory. As the
findfunction marches down the list, it loses track of the beginning of the list, and so that part of the list is garbage collected. The part of the list not yet visited doesn't exist yet:You can't do that with an array, unless you have some exceptionally remarkable array structure. (I.e. not related to the actual arrays in everyday languages that you know).
Cons cellls are useful simply as pairs also. In programing, we sometimes need to glue two items together as a pair, and the
conscell does that well in many situations. A lot of languages have a pair type. Haskell has a way to "cons" items together to make pairs. That's a bit like a cons cell. C++ has astd::pairtemplate that is a bit like a cons cell. Scheme uses the "pair" terminology for cons cells. C++ havingstd::vectordoesn't makestd::pairuseless.As a practical side-note to this: The ability to do functional programming on such a simple datastructure is the reason why code is represented as lists. For macroexpansions (and for what compilers do in general) it is very important, or at least very useful, to work with functional code.
In macros in a modern lisp this is important because we never know how often a macro might be expanded.
So the lesson is: lists have their disadvantages for many applications, but they are an excellent choice to represent code. Representing code is what lists are mainly for, not number crunching or mass data wrangling.
The "changes" you can do are only PRE-pending an element to the list. Placing the value as element inside other values is possible with any representation using references.
Prepending is in my experience not such a common operation; actually more common is APPENDING at the end, not at the beginning, and that with cons cells is impossible without altering the content. Also very common is concatenation, but that also requires mutation or copy. Altering the content of cons cells is possible and the appending operation is so frequent that Lisp code quite often uses the
push/nreverseapproach (you build the result in reverse order you want it and then flip all the CDR links at once). The other way is traversing the whole list and mutate last cons cell likenconcdoes for all but last argument.The very common need for mutation is IMO also the reason that in CL
firstreturns the first element, and somewhat surprisinglylastreturns the last cons cell instead, not last element.Static arrays cannot grow, but what you can do (and that basically all languages in a way or another support, including common lisp as
vector-push-extend) is using static arrays with a fill pointer and reallocation.Fill pointers and reallocation use more memory than the actual content you care about, but still even using a fixed 2 as grow factor the memory used in the worst case is the same as a linked list (and you have O(1) random access).
Linked cons cells lists only are special about sharing tails, and while for sure it's a use case, in my experience it's not such a common one (and nothing prevents you from building linked lists if you want them by using using 2-elements arrays).
When working in lisp implementations I simply found that in many cases you just share parts and that is the same for both cons-cells lists and arrays.
Consider that much of that experience is probably with data structures where appending is more efficient than prepending (for example, something like a Vector in c++ or an ArrayList in Java, where appending is amortized O(1), while prepending is always O(n)). An efficient algorithm in lisp may look different than what you are used to. In particular, data may be stored in the reverse order from what you would use if appending (which in some cases can even make recursion simpler) or you build up the list in reverse, and then reverse it when you are done building it to get it in the original order.
[deleted]
Appending to a list is easily done with a tail pointer. Nodes for a linked list can be pre-allocated, so no memory management is required for enqueuing and dequeuing. For example in an OS kernel, you can embed the list link(s) into the process structure, so that enqueuing a process in a scheduler run queue or a wait queue requires no allocation. Even if you have to allocate a node, it comes from a recycled pool in which all the nodes are of the same size. No situation occurs where you have to relocate the entire list to another spot in memory because it has no room to grow in place. Allocating list nodes from a reserve list can be efficiently done with atomic operations, making it suitable for interrupt service routines.
Ergonomics. Programmers do not want to see a list which looks like this:
What would the following look like if it had to be written using the above notation?
Moreover, array routines are of no help. Say I want the third item of the list. I cannot use
(aref list 2); that is out of bounds. We need(aref (aref (aref list 1) 1) 0)which is in no way better than(car (cdr (cdr list))), not to mention(caddr list), not to mention(third list).Nothing prevents you from constructing a system in which there is a special type tag for a 2-element array which gives it a different read/print notation, so the list looks like
(1 2 3 4), and from providing a collection of operations geared toward list manipulation that work with those two-element lists, so that things are a lot nicer in those allegedly rare situations when list crop up.[deleted]
where are range and take implemented?