It is well known that a Scheme function definition is merely defining a lambda function to a variable:
(define (hypot a b)
(sqrt (+ (* a a) (* b b))))
This function could be written just as well:
(define hypot
(lambda (a b)
(sqrt (+ (* a a) (* b b)))))
Occasionally, we need this explicit style when a function needs to keep around some internal state:
(define generate-id
(let ((i 0))
(lambda ()
(set! i (+ i 1))
i)))
(generate-id) ;=> 1
(generate-id) ;=> 2
(generate-id) ;=> 3
A problem arises when we need multiple functions to share the state,
let’s say we also want a reset-id
function:
(define i 0)
(define (generate-id)
(set! i (+ i 1))
i)
(define (reset-id)
(set! i 0))
(generate-id) ;=> 1
(generate-id) ;=> 2
(generate-id) ;=> 3
(reset-id)
(generate-id) ;=> 1
(generate-id) ;=> 2
Here, I had to make i
a global variable to allow two toplevel
functions to access it. This is ugly, of course. When you did deeper
into the scheme specs, you may find define-values
, which lets us write:
(define-values (generate-id reset-id)
(let ((i 0))
(values
(lambda ()
(set! i (+ i 1))
i)
(lambda ()
(set! i 0)))))
This hides i
successfully from the outer world, but at what cost.
The programming language Standard ML has a nice feature to write this
idiomatically, namely local
:
local
val i = ref 0
in
fun generate_id() = (i := !i + 1; !i)
fun reset_id() = i := 0
end
Here, the binding of i
is not visible to the outer world as well.
It would be nice to have this in Scheme too, I thought.
Racket provides it,
but the implementation is quite hairy.
Since I mostly use Gerbil Scheme these days, I
thought perhaps I can reuse the module system. In Gerbil,
we could also write:
(module ids
(export generate-id reset-id)
(def i 0)
(def (generate-id)
(set! i (+ i 1))
i)
(def (reset-id)
(set! i 0)))
It is used like this:
(import ids)
(generate-id) ;=> 1
(generate-id) ;=> 2
(generate-id) ;=> 3
(reset-id)
(generate-id) ;=> 1
(generate-id) ;=> 2
So, let’s use the module system of Gerbil (which supports nested
modules) to implement local
. As a first step, we need to write a
macro to define a new module and immediately import it. Since all
Gerbil modules need to have a name, we’ll make up new names using
gensym
:
(import :std/sugar)
(defrule (local-module body ...)
(with-id ((mod (gensym 'mod)))
(begin
(module mod
body ...)
(import mod))))
Here, we use the with-id
macro to transform mod
into a freshly
generated symbol. defrule
is just a shortcut for regular Scheme
syntax-rules
, which guarantees a hygienic macro. We can use the
identifier mod
inside the body without problems:
> (local-module (export mod) (def (mod) 42))
> (mod)
42
> mod
#<procedure #27 mod378#mod>
The internal representation of the function shows us it was defined in
a generated module. Re-running local-module
verifies that fresh
modules are generated.
Now to the second step. We want a macro that takes local bindings and
a body and only exports the definitions of the body. Using the Gerbil
export modifier except-out
, we can export everything but the local
bindings; this is good enough for us.
Thanks to the power of syntax-rules
, this is now straight forward to
state:
(defrule (local ((var val) ...) body ...)
(local-module
(export (except-out #t var ...))
(def var val) ...
body ...))
We rewrite all let-style binding pairs into definitions as well, but we make sure not to export their names.
Now, we can write our running example like this:
(local ((i 0))
(def (generate-id)
(set! i (+ i 1))
i)
(def (reset-id)
(set! i 0)))
(generate-id) ;=> 1
(generate-id) ;=> 2
(generate-id) ;=> 3
(reset-id)
(generate-id) ;=> 1
(generate-id) ;=> 2
But perhaps you prefer the slightly less magical way of using modules directly (which is also what many recommend to do in Standard ML). Still, creating modules by macros for fine-grained scoping is a good trick to have up your sleeve.
[Addendum 2024-01-28:] Drew Crampsie showed me another trick with
nested modules that will help improve the local
macro: when you
import
a module into another, the bindings are not re-exported by
(export #t)
(which means “export all defined identifiers”). (You
can use (export (import: module))
if you really wanted this.)
This means we don’t need the except-out
trick, but we can just use
two nested modules instead:
(defrule (local ((var val) ...) body ...)
(local-module
(local-module
(export #t)
(def var val) ...)
(export #t)
body ...))
The inner module contains all the local bindings, the outer one the visible bindings made in the body. Now, definitions in the body can also shadow local bindings (not that this is very useful):
(local ((var 5))
(def (get)
var)
(def var 6))
(get) ;=> 6
NP: Dead Moon—Play With Fire