So far in our implementation, we have made use of the functions
car, cdr and cons to construct
and access LISP data. Now, we will make the same functionality
available within the interpreted environment.
We shall extend the list expression syntax to add some new operators:
(CAR EXPR)
EXPR and returns the car of the
result. It is an error if EXPR does not evaluate to a
pair or NIL.
(CDR EXPR)
EXPR and returns the cdr of the
result. It is an error if EXPR does not evaluate to a
pair or NIL.
(CONS A B)
A and B,
and returns a newly constructed pair containing the results.
In the definitions above we allow taking the car and cdr of
NIL, unlike our Go versions. Some algorithms are simpler to
express if the car and cdr of NIL are defined
to be NIL.
We could choose to implement these by adding more special cases
to eval_expr, just like we did with QUOTE
and DEFINE. However, we will want to add more operators
in the future — and adding each one to eval_expr
would cause the function to get very long. The alternative is to introduce
the concept of functions.
A function is a recipe for converting arguments into a value. If
eval_expr encounters a list expression with a function
as the operator, all it has to do is follow the recipe to come up with
a value to use as the result of the expression.
One way to implement these recipes is to create Go functions which can
be called from eval_expr. We will call these built-in
or primitive functions. Let's see how to extend our LISP
interpreter to accommodate these.
eval_expr will call built-in functions through a C function
pointer, so they must all have the same prototype:
// Builtin is a helper for calling a native Go function.
// We define a struct around it so that we can do
// pointer comparisons for equality in other parts of this package.
type Builtin struct {
fn Native
}
// Native is a function in Go that can evaluate expressions.
type Native func(args Atom) (Atom, error)
In order to appear in expressions, we need a new kind of atom to represent them. We'll add a new enum to our AtomType:
// AtomType is the enum for the type of value in a cell.
type AtomType int
const (
// Nil represents the empty list.
AtomType_Nil AtomType = iota
// Builtin is a native function.
AtomType_Builtin
.
.
.
Sections of code which we wrote previously are abbreviated as vertical ellipsis (". . .").
And then add it to our Atom struct:
p
// AtomValue is the value of an Atom.
// It can be a simple type, like an integer or symbol, or a pointer to a Pair.
type AtomValue struct {
builtin *Builtin
integer int
pair *Pair
symbol *Symbol
}
For completeness, print_expr needs to know how to display the new atom:
// Write writes the value of an Atom to the writer.
// If the atom is a pair, Write is called recursively
// to write out the entire list.
func (a Atom) Write(w io.Writer) (int, error) {
switch a._type {
case AtomType_Nil:
// atom is nil, so write "NIL"
return w.Write([]byte{'N', 'I', 'L'})
case AtomType_Builtin:
// atom is a native function
return w.Write([]byte(fmt.Sprintf("#<BUILTIN:%p>", a.value.builtin)))
.
.
.
And finally a helper function to create atoms of the new type:
// make_builtin returns an Atom on the stack.
func make_builtin(fn Native) Atom {
return Atom{
_type: AtomType_Builtin,
value: AtomValue{
builtin: &Builtin{
fn: fn,
},
},
}
}
We will need to create a shallow copy of the argument list.
// copy_list returns a shallow copy of a list.
// todo: define "shallow copy" and why we would create one.
func copy_list(list Atom) Atom {
if nilp(list) {
return _nil
}
a := cons(car(list), _nil)
p := a
for list = cdr(list); !nilp(list); list = cdr(list) {
setcdr(p, cons(car(list), _nil))
p = cdr(p)
}
return a
}
apply simply calls the builtin function with a supplied
list of arguments. We will extend this function later when we
want to deal with other kinds of evaluation recipe.
// apply calls a native function with a list of arguments and updates the result.
// note that the result may not be updated if we find errors.
func apply(fn, args Atom, result *Atom) error {
if fn._type != AtomType_Builtin {
// it is an error to call this with anything other than a builtin
return Error_Type
}
return fn.value.builtin.fn(args, result)
}
If a list expression is not one of the special forms we defined
previously, then we will assume that the operator is something which
evaluates to a function. We will also evaluate each of the arguments,
and use apply to call that function with the list of
results.
// eval_expr evaluates an expression with a given environment.
// it returns the result and any errors.
func eval_expr(expr, env Atom) (Atom, error) {
.
.
.
op, args := car(expr), cdr(expr)
if op._type == AtomType_Symbol {
.
.
.
}
// evaluate and update the operator
if err := eval_expr(op, env, &op); err != nil {
return err
}
// evaluate arguments by calling eval on a copy of each.
// we have to make the copy, so we don't destroy the input.
args = copy_list(args)
for arg := args; !nilp(arg); arg = cdr(arg) {
// evaluate the arg and update its value
if err := eval_expr(car(arg), env, &arg.value.pair.car); err != nil {
return err
}
}
// return the result of applying eval on our operator and arguments
return apply(op, args, result)
}
The argument list is copied before being overwritten with the results of evaluating the arguments. We don't want to overwrite the original argument list in case we need to use the form again in the future.
Previously we created an empty environment for the read-eval-print loop. The user has no way of creating atoms which represent builtin functions, so we populate the initial environment with bindings for our builtins.
The functions themselves:
// builtin_car makes our native car function available to the interpreter.
// note that the result may not be updated if we find errors.
func builtin_car(args Atom, result *Atom) error {
// verify number and type of arguments
if nilp(args) || !nilp(cdr(args)) {
return Error_Args
} else if car(args)._type != AtomType_Pair {
return Error_Type
}
if nilp(car(args)) {
*result = _nil
} else {
*result = car(car(args))
}
return nil
}
Almost all of the function is code to deal with errors and type checking! Creating functions in this way is pretty tedious.
// builtin_cdr makes our native cdr function available to the interpreter.
// note that the result may not be updated if we find errors.
func builtin_cdr(args Atom, result *Atom) error {
// verify number and type of arguments
if nilp(args) || !nilp(cdr(args)) {
return Error_Args
} else if car(args)._type != AtomType_Pair {
return Error_Type
}
if nilp(car(args)) {
*result = _nil
} else {
*result = cdr(car(args))
}
return nil
}
builtin_cdr is almost identical to builtin_car.
// builtin_cons makes our native cons function available to the interpreter.
// note that the result may not be updated if we find errors.
func builtin_cons(args Atom, result *Atom) error {
// verify number and type of arguments
if nilp(args) || nilp(cdr(args)) || !nilp(cdr(cdr(args))) {
return Error_Args
}
*result = cons(car(args), car(cdr(args)))
return nil
}
With these defined, we can at last use env_set to create
the bindings.
// env_create_default creates a new environment with some native
// functions added to the symbol table.
func env_create_default() Atom {
// create a new environment
env := env_create(_nil)
// add the default list of native functions to the environment
env_set(env, make_sym([]byte("CAR")), make_builtin(builtin_car))
env_set(env, make_sym([]byte("CDR")), make_builtin(builtin_cdr))
env_set(env, make_sym([]byte("CONS")), make_builtin(builtin_cons))
// return the new environment
return env
}
| ID | Input | Output |
|---|---|---|
| 1 | (define foo 1) | FOO |
| 2 | (define bar 2) | BAR |
| 3 | (cons foo bar) | (1 . 2) |
| 4 | (define baz (quote (a b c))) | BAZ |
| 5 | (car baz) | A |
| 6 | (cdr baz) | (B C) |
Notice that (CONS FOO BAR) is not the same as
(QUOTE (FOO . BAR)). In the former expression, the arguments
are evaluated and a new pair is created.
func TestChapter05(t *testing.T) {
env := env_create_default()
for _, tc := range []struct {
id int
input string
expect string
err error
}{
{id: 1, input: "(define foo 1)", expect: "FOO"},
{id: 2, input: "(define bar 2)", expect: "BAR"},
{id: 3, input: "(cons foo bar)", expect: "(1 . 2)"},
{id: 4, input: "(define baz (quote (a b c)))", expect: "BAZ"},
{id: 5, input: "(car baz)", expect: "A"},
{id: 6, input: "(cdr baz)", expect: "(B C)"},
} {
var expr Atom
_, err := read_expr([]byte(tc.input), &expr)
if err != nil {
t.Errorf("%d: read error: want nil: got %v\n", tc.id, err)
continue
}
var result Atom
err = eval_expr(expr, env, &result)
if tc.err == nil && err == nil {
// yay
} else if tc.err == nil && err != nil {
t.Errorf("%d: error: want nil: got %v\n", tc.id, err)
} else if !errors.Is(err, tc.err) {
t.Errorf("%d: error: want %v: got %v\n", tc.id, tc.err, err)
}
if got := result.String(); tc.expect != got {
t.Errorf("%d: eval: want %q: got %q\n", tc.id, tc.expect, got)
}
}
}