They Called It LISP for a Reason: List Processing (2005)
gigamonkeys.com
gigamonkeys.com
I jest a little bit, but that's really the fundamental thing about list-processing. For most code, you don't really care about runtime, and you really just need "a data structure that probably can solve the problem", and the (cons) based list of car and cdr solves it.
List processing itself is an elegant technique: a garbage collector tuned for exactly (cons) (2-element "items" that have a car-and-cdr, and the cdr is usually a pointer to another cons), is small, simple, elegant to implement, and works for almost any problem imaginable.
Maybe not as fast as a properly designed specific data-structure, but it will work. On top of that, Lisp has all kinds of shortcuts to make list processing easier to type.
---------
Getting a basic implementation of whatever project you're doing in cars-and-cdrs in (cons) is more important anyway for learning about your problem. Choosing a data-structure too early can hamper your understanding and bias you towards a possibly inefficient solution.
Knuth tries to explain the topic in his "binary trees representation of trees" subject, and notes that pointers to (two pointers) elegantly solves a wide variety of problems. Whether you wanna see them as binary trees, Lisp-lists, or graphs or even collections of malloc'd() nodes, it doesn't really matter. Its the flexibility of this data-structure that is incredible.
EX: the "left" child is "down a level", and the "right" child is "next sibling". Therefore, binary trees can represent any tree, and if allowed to loop, I'm sure the cons / binary tree node can be forced into working with graphs.
To achieve the same-level of expressiveness in C you need to craft an interpreter, dynamic memory allocation doesn't cut it.
I once wrote a simple linked list library in C to achieve lisp like functions, things got complex very quickly. I still dream of writing a garbage collector for it and evolve it to a lisp like state.
Like Greenspun said, I believe any sufficiently complicated C or Fortran program contains an ad hoc, informally-specified, bug-ridden, slow implementation of half of Common Lisp.
The beauty of the lisp language is that it doesn't care about interpreted vs compiled vs whatever. Its just the true elegance of "everything is a (lisp) list" (which is probably the wrong word. The truth of the matter is that "everything is a graph" and lisp-lists are really just a universal building block that can represent arbitrary graphs).
The important gadgets are:
1. Universal garbage collection (allowing you to ignore free() issues and simplify code)
2. Pointer to (two pointers). That is, car-and-cdr.
That's it. Everything else flows from these two things.
My opinion was that the compiled lisps precompile as much as possible (calculations etc.) into assembly and leave bits they can't compile intact, that's what make them fast.
You can see how these things happen in like, Chicken Scheme (R5RS Scheme to C compiler).
Macros would be evaluated at compile-time (or arguably _right before_ compile time).
I found my exact question on stackoverflow: https://stackoverflow.com/questions/7072980/how-do-you-compi...
The answers on SO are still not clear to me, some people say the macros can be evaluated at compile time, some say that the runtime needs to include some kind of interpreted to run macros. Some say incremental compilation is key to understand how macros get evaluated at compile time.
I will run experiments on this.
When I first heard the term incremental compilation, I thought of a process like going through the ast couple of times and evaluating expressions. This wasn't what it meant. Now it makes sense. You include the lisp compiler in each binary. For parts you cannot evaluate at compile time, you compile at runtime, just like a JIT.
Generally speaking this isn’t the case. There are probably exceptions besides a repl, but they’ll almost all be repl-like because macros are effectively functions of pre-compiled code, represented as lists as written in the code.
In other words, generally speaking, a lisp program’s life cycle goes something like this:
1. Developer writes lists.
2. Some of these lists are macros, compile them and execute them with their arguments. Many of these are themselves lists and symbols, which will be preserved according to various rules depending on which lisp you’re using and how they isolate code lists from program lists. It’s complicated.
3. Recurse step two until there are no more macro calls.
4. Now you have the actual program, and now you compile that.
5. Now you can execute the actual program.
Lisp designers were implementing languages in the 70s which were used to develop whole operating systems for computers with less than one million instructions per second. With window systems, networking, etc.
A big part of the history of Lisp is how to design the language such that it is efficient AND/OR dynamic. Lots of people invented clever memory management, clever implementation techniques and adjusted the language for efficiency.
The first self-hosted Lisp compiler appeared already in 1962.
Because of the way pointers are used, Forth is both compiled and not compiled.
> After the fetch and store operations are redefined for the code space, the compiler, assembler, etc. are recompiled using the new definitions of fetch and store. This effectively reuses all the code of the compiler and interpreter. Then, the Forth system's code is compiled, but this version is stored in the buffer. The buffer in memory is written to disk, and ways are provided to load it temporarily into memory for testing. When the new version appears to work, it is written over the previous version.
Your quote looks like its about bootstrapping the Forth ecosystem. After writing fetch and store operations in native assembly, you can bootstrap the forth system that is logical. This is the way Chuck Moore first used Forth anyways. He had no interpreter/compiler back than, he wrote some functions to aid him circumvent assembly and make his code portable. It is genius, but I think it's still interpreted.
You can compile python to bytecode and run it in python's vm, does this mean your python code is compiled. IMHO no, it is compiled for the python vm and python vm interprets it. Same thing applies to Forth.
Forth is interpreted, even if you compile and package it in a standalone executable binary.
I think it is easier to say forth is closer to compiled. I can kind of imagine each user defined word being translated into assembly. With lisp I am speechles.
As to your speechlessness regarding compiled Lisp, here's a quick example:
CL-USER> (defun foo (n)
(+ 1 n))
FOO
CL-USER> (disassemble #'foo)
; disassembly for FOO
; Size: 35 bytes. Origin: #x53641724 ; FOO
; 24: 498B4510 MOV RAX, [R13+16] ; thread.binding-stack-pointer
; 28: 488945F8 MOV [RBP-8], RAX
; 2C: BF02000000 MOV EDI, 2
; 31: 488BD3 MOV RDX, RBX
; 34: FF14250001A052 CALL QWORD PTR [#x52A00100] ; SB-VM::GENERIC-+
; 3B: 488B5DF0 MOV RBX, [RBP-16]
; 3F: 488BE5 MOV RSP, RBP
; 42: F8 CLC
; 43: 5D POP RBP
; 44: C3 RET
; 45: CC10 INT3 16 ; Invalid argument count trap
NIL
CL-USER>
(SBCL was used for the above)"The logic inside that helps dispatch based on the dynamic types at runtime" is the interpreter part IMHO. Plus you need logic to add the metaprogramming elements that require you to change the code after it has been written.
We need to generate an example of something we can't do in C, something which requires evaluation at runtime.
I think you have a fundamental confusion about when and where macros are applied. In a compiled CL implementation, macros are expanded prior to compilation. The code will be exactly the same as if there was no macro involved. If you want to see what that might look like take the function body of this simple function:
(defun bar (filename)
(with-open-file (in filename :element-type '(unsigned-byte 16))
(let ((contents (make-array (file-length in) :element-type '(unsigned-byte 16))))
(read-sequence contents in)
contents)))
And run it through `macroexpand` (`with-open-file` is the macro I'm particularly interested in here): CL-USER> (macroexpand
'(with-open-file (in filename :element-type '(unsigned-byte 16))
(let ((contents (make-array (file-length in) :element-type '(unsigned-byte 16))))
(read-sequence contents in)
contents)))
(LET ((IN (OPEN FILENAME :ELEMENT-TYPE '(UNSIGNED-BYTE 16))) (#:G659 T))
(UNWIND-PROTECT
(MULTIPLE-VALUE-PROG1
(PROGN
(LET ((CONTENTS
(MAKE-ARRAY (FILE-LENGTH IN) :ELEMENT-TYPE
'(UNSIGNED-BYTE 16))))
(READ-SEQUENCE CONTENTS IN)
CONTENTS))
(SETQ #:G659 NIL))
(WHEN IN (CLOSE IN :ABORT #:G659))))
Now take that output and put it in a new function, call it `baz`. Then disassemble both `bar` and `baz` (I've done this and included the output of just one because they have the same instructions):EDIT: Snipped the excessively long code, here's a link:
https://topaz.github.io/paste/#XQAAAQCODwAAAAAAAAAQYOhAaDnr7... (still long, but at least HN will truncate it)
It doesn't really make sense to talk about disassembling a macro as a macro is not, itself, what gets compiled when it's applied, but rather its output gets compiled (in a compiled Lisp).
> "The logic inside that helps dispatch based on the dynamic types at runtime" is the interpreter part IMHO.
Then every compiled program that uses a tagged union is really an interpreted program (which is a statement you could make a strong argument for, perhaps). The code above is still machine code, though, it's not being interpreted. This is part of the distinction between an interpreter and a runtime. Dynamic dispatch can be applied to statically typed languages as well as dynamically typed languages.
> We need to generate an example of something we can't do in C, something which requires evaluation at runtime.
Strictly speaking, there is no such thing as something that can be done in Lisp that cannot be done in C. It'll just take a lot more effort in C to do some things like compiling a function on the fly, returning it, and then applying it later. You'd at least need to link in a compiler and feed it some structure which it can then compile into a function which gets returned, probably, as a function pointer.
I think this is called incremental compilation: https://en.wikipedia.org/wiki/Incremental_compiler
Now it makes sense, SBCL requires a complete compiler embedded in the runtime. Runtime is small enough that you don't care for it.
Okay I agree that lisp can be compiled. You just need a clever runtime being able to dynamically compile parts at runtime. If there are lisp implementations that do not require embedding either a compiler or an interpreter in the runtime, I'd doubt their expressiveness.
Not sure what you mean by this but perhaps you're referring to how Lisp allows redefining functions on the fly without relinking? That's done by indirect function calls. Every function call in Lisp jumps indirectly through the function's symbol name (if it has one). This incurs a runtime penalty of one extra memory access per function call, but it enables functions to be redefined on the fly without changing any of their callers. Optimization switches exist to get rid of this extra overhead if you need maximum speed in deployed code.
The use of the macro gets expanded at compile time and the expanded code then gets compiled.
Lisp macros are designed in such a way that they are be expanded before runtime.
> evaluation at runtime
Evaluation at runtime does not mean the code gets not compiled.
The SBCL implementation of Common Lisp:
* (let ((fn-code (quote (lambda (a)
(* a 42))))) ;source of function as a list -> FN-CODE
(let ((fn (eval fn-code))) ; evaluation of that list FN-CODE ->eval-> FN
(prog1
(funcall fn 11)
(disassemble fn))))
; disassembly for (LAMBDA (A))
; Size: 36 bytes. Origin: #x700568E8E4 ; (LAMBDA (A))
; 8E4: AA0A40F9 LDR R0, [THREAD, #16] ; binding-stack-pointer
; 8E8: 4A0B00F9 STR R0, [CFP, #16]
; 8EC: EA030CAA MOV R0, R2
; 8F0: 8B0A80D2 MOVZ R1, #84
; 8F4: 3C9880D2 MOVZ TMP, #1217
; 8F8: BE6B7CF8 LDR LR, [NULL, TMP] ; SB-KERNEL:TWO-ARG-*
; 8FC: DE130091 ADD LR, LR, #4
; 900: C0031FD6 BR LR
; 904: E00120D4 BRK #15 ; Invalid argument count trap
462
You'll see that EVAL at runtime compiles the code on-the-fly and in-memory to ARM64 machine code.What you see is no interpreter. It's an interactive interface, reading code, compiling it, executing it, printing the result.
There is by default no interpreter. Everything is compiled in SBCL.
A Lisp system typically includes an on-board compiler and/or interpreter.
Try running sbcl without the REPL and produce an executable binary file. Maybe the binary representation changes into something with dynamic dispatch?
An interpreter is a Lisp feature where code gets interpreted from source at runtime. Some implementations have one, some don't, some only have an interpreter, some only have a compiler, some have several compilers, some have both.
> running sbcl without the REPL and produce an executable binary file
The executable binary file in SBCL always includes the compiler.
REPL: a user interface to execute Lisp code. Reads s-expressions, evaluates them and prints the results as s-expressions.
EVAL: the interface to execute code. A function.
Interpreter: an implementation of Lisp which interprets source code.
Byte Code Interpreter: an implementation of Lisp which interprets byte code instructions.
Compiler: an implementation of Lisp which can compile code to a) byte code, b) C code, c) machine code
In-memory compiler: an implementation of Lisp where the compiler does not create files, but writes the executable code directly to RAM
Whole-Program compiler: an implementation of Lisp, which only compiles whole programs and creates executables -> rare, but various examples exists
I guess the difference is that in an interpreter you don't go down to assembly, you parse and run stuff. The moment you cross the line to generate bytecode/machine code or transpile into a different language you get a compiler.
lispm's point is that it's not a/the Lisp interpreter. An Lisp interpreter and Lisp listener are different things. The listener is more like a "command interpreter".
You can write a very poorly featured listener yourself, by literally following the REPL acronym: (loop (print (eval (read)))). You have not written a Lisp interpreter in four operators; the read and eval functions are doing that. The following is a copy and paste from an actual session:
[1]> (loop (print (eval (read))))
(+ 2 2)
4
(let ((x 9))
(* x x))
81
Some other languages are the same way. When Bash runs a script, it's not going through the interactive command line processor that is used for interactive input, which has completion, recall and editing. That command line processor isn't what parses and understands Bash syntax.We can write a REPL at the Bash prompt also. We can write a loop which reads a line of input, evaluates it as shell syntax, and then repeats that ad infinitum until we hit Ctrl-C:
$ while true ; do read -r command ; eval "$command" ; done
echo foo
foo
for x in 1 2 3; do printf "[%s]\n" $x; done
[1]
[2]
[3]
uname -a
Linux sun-go 4.15.0-167-generic #175-Ubuntu SMP Wed Jan 5 01:55:52 UTC 2022 i686 i686 i686 GNU/Linux
^C
The shell language interpreter is in the eval command; we have not written a shell! The "for x in 1 2 3" syntax isn't coming from our "interpreter" loop.Lisp's eval isn't necessarily an interpreter. A Lisp implementation which has only a compiler will implement eval by compiling, like this:
;; insert expression into lambda expression.
;; compile lambda expression, resulting in a compiled function object
;; call function object, with no arguments.
(defun eval (expr)
(funcall (compile `(lambda () ,expr))))
You can write this function in any Common Lisp (just don't call it cl:eval).
Thereby you obtain a compiling eval, even if the standard cl:eval is interpretive.Let's say you have a list of assembly language function names FOO, BAR, BAZ, etc. Each name stands for the starting memory address of that function. Each function ends with a standard RET instruction. At address PROGRAM, you store the list of 64-bit function addresses. Now a "Forth Interpreter" is just
JSR-INDIRECT RP++
REPEAT
where beforehand you've stashed PROGRAM in register RP. Most assemblers don't provide an indirect JSR instruction but it's easy to write a macro that serves that purpose.Is this an interpreter? The only way it differs from true compiled code is that the function calls are indirect, so it's almost as fast as regular function calls.
What if you unroll the thing so the code looks like
JSR FOO
JSR BAR
JSR BAZ
...
Now all the function calls are direct. And as a bonus, all the individual functions themselves don't have to do anything more special than directly call functions. It's turtles all the way down.I've glossed over some issues like how to pass values and how many stacks you need and proactive tail-calling, but that's the gist of it. The whole "interpreter" just vanishes in a puff of smoke.
Another main difference from true compiled code is that your entire program consists of subroutine calls. There are no inline instructions and a main function.
I've got a lot of reading to do on this. "Indirect jsr", I'll experiment with this.
Okay I'm convinced, forth is not interpreted when it's compiled to threaded code :). There is no main loop that reads instructions and dispatches them dynamically, the program flow is linear with subroutine calls stacked under each other.
Great username btw.
Of course in Lisp your code can and should have clearly named functions that encapsulate your use of conses to build data structures, but casually violating that encapsulation, or better yet doing without encapsulation at all, just playing with conses, was the cool way to do it, the way you programmed if you had even a little bit of swagger. That was fun as long as feeling smart about the fact that I could do it outweighed feeling annoyed at everyone else doing it, but in the end, the feeling of smartness faded and the feeling of annoyance remained.
I never wrote Lisp code professionally, mostly just as a hobby for myself. It was all just dirty cons/cdr/car code.
Given how generic lisp-lists are, they're really free form and could be anything. They are probably "too generic" for a lot of applications, much like XML or JSON is overkill.
There's something to be said about writing configuration as .ini files by Python's ConfigParser, for example. Or other simpler files (even .csv files) and working off of that.
But Lisp gives you that "power" to have a true hierarchy / language built into your lists. With that power, comes great confusion 3 months later when you forget the structure.
-------
A good, professional programmer probably should:
1. Start with the Lisp-lists as a prototype
2. Simplify down to a simpler structure, like vector, or dictionary/HashMap, if possible.
Modern CL is referred to as a multi-paradigm language, and I almost always start a project by starting with "defclass" and "defmethod" like in any OO language.
Scheme, especially R5RS which is what I played around with the most, was about purity and simplicity. It wasn't really a practical/pragmatic language (though practical Schemes existed, like Racket), it was almost a "group study" in functional programming and greater programming concepts.
Common Lisp of course, was a practical/pragmatic language from the start. So of course you use vectors / hash maps / object-oriented programming.
-------
That being said, I fondly remember a lot of my scheme code, even if it was a bit quick-and-dirty. Its a paradigm that clearly works for a ton of problems.
Don't. Lisp since decades has a lot of different data types which are easy to use: vector/arrays, structures (-> records), classes, etc.
That doesn't mean conses and lists should be prohibited, but any use of conses/lists instead of arrays, CLOS objects, hash tables, or structs needs to be justified. And a programmer's ignorance of those other data structures is not a good enough justification.
> I sped up the code a bit by putting in side effects instead of having it cons ridiculously.
From what I’ve seen, this also seems uncommon in Racket, but I haven’t actually written code in Racket or spent time in the ecosystem, so I can’t say nearly so much about what’s typical with much confidence.
1: Excepting reference types, which are all based around either transactional logic, “transients” which are scope-local mutables, or host VM interop.
That's why a bunch of large Lisp software was written with named data structures in the 70s/80s. They were called class, flavor, structure, frame, ... One can find a lot about that in the literature. Common Lisp was literally the first officially standardized object-oriented language, providing named classes and generic functions as building blocks.
I found this a really curious statement. Linked lists made sense given the limitations in compute when LISP was invented (~1958) but how are vectors not a superior solution in every way, when available?
Vectors give you fast random access and fast append plus the same first/next semantics and performance as lists and the same ability to form trees. Pretty much the only thing lists are better at is prepend (which in my experience is much less useful than append).
Then for truly heterogeneous data you probably want hash maps as well, which, if you want something associative rather than sequential, are in a class all their own.
What am I missing?
What if you need to remove an item for example, do you need to shift all remaining items to keep order or mark the removed section with Xs so that the cursor jumps to the next data item.
You need to be able to randomly access bits and pieces to form a network.
The realization that (cons) / Lisp-lists / car-and-cdr give is that you can represent arbitrary graphs (!!!) with car / cdr / cons, leading to a truly universal data-structure.
Not necessarily an _efficient_ datastructure mind you, but a universal one.
------
So really, Lisp-lists are just the "try to represent your problem as a graph" and it really works 99% of the time, because graphs are just so flexible of a concept.
For example, take the HTML of this webpage. <DOCTYPE> <blah blah blah> and all that. Its pretty obvious how to convert it into an equivalent (DOCTYPE (blah blah blah) (p) ...) kind of structure.
Now try to do the same with vectors and hashmaps. You can't. You need to create a concept of vectors-of-vectors and hashmaps-of-hashmaps with arbitrary amount of depth.
-----------
Example HTML to think about
<div>
<p> Start of a paragraph </p>
<p> Second paragraph <b> but some of it is bold</b> </p>
</div>
(div (p "Start of a paragraph") (p "Second paragraph" (b " but some of it is bold")))
Lisp itself is written in the form of Lisp-lists. The entirety of your programming code _IS_ a list, proving how truly universal this data-structure is.You can't convert your C code into a vector or a hash-map. It just doesn't make sense. Meanwhile, all of lisp is a list, including the code.
This is not true; only cdr is a pointer to a pointer. car is a pointer to a value. For example, if x is (1 2), then (car x) is a pointer to the value 1, while (cdr x) is a pointer to the list (2), so only the latter is "to a pointer".
> convert your C code into a vector or a hash-map.
nit: C has neither vectors nor hash-maps, neither builtin nor part of any standard library.
This isn't really true either. Either half of a cons could contain a pointer or a direct value. Lisp makes the decision about pointers based on whether the value you're trying to store is small enough to fit there directly or not. And crucially, Lisp can tell the difference between a pointer and a value at runtime which means it will always do the right thing with whatever it finds.
This is why it gets dicey talking about pointers in Lisp: Pointers exist all over the place in Lisp but you have no direct control over them. That's the garbage collector's job.
Nothing in Lisp requires pointers in either half of a cons cell. There is a convention followed by the Lisp reader and printer that lists are represented with their head in the car and their remainder in the cdr of a cons cell, but the cons cells themselves don't care. Cons cells themselves know nothing about lists. They're just dumb 2-tuples with a car half and a cdr half. You're free to stick anything you want in either side. Each side holds 64 bits, and if you want to stick a string containing the Gettysburg address in the car [or cdr], Lisp will let you. (More specifically if you create this string Lisp will allocate a pointer for it, and it will then stick that pointer in the car.)
You are definitely right about that, but the initial claim was made about "Lisp list", for which cdr is certainly a pointer. I would expect that when we refer to "Lisp lists", we certainly don't include things like (cons 1 2)
BTW
(listp (cons 1 2)) => true
so (cons 1 2) is indeed a list; it's just not a proper list.If you're mutating it, yes. And CL/Scheme do offer that datatype.
Linked lists support a functional style building shared structures without having to worry about plan interference from the mutations. Clojure can do this with immutable arrays instead of lists, but the implementation is hairier than either linked lists or mutable arrays.
Making different lists that share the same tail, by building at the front, is incredibly common in code that manipulates code.
For instance, in many situations you have a body of forms that are to be put into some statement:
(defun glue-args-and-body-making-lambda (args body)
`(lambda ,args ,@body))
That lambda expression shares the args object with the caller, of course (and an array would do the same), but it also shares the body object, which an array won't do unless it's a very specialized array.The backquote expression can compile down to:
(list* 'lambda args body)
which allocates exactly two cons cells: one cell to hold lambda in its car; another one to hold args in its car and the body goes into the cdr of that same one.It's also incredibly common to work with the suffix of a list.
In the reverse direction, we can break the lambda:
(defun take-apart-lambda (lambda-expr)
(destructuring-bind (lambda-sym args &rest body) lambda-expr
(values args body)))
This requires no memory allocation We can pull the args out of an array-based lambda expression for free; but not the body (suffix of the array).Vectors have the advantage of compact storage; a large vector's storage should be roughly the value word size times number of elements, plus small fixed overhead. Each element of a list costs us a cons cell (unless we have cdr coding, which is just little vectors in disguise). Cons cells can be compactly allocated, but not like the elements of a vector. They can be individually reclaimed though. If we cdr three steps down a list, and nothing has retained the pointer to that list, those three conses can be identified as garbage and reclaimed.
Doing this with vectors is internally much messier and requires a lot of copying, which makes it slower, unless your queue is a circular buffer of fixed size.
How so?
Say we have a vector #(1 2 3 4) .
How do I get a vector with the first element removed? How do I get a vector with a element added?
(rest #(1 2 3 4)) -> #(2 3 4) (prepend 0 #(1 2 3 4)) -> #(0 1 2 3 4)
There are basically a few options to implement this:
a) allocate new vectors
b) implement growing/shrinking vectors
c) do something very clever internally, sharing vector memory
With cons cells this is simple. The possible drawbacks:
a) memory is fragmented into cons cells linking each other
b) lists may share memory
I don’t recall if it has instructions on getting a dev env up and running, but if it does I imagine they’re out of date. Emacs with Sly or SLIME is what I’ve used, but I believe there are viable options for proper interactive development in vim and vs code among others.
If you just use a basic editor and paste code into a repl in a terminal you won’t be getting the experience most Lispers do.
The chapter on files in the Practical Common Lisp book wasn't complete enough for me to read a one line file of 800MB which is part of the Harvard Library Open Metadata archived set.
http://www.cs.cmu.edu/~dst/LispBook/index.html
— which, yes, is a wonderful book for a complete beginner.
I tried searching on YouTube but didn't find anything particularly unique.
For larger projects I remember youtube videos for Diablo and Minecraft clones.
This is a real project, this guy wrote a compiler, to convert common lisp to GLSL (I'm not talking about a simple DSL with code generation, but also type checking and so on).
In his Livestreams, he usually try to implement a specific 3D feature (like triplanar mapping, the Phong of lighting, ...).
Everything is done in a single window, with the program being recompiled while running, pure lisp style.
He is on a hiatus right now though with livestreams, but there is plenty of material already to watch ...
FWIW I feel like RelationalAI, in their "Rel" language, has done for n-ary (database) relations what Lisp&Scheme did for lists. Something I had pondered myself for years and kind of grasped at but never really got, and I think they've done it. It's really quite elegant, worth checking out:
https://docs.relational.ai/rel/intro/overview
E.g.
"The constants true and false are also relations, of arity 0. There are only two of these: false is {} (the empty relation with arity 0), and true is {()}, that is, the relation with one empty tuple (arity 0 and cardinality 1)."
and
"In Rel, a single elements is identified with a relation of arity 1 and cardinality 1. For example, the number 7 is the same as the relation {(7)}"
This lets them do clever things like use relational cross-product ("," operator) kind of like Lisp's `cons` to build tuples, so:
(1, 2, 3)
builds the relation {(1,2,3)} from {(1)}, {(2)}, {(3)}.
And also the same operator can be used for filtering because "false" and "true" likewise evaluate to relations, so crossproducting them acts like a "where" clause, so: def myelements = 1; 2; 3; 4; 5; 6; 7; 8; 9
def output(x) = myelements(x), x > 3, x < 7
^ "filters" myelements to return the values in the relation that are greater than 3 and less than 7.And it also works as a pure cross-product operator, of course, for when you need that.
It's the same kind of elegant composability you get from Lisp, but with a maybe semantically richer datatype and a richer set of (relational algebraic) operations.
By the way, check out datalisp.is, I think we should re-encode the web :)
Contrasted with many other languages that are catching up, but sometimes have surprising amount of boilerplate to process data.
You would also need various arithmetic operations, possibly up to hashing algorithms for hash tables/sets/bloom filters/etc. I would love to know if anyone can point me to an article or something describing the minimal set of operations needed to construct any data structure.
For those who haven't checked either nandgame or nand2tetris out, I would recommend them as fun little diversions in boolean logic and computer architecture.
But yeah the real answer is lambda calculus, see lambda lisp and justine.lol
If you'll forgive my giving an answer that clearly wasn't what you intended, rather famously, lambdas are sufficient: https://en.wikipedia.org/wiki/Lambda_calculus. See also https://en.wikipedia.org/wiki/Combinatory_logic.
— ⁂ —
The first thing to understand is that Lisp invented functional programming. (Seibel's chapter mentions this, of course.)
The functional-programming approach is to define your procedures recursively rather than iteratively; this began in Lisp but is now central to a number of other languages, like Haskell, OCaml, and F#. For certain fields, like symbolic algebra and compilers, this approach makes many difficult problems trivial. Even outside those fields, pervasive immutability often has many benefits, eliminating large classes of bugs, greatly simplifying problems like undo and thread-safety, and dramatically reducing the runtime cost of garbage collection. However, recursively defined linked data structures tend to use more space, and they're not very cache-friendly, and sometimes those are more important considerations.
To define your procedures recursively, it's very helpful to define your data types recursively. Recursively-defined lists (for example, "a list of Ts is either the empty list of Ts or a T followed by a list of Ts") support this recursive, immutable approach to programming.
Other kinds of recursive structures do, too; Haskell and ML dialects tend to use application-specific sum types at least as much as general-purpose lists, and I like that style better. It has most of the same advantages and disadvantages.
STL-style vectors do not support a recursive style of programming. They support an iterative, imperative approach to programming. If you use that other approach to programming exclusively, you will not understand why anyone would want to primarily use linked lists. And occasionally you will be confronted with problems that are very difficult for you to solve, problems which would have been trivial with a recursive approach, and you will not realize that you are doing a hundred times more work to solve them than you need to. SICP is full of examples.
— ⁂ —
The second thing about Lisp is that it has orthogonal serialization and deserialization: PRINT and READ. This mechanism is sometimes, though not always, adequate for debug logging, saving and loading application state, configuration files, and networking. Modern Lisps like Clojure generally extend those to arbitrary data structures, not just lists and atoms, but it's especially easy to implement if you only have lists and atoms; it's about 30 lines of Forth, for example¹. This is of course not unique to Lisp anymore; Java, Python, Tcl, Golang, and many other languages have ways to do it.
Orthogonal deserialization of lists is not really an optional extra, since you use it to parse Lisp programs, too. And you need the serialization to print lists in the REPL, anyway.
— ⁂ —
The third thing about Lisp is that it supports metaprogramming very well. This takes lots of forms; compile-time macros are a common one, and they give you enormous flexibility to extend Lisps into domain-specific languages, though they're probably used even more often to hack around inadequately optimized compilers. EVAL answers many requirements for runtime flexibility, though there are times when it is too powerful.
As with serialization and deserialization, these metaprogramming facilities mostly just fall out of the Lisp design; they require minimal or no extra code in a Lisp interpreter.
— ⁂ —
Historically speaking Lisp had a lot of other advantages: for a long time it was the only garbage-collected language, the only dynamically-typed language, the only language with EVAL, the only language with higher-order functions, and so on, and so for many years it was by far the best language for the things that you would do in JS, Python, Ruby, Lua, or OCaml today. JS, Python, Ruby, Lua, and OCaml are better for some of those things, and for many purposes they adequately support the functional, recursive, immutable approach to programming that Lisp pioneered.
Still, it might be easier to learn it in Lisp.
— ⁂ —
Lisp is sort of a minimal core of functional programming. In a decent low-level language like C or Forth, you can build a Lisp with an interactive functional programming environment with dynamic typing, orthogonal serialization and deserialization, an interactive interpreter, garbage collection, and extensive metaprogramming capabilities, in under 1000 lines of code, and the only data structure you need to do it is cons.
(It's not quite as simple as you might think from reading the "Maxwell's Equations of Software" in the Lisp 1.5 manual; those gloss over READ, PRINT, decimal conversion, arithmetic, symbol interning, the garbage collector, and user interaction, so you end up with significantly more code in a low-level language² where you have to implement those. But it's still days of work, not weeks.)
That doesn't mean Lisp is the only way to do functional programming, or even the best one. Quite apart from implementation questions, you might reasonably prefer the ML approach, with its strong static type checking; or the Haskell approach, which also features laziness; or the Clojure approach, which includes first-class persistent finite maps and integer-indexed "vectors"; or again the Clojure approach, where every operation supports ad-hoc polymorphism; or the Tcl approach, where everything is a string; or the Q approach, where your code defines term-rewriting rules rather than functions; or the Prolog approach, where you have not only list processing but lists that can contain uninstantiated logic variables; or the KANREN approach, which generalizes functional programming to full-on relational programming; or the Bicicleta approach, where you program directly in a side-effect-free ς-calculus, overriding methods instead of passing parameters; and many other approaches that haven't been thought of yet.
But if you're thinking of Forth as an alternative to Lisp, or STL vectors as an alternative to Lisp lists, and in general rather than in a particular case, you just haven't understood the Lisp approach to problem solving at all.
And when you do, it's going to be awesome.
______
¹ http://canonical.org/~kragen/sw/dev3/readprint.fs
² https://www.mail-archive.com/kragen-hacks@canonical.org/msg0...
This isn't just a dismissive observation. It's the heart of why Lisp is so hard to implement. When I ignored mutable cons cells, I realized I could just implement bel in Python by using actual Python lists.
t = True
nil = None
def car(l):
if l:
return l[0]
assert car(nil) == nil
assert car([]) == nil
assert car([1]) == 1
assert car([[1]]) == [1]
assert car(car([[1]])) == 1
assert car(car([(1, 2)])) == 1
def sequence(x):
return isinstance(x, (list, tuple))
def cdr(l):
if l:
if v := l[1:]:
if v[0] == ".":
if sequence(l):
if len(l) == 3 and l[1] == ".":
return l[2]
return v
assert cdr(nil) == nil
assert cdr([]) == nil
assert cdr([1]) == nil
assert cdr([1, ".", 2]) == 2
assert cdr((1, 2, 3)) == (2, 3)
assert cdr("foo") == "oo"
def join(x=nil, y=nil):
if sequence(y):
return [x, *y]
return [x, ".", y]
assert join(nil, nil) == [nil]
assert join(1, 2) == [1, ".", 2]
assert join(1, '"foo"') == [1, ".", '"foo"']
Presto, now you have car, cdr, and join (cons) in Python.This works (and works very well). You can build lists with Lisp and then pass them off to other Python libraries -- after all, they're just lists. And in the rare case where you care about mutating cons cells, you can just use a dict instead.
EDIT: See https://news.ycombinator.com/item?id=33194570 for a more thorough explanation of why mutable cons cells are problematic.
Every MMU running a Unix with mmap probably disagrees!
There's a fascination in the Lisp world for cons cells. Specifically the cell aspect. If you represent lists as:
l = [x, [y, [z]]]
Then you can implement car as l[0] and cdr as l[1].If you require mutable cons cells, that's pretty much the only way to do it. Because if you want to set the car of cdr(l), how do you do it? You can just do
cdr(l)[0] = t
Because that's the same as l[1][0] = t
Which of course makes the list become l = [x, [t, [z]]]
But this sucks. It's always sucked, and Lispers go out of their way to ignore the fact that it sucks. I wince at having such a dismissive attitude here, but it's been the source of years of frustrations.It's a frustration because if Lisp had been implemented using vectors and hash tables instead, it'd be in a far stronger position today. Everyone uses vectors. Vectors are [1, 2, 3, 4] ... Plain old arrays! In fact, I used vectors in the above examples, and didn't even have to explain what they were. Everyone knows and understands arrays.
Lisp can work fine with vectors, if you drop the requirement of mutable cons cells. Because l becomes:
l = [x, y, z]
And cdr(l) becomes l[1:], so cdr(l) returns [y, z] -- an entirely new vector containing y and z.This might seem like nonsense, but in practice it's not. In practice, you're rarely building lists containing hundreds of thousands of elements. Usually it's much smaller lists contained in other structures, like hash tables.
And when the lists are small, you really don't care about creating new lists. The fact that cdr(l) returns a copy of l minus the first element is inconsequential. You'll ~never experience a slowdown.
And the gains are massive. You get to interface with all your native libraries using lisp algorithms. You don't have to convert from "lisp lists" to "python lists" or "javascript lists" or anything else. They're just arrays.
If you want to try it out for yourself, give Lumen a spin: https://github.com/sctb/lumen
His Postgres FFI is the prettiest lisp FFI you’ll ever see. https://github.com/sctb/motor/blob/master/pq.l
Sounds a lot like Clojure
It's a general purpose language, and any competent Lisper uses the right data structure for the job. As it happens, the cons tree (not a list, a tree!) is the right data structure for representing Lisp code. It's not necessarily the right data structure for representing other non-code data that Lisp code is working with.
It’s worth considering carefully why you feel cons cells are so important for lisp code. Nested vectors are trees. Why not represent
(define (foo x) x)
as [define, [foo, x], x]
?What? MAP[1] works just fine with vectors and other sequence types. Are you somehow surprised that MAPCAR doesn't? The name makes it pretty obvious I'd think. I'm starting to think you just lack familiarity with the language that you're criticizing.
My retort to you would be "I'm starting to think you like complexity for the sake of it," but debates are much more fun when we're both genuinely interested in the other's perspective.
Suppose Lisp were forced to abandon cons cells and could only use SQL tables to represent code. What's the disadvantage?
Anyhow, by all means write your Python vector based Lisp dialect. It's no skin off my teeth. Maybe it really is superior and you'll be the next Rich Hickey.
with vectors that doesn’t work UNLESS you add a bit of overhead (which most optimising programs may do since for many cases vectors can be more performant)
> In the presence of mutable objects, CDR coding becomes more complex. If a reference is updated to point to another object, but currently has an object stored in that field, the object must be relocated, along with any other pointers to it. Not only are such moves typically expensive or impossible, but over time they cause fragmentation of the store. This problem is typically avoided by using CDR coding only on immutable data structures.
This is exactly what I've been saying. Thank you for providing a formal reference to the idea.
(I'm a bit confused how we wound up talking past each other, since my original proposal was identical to CDR coding on immutable cons cells.)
std::variant is really tricky, mostly because of C++'s type system. I went with std::any. My attempt is here: https://gist.github.com/shawwn/63e0f010479efd95ebffdf2108645...
I'd love to see your code and compare notes! Mine is pretty crummy; I'm not sure there are any worthwhile ideas in it. Did you have much trouble with the std::variant route?
What I'm doing is an extremely simple evaluator, which can do what is required for PDDL: basic arithmetic, logic operators and IF.
Anyway, these kind of weird arguments from people who don't get it have been proposed and shot down a million times before, and I'm not sure there's any value reiterating. I suggest anyone interested in Lisp (or any programming language, really) ignore weird HN critiques and just read a book, like the one linked here.
@dataclass
class Cons:
car: Any
cdr: Any
You can define a nice printer or reader for it if you want, but mutability doesn't seem to be a hindrance in implementation.I tried. It sucks. The conversion becomes a problem all over the place.
isinstance(Cons(nil, nil), list) will fail, for example.
I posted a more thorough answer here: https://news.ycombinator.com/item?id=33194570
I think I see where your problem actually is.
Other languages have started catching up with massive standard libraries. But... LISP has been there for a long time.
Lisp is hard to implement? The list processing core is very small and relatively easy to implement.
So confused by this. Shared, tree-like, and mutable seem pretty orthogonal to me.
Sure, I'll forgo mutable structure. And since I don't mutate, I can safely share structure. But I don't want O(n) update operations, so I'd better make my hash tables use a tree-shaped data structure: