I had a use case for units, a rarely-used feature of Racket, this
week. I ran into units before when hacking on the web server, so I knew what they were and
how they worked, but I had never had occasion to write my own unit
before, except to implement the tcp@
signature.
Basically, units let you write code that depends on bindings whose concrete implementations are filled in at a later point in time. To specify the inputs and outputs of a unit, we use signatures:
(define-signature x^
(x))
The example above declares a signature containing one binding, x
.
Depending on how the signature is used, x
may be an import to a unit,
or an export of a unit. We can define a unit, printer@
that takes the
x^
signature as input and exports a procedure to print the value of
x
as follows:
(define-signature printer^
(print-x))
(define-unit printer@
(import x^)
(export printer^)
(define (print-x)
(println x)))
What this is saying is that once the unit printer@
is invoked, it will
provide a procedure named print-x
that refers to whatever binding of
x
is available in scope at invocation time. To invoke the unit, we
can use define-values/invoke-unit
:
(define x 42) ;; provide a definition for `x` to be used by the unit
(define-values/invoke-unit printer@
(import x^)
(export doer^))
(print-x) ;; prints 42
In Congame, we have a DSL for building studies called #lang conscript
which provides some syntactic conveniences on top of
racket/base
. To run a Conscript study, you need a Congame server,
which can be a pain to set up, especially for students. So, we have
another language called #lang conscript/local
, which re-provides the
bindings from #lang conscript
, replacing some of them so that the
whole thing works using a stand-alone web server that doesn't require a
running Postgres database. My use case for units was to write a generic
implentation of matchmaking that is reusable between the
two languages.
The process was straightforward. I wrote a signature for the things that a Conscript-like language provides:
(define-signature conscript^
(get-var put-var undefined? call-with-study-transaction)) ;; among others
Then, I wrote the signature for the matchmaking implementation:
(define-signature matchmaking^
(get-ready-groups get-current-group matchmake))
Then, I implemented the unit:
(define-unit matchmaking@
(import conscript^)
(export matchmaking^)
(define (get-ready-groups) ...)
(define (get-current-group) ...)
(define (matchmake group-size) ...))
Finally, I instantiated it once using the bindings provided by #lang conscript
:
#lang conscript
(require "matchmaking-sig.rkt"
"matchmaking-unit.rkt")
(provide (all-defined-out))
(define-values/invoke-unit matchmaking@
(import conscript^)
(export matchmaking^))
And then I did the same for the conscript/local
version in a different
module. The only difference between the two modules is the #lang
line. The end result is that users of the two languages can import the
respective matchmaking module for their #lang
and get implementations
that work for their environment, and I get to maintain a single generic
implementation between the two languages.