Homegrown closures for uxn
at least, kind of...
For a week or so now, I've been writing niënor, a "lispy environment for uxn". I did not want it to become a full-blown lisp but just another way of writing uxntal. Uxntal is a bit too dense for my liking, and I prefer lisp/scheme s-expression syntax. Niënor is just a compiler and a macroexpander that takes in scheme-like code and spits out uxn roms.
This article describes my homegrown method of creating lexically scoped closures in this environment.
Lambdas
Or, anonymous functions.
When ignoring lexical scope lambdas are really simple to implement if we can already compile named functions.
Here is some simplified code with commentary from the compiler that
does exactly that (I've skipped some boring parts). We simply give the
anonymous function a name, skip the compilation for now (add to
epilogue), and push
the name (that will get resolved later
by the compiler) in its place.
exp)
(tuple-case (list->tuple ;; [...]
; if it's a lambda
((_λ args body) let ((used-locals ; check what symbols the lambda uses,
(; intersect them with the list of known locals (function arguments, local values declared in let expressions)
(intersect ; to get a list of used locals
(get env 'locals #n)
(code->used-symbols body)))) if (null? used-locals) ; if there are no locals,
(let ((name (gensym))) ; generate a random name for that function
(;; [...]
; <- put this function into the epilogue, so it gets compiled at the end of the program
(put env 'epilogue append (get env 'epilogue #n)
(
`((_defun ,name ,args ,body))));; [...]
;; in this place, the gensymmed name gets pushed, and later resolved to the functions' location
;; so an expression like
;; (let ((f (λ (x) (+ x 1))))
;; ...)
;; would resolve to
;; (let ((f @@gensym__1))
;; ...)
;; @@gensym__1 being the generated name of the now de-anonymized function :P
)))))
Closures
Or, lambdas with extra steps.
Closures are anonymous functions that capture variables from the environment. Consider this classic example:
define (make-adder a)
(+ a b))) (λ (b) (
Here, the lambda captures the variable a
, to later add
it to b
. If we would try to simply name the lambda and
compile it somewhere else, we wouldn't be able to resolve the symbol
a
, as it wouldn't be visible in global scope.
We could simply disallow closures. Niënor was meant to be quite low-level and closures are, well, quite high-level-ish in my opinion. That is a valid method, but not really a solution. I keep closures close to my heart, so I really wanted to have them in my lispy uxn.
I decided to create objects that tied the required environment to functions. Well, kind of... Because there are no types on runtime, I can't really detect if I'm trying to call a closure, a function, or something else.
This, for example, is perfectly valid way to restart a program:
#x100)
(
;; or
let ((f #x100))
(; this would quickly exhaust the local variable stack though
(f)) ; as we're not doing any cleanups, because they would be done after a return from f, which never happens
This means that we have to generate executable code at runtime to later jump to - yikes!
I thought of copying the entire function to somewhere else and replacing the unbound variables with environment-specified values during runtime, but that wouldn't work as absolute jumps would always go back to the original function, not our copied version. We could (probably) find all internal jumps and calculate new positions at runtime, but that's expensive (slow) and hard.
The solution i came up with (while showering) is the following:
- At compile time, when we know what values from the environment the function will need, add them as parameters
;; this:
define (make-adder a)
(+ a b)))
(λ (b) (
;; would become this:
define (make-adder a)
(+ a b))) (λ (a b) (
This makes it so all the variables will always be bound, and we can generate code for this like for a normal lambda. Of course, we can't just return this to the user, because they will expect a single-argument function with environment attached.
- Generate wrapper at runtime
At runtime, we generate a portal - a "function" we'll return to the user, that adds the environment to the normal function call.
For example, if we called the upper-mentioned make-adder
with 32 at runtime, this would happen:
define (make-adder a)
(let ((pointer (malloc this-closure-size))) ; this is lisp pseudocode. this is written by the compiler so the source is plain uxntal
(; but it does exactly that: malloc is used to allocate space after the end of rom for the closure, it gets generated
(generate-and-return-the-function-at pointer) ; and the pointer to it gets returned
pointer))
;; [...]
define (main)
(let ((func (make-adder 3)))
(...))
;; [...]
define (@@gensym__1 a b) ; <- this is the internal make-adder lambda, generated at compile-time
(+ a b))
(
;;; -- [end of rom]
;; The following will be uxntal pseudocode generated at runtime on the call (make-adder 3)
00 20 ; push 0x0020 (32) as the 1st argument (we call function passing arguments with reverse order so this being evaluated)
LIT2 ; as last makes it the first argument
; push the lambda without environment - @@gensym__1 will of course be resolved to a place in memory
LIT2 @@gensym__1 ; jump to this function (not JSR2 to save space - @@gensym__1 will return back to the original caller) JMP2
Then if func
was to be called with 10
, the
program counter would firstly land on the freshly generated closure
code, 0x0020
would be pushed and @@gensym__1
would be called with 32
and 10
as
a
and b
.
Example
This is a small example of a gui program that draws hearts in random spots.
define *width* 400)
(define *height* 300)
(
(alloc! *heart-texture*#b01000010
#b11100111
#b11111111
#b11111111
#b01111110
#b00111100
#b00011000
#b00000000)
(defvar heart!)
define (make-drawer sprite)
(
(λ (x y)0 layer-1 0 0 1)))
(sprite! x y sprite
define (main)
(
(set-draw-handler! draw)#x0f00 #x00f0 #x000f)
(set-colors!
(set-screen-width! *width*)
(set-screen-height! *height*)
set! heart! (make-drawer *heart-texture*)))
(
(define-vector (draw)let ((x (modulo (rand) *width*))
(modulo (rand) *height*)))
(y ( (heart! x y)))
make-drawer
returns a closure, we'll take a closer look
at what the generated code for it looks like. This is a decompilation
spat out by nienor -OD
with some extra commentary on
top.
( defun normal make-drawer (sprite) )
( label: make-drawer )
( _with-locals! (sprite) ;; locals = (sprite) )
|06af a0 01 0b ( )
|06b2 ae ( JSR2k )
|06b3 22 ( POP2 )
( label: make-drawer__skip-prologue )
( λ CLOSURE @@gensym__7 [ (sprite) ] (x y) )
( here we begin generating the closure )
( push closure size ) |06b4 a0 00 07 ( )
( push malloc ) |06b7 a0 06 41 ( )
( get place in memory ) |06ba 2e ( JSR2 )
( for the function from malloc )
|06bb 26 ( DUP2 )
|06bc 80 a0 ( )
|06be 05 ( ROT )
|06bf 05 ( ROT )
( append LIT to the ) |06c0 95 ( STAk )
( new function )
|06c1 05 ( ROT )
|06c2 02 ( POP )
|06c3 21 ( INC2 )
|06c4 80 00 ( )
|06c6 10 ( LDZ )
|06c7 80 00 ( )
|06c9 19 ( SUB )
|06ca 80 02 ( )
|06cc 1a ( MUL )
( load the local we're) |06cd 30 ( LDZ2 )
( saving from the zero-page - nienor stores locals in the zero-page [memory addresses from 0 to 255] )
|06ce 24 ( SWP2 )
( append the local to ) |06cf b5 ( STA2k )
( the new function )
|06d0 23 ( NIP2 )
|06d1 21 ( INC2 )
|06d2 21 ( INC2 )
|06d3 80 a0 ( )
|06d5 05 ( ROT )
|06d6 05 ( ROT )
( append another LIT ) |06d7 95 ( STAk )
|06d8 05 ( ROT )
|06d9 02 ( POP )
|06da 21 ( INC2 )
|06db a0 07 7b ( )
|06de 24 ( SWP2 )
( append the full ) |06df b5 ( STA2k )
( lambda location to the new function. here it's called @@gensym__7 and captures one argument [sprite] )
|06e0 23 ( NIP2 )
|06e1 21 ( INC2 )
|06e2 21 ( INC2 )
|06e3 80 2c ( )
|06e5 05 ( ROT )
|06e6 05 ( ROT )
( append JMP2 to the ) |06e7 15 ( STA )
( new function )
( this is the end of the code generation on runtime. the value left on stack is the previously DUP2'd )
( pointer to the new function - the one we got from malloc )
( freeing 1 locals ;; locals = () )
|06e8 80 00 ( )
|06ea 10 ( LDZ )
|06eb 80 01 ( )
|06ed 19 ( SUB )
|06ee 80 00 ( )
|06f0 11 ( STZ )
|06f1 6c ( JMP2r )
So, the code that gets generated at runtime in this case would be:
( |somewhere )
( we're assuming *heart-texture* is at #x110 )
( we're assuming @@gensym__7 is at #x500 )
LIT2 01 10 ( push *heart-texture* )
LIT2 05 00 ( push @@gensym__7 )
JMP2 ( jump to @@gensym__7 )
This is the entirety of the portal that we'll return to the user.
malloc?
I've implemented malloc
& free
to
manually manage the memory left in the RAM after the ROM. They are also
used to allocate closures - this means that when the user is done with a
closure, they can simply free
it to return unused memory to
the pool.
For example:
define (make-closure n)
(
(λ () n))
define (main)
(let ((f1 (make 1))
(2)))
(f2 (make
(print-number f1)
(print-number f2)
(free f1)let ((f3 (make 3)))
( (print-number f3))))
This program yields:
1811
1820
1811
Because memory used by f1 (1811) got freed, f3 got allocated in the same spot (also 1811).
epilogue
This has not been battle-tested yet and is (of course) purely experimental. Thank you for reading. If I've sparked your interest, you can download niënor and follow its development here.