Compiling a Lisp to x86-64: Let expressions
bernsteinbear.com
bernsteinbear.com
Having tried my hand at following the Make a Lisp project ¹, implementing an interpreter in a couple of languages, it's fascinating to see the process of designing the language and compiler from the ground up, discussing various tradeoffs for simplicity, reasons for internal data structure, etc.
In fact, I think I'm learning more about C99 than Lisp - there's something about creating a language (or just following along like me), by necessity it leads to the foundations of computing/programming, the concepts that make up the basis of all languages. It's a perfect (meta)subject for an educational project.
https://pages.lip6.fr/Christian.Queinnec/WWW/LiSP.html
..And someone who updated the book's source code to run on modern Schemes:
Question: I saw a chapter about unary functions. In some interpreters let bindings are explained as "sugar", or syntactic extensions on top of function calls. For instance `let` is not present in the core of scheme [1]. So in this way one could transform a let binding from:
(let ((a 1) (b 2)) (+ a b))
to: ((((lambda (a) (lambda (b) (+ a b))) 1) 2)
through: (define-syntax let
(syntax-rules ()
[(_ ((x e) ...) b1 b2 ...)
((lambda (x ...) b1 b2 ...) e ...)]))
... then you wouldn't need to compile let bindings, just function application, which some people call "desugaring".I was wondering if this kind of desugaring is used in practical lisp compilers or not... without knowing too much about lisp compilers, I imagine one reason to not go this way is that it could potentially generate way more code than compiling higher level concepts like `let` directly.
What I mean is, there will only be 20~30 builtins; the rest can be implemented 'in userspace', as it were.
Though specifically in the case of cl, clos might complicate that somewhat, but probably not a ton.
It's one of the features of Common Lisp that things that might be buried in the compiler in other languages are surfaced and made available to the user. This means a great deal of the language's standard functionality can be implemented as if it were in "user space". Macros, readtables, method combinations, setf expanders and generalized places, for example.
IMO, if Common Lisp were to be extended (it probably won't be, at least officially, as there's no money for another whack at a standard) I think it would be best even if more "internal" features were surfaced this way, and made available for user extension.
(let* ((a 1) (b a)) (+ a b))
is equivalent to: ((((lambda (a) (lambda (b) (+ a b))) a) 1)
And anyway, implementing 'letrec' in C/Asm is really not that hard: allocate cells, assign names to them, compute the values. Compare that with the machinery of making closures (flat or linked?) and function invocation.I personally prefer to have 'letrec' as a primitive and implement non-recursive 'let' on top of it by checking whether any newly bound names are referenced from the bound values.
(let* ((a 1) (b a)) (+ a b))
I think this should just be plain let.If I do:
(define-syntax let-
(syntax-rules ()
[(_ ((x e) ...) b1 b2 ...)
((lambda (x ...) b1 b2 ...) e ...)]))
And then try: (let- ((a 2) (b a)) (+ a b))
I get: ; a: undefined;
; cannot reference an identifier before its definition
; in module: top-levelRe: lambda: it's important to realize that this series is building up incrementally bigger features from nothing. This Lisp compiler won't have a full implementation of closures or even labeled procedures for some time. So right now we're getting used to name binding and using what we have.
While it's possible to generate the same code I did even when rewriting to lambda, you'll probably have to have some kind of strength reduction afterward.
C:\nokolisp
(ncompile (macroexpand '(let ((x 1)) (+ x 1))))
$09CB:$7188: JMP ?? ; see below
$09CB:$718B: CALL $0ECD ; CALL ONEARG
$09CB:$718E: PUSH [$012C]
$09CB:$7192: MOV [$012C],AX
$09CB:$7195: JMP ?? ; see below
$09CB:$7198: PUSH [STACKMARK]
$09CB:$719C: MOV [STACKMARK],SP
$09CB:$71A0: MOV AX,[$012C]
$09CB:$71A3: CALL $0F1D ; CALL NUMVAL
$09CB:$71A6: MOV BX,$01
$09CB:$71A9: ADD AX,BX
$09CB:$71AB: CALL $05C9 ; CALL MAKNUM
$09CB:$71AE: JMP $2080
$09CB:$7195: JMP $71B1
$09CB:$71B1: CALL $7198
$09CB:$71B4: POP [$012C]
$09CB:$71B8: JMP $1DA7
$09CB:$7188: JMP $71BB
$09CB:$71BB: MOV AX,$04 ; S-object 1
$09CB:$71BE: CALL $718E
$09CB:$71C1: JMP $1DA7
(subru: eval=$7188, compile=$3B6F)
One pass, but corrects forward jumps afterwards.It's a good point and I'll try and remember to add a notice about order being important later.
What is gained from an extra layer of indirection?
(lets (a b) (list 1 2) c (+ a 3) (d e f) (some-func a b c) (* d e f))
It destructures automatically. The equivalent traditional macro might look like
(destructuring-bind* [[(a b) (list 1 2)] [c (+ a 3)] [(d e f) (some-func a b c)]] (* d e f))
which is unreadable. And yeah, normally it's indented properly and yada-yada, and people use editor plugins like paredit to make it not such a pain to type, but still. It adds up.
(let ((a 1) (b 2))
(+ a b))
and harder to implement as a syntax rules macro :)Arc uses (let a 1 (+ a 2)), and lets is simply the logical progression of that.
The parens of let lets me easily distinguish the binding clauses, and lets me navigate it a lot easier in Emacs. The lets macro seems like the opposite of that. Every odd thing except the last element is an identifier and every even thing is that identifiers value.