Skip to content
AyoKoding

Part 3: Environments and Evaluation

With parsing complete, we now have LispVal trees. The evaluator's job is to give them meaning. This part introduces the two foundational concepts that underlie every interpreter: the environment model and the eval/apply mutual recursion.

CS Concept: Two Models of Evaluation

There are two mental models for how a programming language evaluates expressions.

Substitution model — replace names with values before evaluating:

%% Color palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161, Gray #808080
flowchart TB
    S1["(define x 5)\n(+ x 3)"] --> S2["Substitute x → 5\n(+ 5 3)"] --> S3["Result: 8"]
 
    classDef blue fill:#0173B2,color:#fff,stroke:#0173B2
    class S1,S2,S3 blue

Environment model — look up names in an environment chain at runtime:

%% Color palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161, Gray #808080
flowchart TB
    E1["(+ x 3)"]
    E2["Environment\nx → 5"]
    E3["Look up x → 5\nApply + to (5, 3)"]
    E4["Result: 8"]
    E1 --> E3
    E2 --> E3
    E3 --> E4
 
    classDef teal fill:#029E73,color:#fff,stroke:#029E73
    class E1,E2,E3,E4 teal

The substitution model — a name is replaced by its value before evaluation. Intuitive, but breaks down for mutation and closures.

The environment model — maintain a data structure (the environment) that maps names to their current values. Evaluation looks up names in this structure. This is what real interpreters use.

CS Concept: The Environment as a Chain of Frames

An environment is not a single flat dictionary. It is a chain of frames, where each frame is a dictionary of bindings and each frame has a pointer to its enclosing (parent) frame.

%% Color palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161, Gray #808080
flowchart TB
    G["Global Frame\n+ → builtin\n- → builtin\n* → builtin\ndefine → special"]
    L["Local Frame\nn → 5\nx → 10"]
    I["Inner Frame\nz → 15"]
 
    L -->|"parent"| G
    I -->|"parent"| L
 
    classDef blue fill:#0173B2,color:#fff,stroke:#0173B2
    classDef orange fill:#DE8F05,color:#fff,stroke:#DE8F05
    classDef teal fill:#029E73,color:#fff,stroke:#029E73
 
    class G blue
    class L orange
    class I teal

Variable lookup traverses this chain: check the innermost frame first; if not found, check the parent; repeat until the global frame.

%% Color palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161, Gray #808080
flowchart TB
    Q["Look up 'n'"]
    F1{"In Inner\nFrame?"}
    F2{"In Local\nFrame?"}
    F3{"In Global\nFrame?"}
    E["Error:\nUnbound variable"]
    R["Return value"]
 
    Q --> F1
    F1 -->|"yes"| R
    F1 -->|"no"| F2
    F2 -->|"yes"| R
    F2 -->|"no"| F3
    F3 -->|"yes"| R
    F3 -->|"no"| E
 
    classDef blue fill:#0173B2,color:#fff,stroke:#0173B2
    classDef orange fill:#DE8F05,color:#fff,stroke:#DE8F05
    classDef teal fill:#029E73,color:#fff,stroke:#029E73
    classDef brown fill:#CA9161,color:#fff,stroke:#CA9161
 
    class Q blue
    class F1,F2,F3 orange
    class R teal
    class E brown

This chain structure is what implements lexical scope: a function's free variables resolve in the frame where the function was defined, not where it is called.

Implementing the Environment

In Go, the environment is a struct with a map of bindings and a pointer to the parent frame. The chain is formed by following parent pointers — no need for a list of maps as in the F# version.

type Env struct {
    bindings map[string]LispVal
    parent   *Env
}
 
func newEnv(parent *Env) *Env {
    return &Env{bindings: make(map[string]LispVal), parent: parent}
}
 
func (e *Env) lookup(name string) (LispVal, error) {
    if v, ok := e.bindings[name]; ok {
        return v, nil
    }
    if e.parent != nil {
        return e.parent.lookup(name)
    }
    return nil, fmt.Errorf("unbound variable: %s", name)
}
 
func (e *Env) define(name string, val LispVal) {
    e.bindings[name] = val
}
 
func extendEnv(params []string, args []LispVal, parent *Env) *Env {
    env := newEnv(parent)
    for i, param := range params {
        env.define(param, args[i])
    }
    return env
}

The *Env pointer in Lambda (Part 4) is what makes closures work: a lambda captures the *Env at definition time, and extendEnv extends that captured env — not the call-site env — when the lambda is applied.

CS Concept: Eval and Apply

The evaluator is structured as two mutually recursive functions: eval and apply. This pairing — discovered by John McCarthy in 1960 and formalized in SICP — is the canonical architecture for tree-walking interpreters.

