leah blogs

January 2024

27jan2024 · Definitions with shared hidden variables in Gerbil Scheme

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

Copyright © 2004–2022