mal: Step 4

project:

mal-py

filename:

step4_if_fn_do.py

read

mal-py step4_if_fn_do.py
import itertools

from core import ns
from env import Env
from reader import S, read_str
from printer import print_form


def read(ins):
    return read_str(ins)

evaluate

To evaluate code we “just” have to switch on the form

mal-py step4_if_fn_do.py
def evaluate(form, env: Env):
    match form:
{{ insert(slots['eval'], indent=8) }}
  • Symbol values are looked up in the environment

    mal-py step4_if_fn_do.py
    case S(name):
        return env.get(name)
    
  • Special case the empty list

    mal-py step4_if_fn_do.py
    case []:
        return []
    
  • Unsurprisingly, a list of terms is where most of the action is

    mal-py step4_if_fn_do.py
    case [*fs]:
        match fs[0]:
    {{ insert(slots['special-form'], indent=8) }}
    

    We dispatch on the first item in the list and treat it accordingly.

  • Finally, everything else (numbers, strings etc.) return as-is

    mal-py step4_if_fn_do.py
    case _:
        return form
    

Special forms

By definition, each special form is an edge case and needs to be handled separately.

def!

def! is how mal spells setq and updates the current environment

mal-py step4_if_fn_do.py
case S('def!'):
    env.set(fs[1].name, v := evaluate(fs[2], env))
    return v

do

Equivalent to progn, evals all terms and returns the last one.

mal-py step4_if_fn_do.py
case S('do'):
    for f in fs[1:]:
        v = evaluate(f, env)
    return v

fn*

a.k.a lambda.

I’m surprised at how simple conceptually it is.

A function definition:

  • Creates a new Env as a child of the current environment, it’s parameters define names that are yet to have values associated with them.

  • When called, these missing values are provided from its arguments and the function then calls evaluate on its body with the new local environment.

mal-py step4_if_fn_do.py
case S('fn*'):
    def fn(*args):
        localenv = Env(outer=env, binds=fs[1], exprs=args)
        return evaluate(fs[2], localenv)
    return fn

Of course, the fact Python already has closures means that it is doing all the heavy lifting!

if

mal considers anything that is not false or nil to be true. Python however, considers other values like 0 to also be False!

This mismatch made implementing if surprinsgly difficult, especially when it likes to coerce 0 to False or False to 0.

The best I have come up with so far is to call print_form on the result and check the string values.

mal-py step4_if_fn_do.py
case S('if'):
    t = print_form(evaluate(fs[1], env))
    if t not in {'false', 'nil'}:
        return evaluate(fs[2], env)
    if len(fs) > 3:
        return evaluate(fs[3], env)
    return None

let*

let* is used to introduce a new local Env with additional bindings.

mal-py step4_if_fn_do.py
case S('let*'):
    letenv = Env(env)
    for binding, expr in itertools.batched(fs[1], n=2):
        letenv.set(binding.name, evaluate(expr, letenv))
    return evaluate(fs[2], letenv)

Non-special forms

If the first symbol isn’t any of the above, then normal semantics apply i.e. all items are evaluated, the first is assumed to be a function and passed the remainder as arguments.

mal-py step4_if_fn_do.py
case _:
    f, *args = [evaluate(f, env) for f in fs]
    return f(*args)

print

mal-py step4_if_fn_do.py
def printit(form):
    return print_form(form)

REPL

rep

mal-py step4_if_fn_do.py
def rep(ins: str, env: Env):
    try:
        return printit(evaluate(read(ins), env))
    except EOFError:
        return f"EOF"
    except KeyError as e:
        return f"{e.args[0]!r} not found"
    except Exception as e:
        print(f"RuntimeError: {e}")
        # import pdb; pdb.post_mortem()

repl

mal-py step4_if_fn_do.py
def repl(ins: io.TextIO):
    env = Env()
    for symb, val in ns.items():
        env.set(symb, val)

    print("user> ", end='', flush=True)
    while (line := ins.readline()) != "":
        print(rep(line, env))
        print("user> ", end='', flush=True)


if __name__ == "__main__":
    import sys
    repl(sys.stdin)