Metaprogramming

Programs as data

Many real-world programs make very extensive use of records. After writing out a few sets of definitions to implement record types, however, Scheme programmers generally make an important discovery: Typing out these definitions is boring. They have a very predictable form -- only the name of the type and the number, names, and types of the fields change from one kind of record to another -- and yet one has to sit down and type out a dozen or so procedures each time one wants to use a new kind of record. The process soon becomes tedious and error-prone.

The solution to this problem is metaprogramming: the creation of procedures and programs that automatically construct the definitions of other procedures and programs. Metaprogramming automates some of the tedious and error-prone parts of the programmer's job. Scheme is particularly well suited to metaprogramming because of the fact that definitions and commands in Scheme have the same form as data: They are trees in which the leaves include such symbols as lambda, if, and cons, together with literal constants of various sorts.

For instance, if we wanted to build a datum that would look just like the Scheme definition

(define square
  (lambda (n)
    (* n n)))

we could do it simply by using the list procedure to collect the right symbols in the right ways:

(list 'define 'square
      (list 'lambda (list 'n)
            (list '* 'n 'n)))

Exercise 1

Evaluate the preceding expression to confirm that its value is a datum that looks just like the definition of square.


Exercise 2

Write a similar Scheme expression that, when evaluated, yields a datum that looks just like the following definition of the singleton procedure:

(define singleton
  (lambda (element)
    (cons element null)))

It's not much more difficult to metaprogram a procedure that will take a symbol that is the name of a record type and a list of symbols that are the names of its fields, and returns a datum that looks just like a constructor procedure:

(define constructor-maker
  (lambda (record-name . field-names)
    (let ((record-string (symbol->string record-name))
          (vector-size (+ (length field-names) 1)))
      (let ((constructor-name
             (string->symbol (string-append "make-" record-string)))
            (type-marker-name
             (string->symbol (string-append "produce-" record-string "-mark")))
            (mutator-name
             (lambda (field)
               (string->symbol (string-append record-string
                                              "-"
                                              (symbol->string field)
                                              "-set!")))))
        (let ((mutators (let kernel ((rest field-names))
                          (if (null? rest)
                              null
                              (cons (list (mutator-name (car rest))
                                          'result
                                          (car rest))
                                    (kernel (cdr rest)))))))
          (list 'define
                constructor-name
                (list 'lambda
                      field-names
                      (cons 'let
                            (cons (list 'result
                                        (list 'make-vector vector-size))
                                  (cons (list 'vector-set!
                                              'result
                                              0
                                              (list type-marker-name))
                                        (append mutators
                                                (list 'result))))))))))))

Here's what happens when you invoke it:

> (constructor-maker 'star 'name 'right-ascension 'declination
                                  'visual-magnitude 'spectral-type)
(define make-star
  (lambda (name right-ascension declination visual-magnitude spectral-type)
    (let (result (make-vector 6))
      (vector-set! result 0 (produce-type-mark))
      (star-name-set! result name)
      (star-right-ascension-set! result right-ascension)
      (star-declination-set! result declination)
      (star-visual-magnitude-set! result visual-magnitude)
      (star-spectral-type-set! result spectral-type)
      result)))

If we write this datum-that-looks-like-a-definition to a file and subsequently load that file as if it contained a Scheme program, neither DrScheme nor any human reader can tell that it is ``only a datum''! It is, in fact, indistinguishable from a definition that a human programmer might have written and placed in that same file.

We can similarly metaprogram all the other components of the implementation of a record type. We can even collect them into a procedure -- let's call it generate-record-definition-file -- that takes arguments giving the record name and, for each field, the field name, the identifiers for the precondition predicate, identity tester, and copier, and writes the entire collection of procedure definitions (the constructor, the selectors, the mutators, and the type predicate) into an appropriately named file. If we can write this metaprogram once, we won't have to write our own definitions for record types ever again. Instead, we'll just do something like this:

> (generate-record-definition-file 'compound
    (list 'name 'string? 'string-ci=? 'copy-string)
    (list 'formula 'string? 'string=? 'copy-string)
    (list 'molecular-weight 'positive-real?  '= 'identity)
    (list 'melting-point 'Celsius-temperature? '= 'identity)
    (list 'boiling-point 'Celsius-temperature? '= 'identity)
    (list 'color 'symbol? 'eq? 'identity))
> (load "compound-definition.ss")
> (define sample
    (make-compound "gadolinium iodide"
                   "GdI3"
                   537.96
                   926
                   1340
                   'yellow))

Actually, for this to work, we must either define positive-real? and Celsius-temperature? interactively or add those definitions to compound-definition.ss before loading it:

(define positive-real?
  (lambda (something)
    (and (real? something) (positive? something))))

(define Celsius-temperature?
  (let ((absolute-zero -273.15))
    (lambda (something)
      (and (real? something)
           (<= absolute-zero something)))))

The symbol 'identity, on the other hand, means that no special copying procedure is needed; the copier-maker in record-builder.ss recognizes this as a special case and generates appropriate code for it.

Quasiquotation

Scheme provides another kind of construction that makes metaprogramming easier: the quasiquotation. Ordinary quotation converts a symbol, a list, or a vector into a Scheme datum by setting up a barrier to evaluation -- the single quotation mark tells the Scheme expression evaluator not to go to work on the subexpression to which it is prefixed. The quasiquotation mark (a backquote, ` -- you'll find this character in the upper left-hand corner of the keyboard) also converts a symbol, a list, or a vector into a datum, but its message to the Scheme expression evaluator is more subtle: It prohibits the evaluation of any subexpression except one that is immediately preceded by a comma or by the two-character prefix ,@ (``comma-at''). For a subexpression that is preceded by a comma or comma-at, evaluation is switched back on again, and the result of the evaluation is inserted into the datum at the point at which the subexpression occurs.

Here are some simple examples of quasiquotations:

> `(The sum of 7 and 5 is ,(+ 7 5))
(the sum of 7 and 5 is 12)

The quasiquote marks the list that it is attached to as a datum, turning off evaluation; the comma turns evaluation back on again for the subexpression (+ 7 5).

> (let ((sum (+ 7 5)))
    `(The sum of 7 and 5 is ,sum))
(the sum of 7 and 5 is 12)

> (let ((name (string #\B #\o #\b)))
    `(My name is ,name))
(my name is "Bob")

> (reverse `(a b ,(reverse '(c d)) e))
(e (d c) b a)

If you change the quasiquote in the last example to a quote, you naturally get no evaluation within the argument to reverse:

> (reverse '(a b ,(reverse '(c d)) e))
(e ,(reverse '(c d)) b a)

The difference between a comma that switches evaluation back on and the comma-at switch is that the value that results from a comma-at evaluation, which must be a list, is spliced into the context in which it occurs, instead of just being inserted as a list element:

> (define full-name '(Robert Calvin Makkai))

> `(My full name is ,full-name and I like it)
(my full name is (robert calvin makkai) and i like it)

> `(My full name is ,@full-name and I like it)
(my full name is robert calvin makkai and i like it)

As a more realistic example, here is how to write the constructor-maker procedure with the help of quasiquotation:

(define constructor-maker
  (lambda (record-name . field-names)
    (let ((record-string (symbol->string record-name))
          (vector-size (+ (length field-names) 1)))
      (let ((constructor-name
             (string->symbol (string-append "make-" record-string)))
            (type-marker-name
             (string->symbol (string-append "produce-" record-string "-mark")))
            (mutator-name
             (lambda (field)
               (string->symbol (string-append record-string
                                              "-"
                                              (symbol->string field)
                                              "-set!")))))
        (let ((mutators (let kernel ((rest field-names))
                          (if (null? rest)
                              null
                              (cons `(,(mutator-name (car rest))
                                      result
                                      ,(car rest))
                                    (kernel (cdr rest)))))))
            `(define ,constructor-name
               (lambda ,field-names
                 (let ((result (make-vector ,vector-size)))
                   (vector-set! result 0 (,type-marker-name))
                   ,@mutators
                   result))))))))

The quasiquotation acts as a template; one fills in the template with the symbols and lists that are passed to constructor-maker as parameters or computed in the binding specifications of the let-expressions. A comma marks a position in the template that is to be filled in with the value of one of these identifiers; a comma-at marks a position at which a list that is associated with one of those identifiers is to be spliced in.


Exercise 3

Write a procedure selector-maker that takes three arguments -- a symbol giving the name of the record type, a symbol giving the name of a field, and an integer giving the position of the field in the vector that implements it -- and returns a datum that looks just like a selector for that field.


Exercise 4

The file /home/stone/courses/scheme/examples/record-builder.ss contains a definition of the generate-record-definition-file procedure.

Part (a)

Load this file into DrScheme and call the generate-record-definition-file procedure with appropriate arguments to construct a shirt record type, with five fields: catalog number, size, color, price, and quantity in stock. (The procedure presupposes that all fields are mutable.)

Part (b)

Inspect the file that generate-record-definition-file created in the preceding exercise; it will be called shirt-definition.ss.

Part (c)

Load the shirt-definition.ss file and use the constructor procedure defined in it to create an inventory record for a shirt with catalog number 03862A, size XL, white, priced at $21.95, with six in stock.


Exercise 5

A projection function is a procedure that returns one of its arguments and ignores all of the others. Here, for instance, is the definition of a projection function that returns the next-to-last of its seven arguments:

(define position-5-of-7
  (lambda (p0 p1 p2 p3 p4 p5 p6)
    p5))

Write a metaprocedure projection-function-maker that takes two arguments -- a positive integer argument-count and a natural number position that must be less than argument-count -- and returns a datum that looks just like the definition of a projection function that has argument-count arguments and returns the one in the specified (zero-based) position.

> (projection-function 7 5)
(define position-5-of-7
  (lambda (p0 p1 p2 p3 p4 p5 p6)
    p5))

Exercise 6

Puzzle (for entertainment only): Write a Scheme procedure call that, when evaluated, yields a datum that looks just like itself. Hint: Use a lambda-expression to identify the procedure that will be invoked.


This document is available on the World Wide Web as

http://www.cs.grinnell.edu/courses/Scheme/metaprogramming.xhtml

created April 29, 1997
last revised April 25, 2000

John David Stone (stone@cs.grinnell.edu)