%% Color palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161, Gray #808080
flowchart LR
    EVAL["eval(expr, env)"]
    APPLY["apply(proc, args)"]
 
    SE["Self-evaluating\nNumber/Str/Bool\n→ return as-is"]
    SY["Symbol\n→ env.lookup in chain"]
    SF["Special form\ndefine/if/lambda/begin\n→ handled directly"]
    GA["General application\n→ eval head + all args\nthen call apply"]
 
    BI["Builtin\n→ call Go fn directly"]
    LA["Lambda\n→ extend closureEnv\nwith args\n→ eval body"]
 
    EVAL --> SE
    EVAL --> SY
    EVAL --> SF
    EVAL --> GA
 
    GA -->|"calls"| APPLY
    LA -->|"calls back"| EVAL
 
    APPLY --> BI
    APPLY --> LA
 
    classDef blue fill:#0173B2,color:#fff,stroke:#0173B2
    classDef orange fill:#DE8F05,color:#fff,stroke:#DE8F05
    classDef teal fill:#029E73,color:#fff,stroke:#029E73
    classDef purple fill:#CC78BC,color:#fff,stroke:#CC78BC
 
    class EVAL blue
    class APPLY orange
    class SE,SY,SF,GA teal
    class BI,LA purple

Neither eval nor apply is simpler than the other. They are symmetric: eval produces values from expressions; apply produces values from procedures and argument values.

The Evaluator

In Go, pattern matching on the LispVal interface is done with a type switch:

func eval(expr LispVal, env *Env) (LispVal, error) {
    switch e := expr.(type) {
    case Number, Str, Bool:
        return expr, nil
 
    case Symbol:
        return env.lookup(e.Value)
 
    case List:
        if len(e.Values) == 0 {
            return Nil{}, nil
        }
        head := e.Values[0]
        args := e.Values[1:]
 
        // Check for special forms
        if sym, ok := head.(Symbol); ok {
            switch sym.Value {
            case "quote":
                if len(args) != 1 {
                    return nil, fmt.Errorf("quote: expects 1 argument")
                }
                return args[0], nil
            }
        }
 
        // General application
        proc, err := eval(head, env)
        if err != nil {
            return nil, err
        }
        evaluatedArgs := make([]LispVal, len(args))
        for i, arg := range args {
            v, err := eval(arg, env)
            if err != nil {
                return nil, err
            }
            evaluatedArgs[i] = v
        }
        return apply(proc, evaluatedArgs)
 
    default:
        return nil, fmt.Errorf("cannot evaluate: %T", expr)
    }
}
 
func apply(proc LispVal, args []LispVal) (LispVal, error) {
    switch p := proc.(type) {
    case Builtin:
        return p.Fn(args)
    case Lambda:
        if len(p.Params) != len(args) {
            return nil, fmt.Errorf("arity mismatch: expected %d, got %d",
                len(p.Params), len(args))
        }
        extended := extendEnv(p.Params, args, p.Env)
        return eval(p.Body, extended)
    default:
        return nil, fmt.Errorf("not a procedure: %T", proc)
    }
}

Tracing (* (+ 1 2) 4)

%% Color palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161, Gray #808080
sequenceDiagram
    participant C as Caller
    participant EV as eval
    participant AP as apply
 
    C->>EV: List{Symbol*, List{Symbol+, 1, 2}, Number 4}
    EV->>EV: eval Symbol "*" → Builtin multiply
    EV->>EV: eval List{Symbol+, 1, 2}
    note over EV: recursive call for inner expr
    EV->>EV: eval Symbol "+" → Builtin add
    EV->>EV: eval Number 1 → 1
    EV->>EV: eval Number 2 → 2
    EV->>AP: apply Builtin add [1, 2]
    AP-->>EV: Number 3
    EV->>EV: eval Number 4 → 4
    EV->>AP: apply Builtin multiply [3, 4]
    AP-->>EV: Number 12
    EV-->>C: Number 12

The Substitution Model vs Environment Model: Why It Matters

Notice that apply for a Lambda extends p.Env — the environment captured at the time the lambda was created — not the env argument at the call site. This is lexical scope.

Lexical scope (Scheme — correct) — closure captures env at definition site:

%% Color palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161, Gray #808080
flowchart TB
    LD["define f\nas lambda using n"] --> LE["Closure captures env\nwhere lambda was defined\nwhere n is bound"]
 
    classDef teal fill:#029E73,color:#fff,stroke:#029E73
    class LD,LE teal

Dynamic scope (wrong for Scheme) — would look up n in caller's environment:

%% Color palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161, Gray #808080
flowchart TB
    DD["define f\nas lambda using n"] --> DE["Would look up n\nin caller's environment\nunpredictable!"]
 
    classDef brown fill:#CA9161,color:#fff,stroke:#CA9161
    class DD,DE brown

If we extended env (the call-site environment) instead of p.Env, we would get dynamic scope: a function's free variables resolve in the caller's environment. Scheme mandates lexical scope. The Env *Env field in Lambda is what enforces it.

