Home

Awesome

Minimal Scheme implemented in Go

gosch is pronounced the same as gosh, as in "oh gosh, why would anyone implement Scheme again?!".

Do It, Do It Again, and Again, and Again ...
  — The Little Schemer by Friedmann and Felleisen

Lisp cycles XKCD #297: "Those are your father's parentheses. Elegant weapons for a more... civilized age."

(source https://xkcd.com/297/)

Oh gosch, it's Scheme

As in classic Lisps, gosch recognizes the two main data structures atoms and pairs (aka linked lists). The variety of available data types is limited to atoms like numbers (integers and floats) and booleans (#t and #f). There is also a limited support for strings. The implementation is properly tail-recursive as required by Scheme. Unlike the classic Scheme, there is null type nil and procedures return it instead of undefined results, for example (if #f 'ok) returns <nil>. There is no distinction between round and square brackets, so they can be used interchangeably. Unlike in some Lisps, all the lists are guaranteed to be proper and there are no dotted pairs. Dots within lists are ignored, so using [dotted pair notation] would not raise errors.

gosch implements the following procedures:

Comments begin with ; and everything that follows, from the semicolon until the end of the line, is ignored.

Details of the Lisp design

  1. Everything is an S-expression.

  2. There are two kinds of S-expressions: atom and pair of S-expressions.

  3. Atoms are the basic data types like booleans, numbers, strings, etc.

  4. Pairs (lists) are implemented as linked lists:

    type Pair struct {
        This Sexpr
        Next *Pair
    }
    

    As in every Lisp, they are written as (this next). The pair has a head and tail, that can be accessed using the car and cdr procedures.

      ( elem1 ( elem2 ( elem3 ( ... ))))
    1    car  └───────── cdr ─────────┘
    2            car  └───── cdr ────┘
    3                    car  └ cdr ┘
    

    Pairs can be empty (), we call them the null lists.

    Accessing the first element of the linked list, removing it, or prepending pair with a new value have O(1) complexity, so those operations would happen the most often in Lisps.

  5. A procedure (function) is also just a pair, where the name of the procedure is the first element of the pair, and the arguments are the following elements. For example, (+ 1 2 3) is a function that calculates the sum of the three numbers.

  6. There is a special kind of atom, a symbol that can be used by itself or as a placeholder.

  7. When evaluating an S-expression, the following rules apply:

    1. a symbol is evaluated by looking up the value it points to in the surrounding environment (see below).
    2. other atoms are evaluated to themselves.
    3. a pair is evaluated by evaluating each of its elements, and then calling the procedure named by the first element of the pair with arguments at the following elements of the pair.
  8. There are some procedures with special rules of evaluation, for example (quote sexpr) returns sexpr without evaluating it; if and cond will evaluate the arguments conditionally; and and or will evaluate the arguments sequentially, possibly stopping before evaluating every argument.

  9. Everything resides within some surrounding environment. By default, this is a global environment, but there are procedures (let, do, and lambda) that can create their environments. For example:

    (define x 3)    ;; define x in global environment
    (let ((y 4))    ;; define y in local environment
         (+ x y))   ;; => 7
    (+ x y)         ;; => ERROR
    

    The local environment has access to its objects, but also to the parent environment, but not the other way around. We call it lexical scoping or closures.

  10. lambda is a special procedure that returns a procedure. It is defined in terms of its arguments and the body of the function to be executed.

    (define add (lambda (x y) (+ x y)))
    (add 2 5)       ;; => 7
    
  11. Some procedures are tail-call optimized, this includes begin, if, cond, let, and lambda. While regular procedures are evaluated by returning the result of the computation, in tail-call optimized procedures the last expression in the body of the procedure is returned unevaluated. This transforms recursive calls into a for-loop. The simplified code below illustrates this:

    func Eval(sexpr Sexpr, env *Env) Any {
        for {
            switch val := sexpr.(type) {
            case Symbol:
                return getSymbol(val, env)
            case *Pair:
                fn := Eval(val.This, env)
                switch fn := fn.(type) {
                case Primitive:
                    return fn(val.Next, env)
                case TailCallOptimized:
                    sexpr, env = fn(val.Next, env)
                }
            default:
                return sexpr
            }
        }
    }
    

That's it. Nothing more is needed to build a minimal Scheme.