The Global Environment

func makeGlobalEnv() *Env {
    env := newEnv(nil)
 
    numericBinop := func(op func(float64, float64) float64) LispVal {
        return Builtin{Fn: func(args []LispVal) (LispVal, error) {
            if len(args) != 2 {
                return nil, fmt.Errorf("expected 2 arguments, got %d", len(args))
            }
            a, ok1 := args[0].(Number)
            b, ok2 := args[1].(Number)
            if !ok1 || !ok2 {
                return nil, fmt.Errorf("expected two numbers")
            }
            return Number{Value: op(a.Value, b.Value)}, nil
        }}
    }
 
    env.define("+", numericBinop(func(a, b float64) float64 { return a + b }))
    env.define("-", numericBinop(func(a, b float64) float64 { return a - b }))
    env.define("*", numericBinop(func(a, b float64) float64 { return a * b }))
    env.define("/", numericBinop(func(a, b float64) float64 { return a / b }))
 
    env.define("=", Builtin{Fn: func(args []LispVal) (LispVal, error) {
        a, ok1 := args[0].(Number)
        b, ok2 := args[1].(Number)
        if !ok1 || !ok2 {
            return nil, fmt.Errorf("=: expects two numbers")
        }
        return Bool{Value: a.Value == b.Value}, nil
    }})
 
    env.define(">", Builtin{Fn: func(args []LispVal) (LispVal, error) {
        a, ok1 := args[0].(Number)
        b, ok2 := args[1].(Number)
        if !ok1 || !ok2 {
            return nil, fmt.Errorf(">: expects two numbers")
        }
        return Bool{Value: a.Value > b.Value}, nil
    }})
 
    env.define("car", Builtin{Fn: func(args []LispVal) (LispVal, error) {
        lst, ok := args[0].(List)
        if !ok || len(lst.Values) == 0 {
            return nil, fmt.Errorf("car: not a non-empty list")
        }
        return lst.Values[0], nil
    }})
 
    env.define("cdr", Builtin{Fn: func(args []LispVal) (LispVal, error) {
        lst, ok := args[0].(List)
        if !ok || len(lst.Values) == 0 {
            return nil, fmt.Errorf("cdr: not a non-empty list")
        }
        return List{Values: lst.Values[1:]}, nil
    }})
 
    env.define("cons", Builtin{Fn: func(args []LispVal) (LispVal, error) {
        lst, ok := args[1].(List)
        if !ok {
            return nil, fmt.Errorf("cons: second argument must be a list")
        }
        return List{Values: append([]LispVal{args[0]}, lst.Values...)}, nil
    }})
 
    env.define("null?", Builtin{Fn: func(args []LispVal) (LispVal, error) {
        switch v := args[0].(type) {
        case List:
            return Bool{Value: len(v.Values) == 0}, nil
        case Nil:
            return Bool{Value: true}, nil
        default:
            return Bool{Value: false}, nil
        }
    }})
 
    return env
}

Testing the Evaluator So Far

The examples below use a small mustRead helper that panics on parse errors — convenient for inline test expressions:

func mustRead(s string) LispVal {
    v, err := read(s)
    if err != nil { panic(err) }
    return v
}
env := makeGlobalEnv()
 
v, _ := eval(mustRead("42"), env)
// → Number{Value: 42}
 
v, _ = eval(mustRead("(+ 1 2)"), env)
// → Number{Value: 3}
 
v, _ = eval(mustRead("(* (+ 1 2) (- 5 3))"), env)
// → Number{Value: 6}
 
v, _ = eval(mustRead("(car (cons 1 (cons 2 (cons 3 ()))))"), env)
// → Number{Value: 1}

We cannot yet evaluate (define x 10) or (lambda (x) x) — those require special form handling, which is Part 4.

Summary

%% Color palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161, Gray #808080
flowchart TB
    A["LispVal tree\n(from Part 2)"]
    B["eval"]
    C["apply"]
    D["*Env chain\n(struct with parent pointer)"]
    E["LispVal result"]
 
    A --> B
    D --> B
    B <-->|"mutual recursion"| C
    C --> E
 
    classDef blue fill:#0173B2,color:#fff,stroke:#0173B2
    classDef orange fill:#DE8F05,color:#fff,stroke:#DE8F05
    classDef teal fill:#029E73,color:#fff,stroke:#029E73
 
    class A,D blue
    class B,C orange
    class E teal

The environment model maintains a chain of *Env frames mapping names to values. eval and apply form a mutually recursive pair. The closure's captured *Env (not the call-site env) is used when applying a lambda — this is what enforces lexical scope.

In Part 4, we add define, if, lambda, and begin — the forms that make the evaluator Turing-complete and introduce closures.

Last updated April 19, 2026

Command Palette

Search for a command to run...