⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1516+8a4b5424dWhat's newOn an older Zig?

Functions and Frames

The interpreter so far has one set of variables. Adding functions breaks that immediately: two calls to the same function must not share n, and a recursive call has to have several ns alive at once, each belonging to a different call.

So a call needs its own storage. That is a frame, the run-time counterpart of the machine-level idea in The Stack, except here you build it yourself instead of the hardware providing it. Calling pushes one, returning pops it, and the array of them is the call stack.

The second problem is subtler. return can appear anywhere: inside an if, inside a while, inside a block inside both. It has to abandon all of that and land back at the call site. The exec function is recursive, so return needs to unwind an unknown number of Zig frames.

The program

const std = @import("std");
const lex = @import("lexer.zig");
const ast = @import("parser.zig");

pub const Error = error{
    UndefinedVariable,
    UnknownFunction,
    WrongArgCount,
    TooManyVariables,
    DivideByZero,
    StackOverflow,
    WriteFailed,
    /// Not a failure. `return` has to unwind out of however many nested
    /// blocks, loops and ifs it sits inside, and an error is the only
    /// mechanism that walks back up through arbitrary calls. The one place
    /// this language uses an error for control flow, and it is caught by
    /// exactly one caller.
    Return,
};

/// One call's variables. Frame 0 is the globals, which is why a name is looked
/// up here first and there second: locals shadow, and nothing else does.
const Frame = struct {
    names: [16][]const u8 = undefined,
    values: [16]i64 = undefined,
    count: usize = 0,

    fn find(f: *Frame, name: []const u8) ?usize {
        for (f.names[0..f.count], 0..) |candidate, i| {
            if (std.mem.eql(u8, candidate, name)) return i;
        }
        return null;
    }

    fn define(f: *Frame, name: []const u8, value: i64) Error!void {
        if (f.find(name)) |i| {
            f.values[i] = value;
            return;
        }
        if (f.count == f.names.len) return error.TooManyVariables;
        f.names[f.count] = name;
        f.values[f.count] = value;
        f.count += 1;
    }
};

pub const Interpreter = struct {
    nodes: []const ast.Node,
    out: *std.Io.Writer,

    /// Declared functions, by name. Filled in as `fn` statements execute.
    fn_names: [16][]const u8 = undefined,
    fn_nodes: [16]u32 = undefined,
    fn_count: usize = 0,

    /// The call stack, as an array. Depth is bounded here on purpose: the
    /// limit is a number you can point at rather than whatever the host's
    /// stack happened to allow.
    frames: [64]Frame = undefined,
    depth: usize = 0,
    returned: i64 = 0,

    fn frame(vm: *Interpreter) *Frame {
        return &vm.frames[vm.depth];
    }

    fn lookup(vm: *Interpreter, name: []const u8) Error!i64 {
        if (vm.frame().find(name)) |i| return vm.frame().values[i];
        if (vm.frames[0].find(name)) |i| return vm.frames[0].values[i];
        return error.UndefinedVariable;
    }

    fn call(vm: *Interpreter, index: u32) Error!i64 {
        const node = vm.nodes[index];

        var found: ?u32 = null;
        for (vm.fn_names[0..vm.fn_count], 0..) |candidate, i| {
            if (std.mem.eql(u8, candidate, node.name)) found = vm.fn_nodes[i];
        }
        const decl_index = found orelse return error.UnknownFunction;
        const decl = vm.nodes[decl_index];

        // Arguments are evaluated in the *caller's* frame, before the new one
        // exists. Getting this backwards is how a parameter accidentally sees
        // the value it is about to be given.
        var args: [8]i64 = undefined;
        var count: usize = 0;
        var arg = node.first;
        while (arg) |a| : (arg = vm.nodes[a].next) {
            if (count == args.len) return error.WrongArgCount;
            args[count] = try vm.eval(a);
            count += 1;
        }

        if (vm.depth + 1 == vm.frames.len) return error.StackOverflow;
        vm.depth += 1;
        defer vm.depth -= 1;
        vm.frame().* = .{};

        var param = decl.first;
        var bound: usize = 0;
        while (param) |pa| : (param = vm.nodes[pa].next) {
            if (bound == count) return error.WrongArgCount;
            try vm.frame().define(vm.nodes[pa].name, args[bound]);
            bound += 1;
        }
        if (bound != count) return error.WrongArgCount;

        // A function that finishes without returning yields 0. `error.Return`
        // is caught here and nowhere else, which is what makes it a jump to
        // this exact point rather than a failure.
        vm.returned = 0;
        vm.exec(decl.b.?) catch |err| switch (err) {
            error.Return => {},
            else => return err,
        };
        return vm.returned;
    }

    pub fn eval(vm: *Interpreter, index: u32) Error!i64 {
        const node = vm.nodes[index];
        return switch (node.kind) {
            .number => node.value,
            .variable => vm.lookup(node.name),
            .call => vm.call(index),
            .unary => -(try vm.eval(node.a.?)),
            .binary => blk: {
                const left = try vm.eval(node.a.?);
                const right = try vm.eval(node.b.?);
                break :blk switch (node.op) {
                    .plus => left + right,
                    .minus => left - right,
                    .star => left * right,
                    .slash => if (right == 0) error.DivideByZero else @divTrunc(left, right),
                    .lt => @intFromBool(left < right),
                    .eq => @intFromBool(left == right),
                    else => 0,
                };
            },
            else => 0,
        };
    }

    pub fn exec(vm: *Interpreter, index: u32) Error!void {
        const node = vm.nodes[index];
        switch (node.kind) {
            .fn_decl => {
                if (vm.fn_count == vm.fn_names.len) return error.TooManyVariables;
                vm.fn_names[vm.fn_count] = node.name;
                vm.fn_nodes[vm.fn_count] = index;
                vm.fn_count += 1;
            },
            .ret => {
                vm.returned = if (node.a) |value| try vm.eval(value) else 0;
                return error.Return;
            },
            .let, .assign => try vm.frame().define(node.name, try vm.eval(node.a.?)),
            .print => try vm.out.print("{d}\n", .{try vm.eval(node.a.?)}),
            .@"if" => {
                if (try vm.eval(node.a.?) != 0) {
                    try vm.exec(node.b.?);
                } else if (node.c) |other| {
                    try vm.exec(other);
                }
            },
            .@"while" => while (try vm.eval(node.a.?) != 0) {
                try vm.exec(node.b.?);
            },
            .block => {
                var child = node.first;
                while (child) |c| : (child = vm.nodes[c].next) try vm.exec(c);
            },
            else => {},
        }
    }
};

fn run(source: []const u8, out: *std.Io.Writer) !void {
    var tokens: [512]lex.Token = undefined;
    var nodes: [512]ast.Node = undefined;
    const tree = try ast.parse(source, &tokens, &nodes);

    var interp: Interpreter = .{ .nodes = &nodes, .out = out };
    interp.frames[0] = .{};
    try interp.exec(tree.root);
}

fn report(out: *std.Io.Writer, label: []const u8, result: anyerror!void) !void {
    if (result) |_| {
        try out.print("{s} -> no error\n", .{label});
    } else |err| {
        try out.print("{s} -> {t}\n", .{ label, err });
    }
}

pub fn main(init: std.process.Init) !void {
    var buf: [2048]u8 = undefined;
    var stdout_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &stdout_writer.interface;

    try out.writeAll("recursion\n");
    try run(
        \\fn fact(n) {
        \\  if (n < 2) { return 1; }
        \\  return n * fact(n - 1);
        \\}
        \\fn fib(n) {
        \\  if (n < 2) { return n; }
        \\  return fib(n - 1) + fib(n - 2);
        \\}
        \\print fact(5);
        \\print fib(10);
    , out);

    // The parameter is a local, so it shadows the global of the same name and
    // the global is untouched when the call returns.
    try out.writeAll("\nshadowing\n");
    try run(
        \\let x = 100;
        \\fn bump(x) { return x + 1; }
        \\print bump(1);
        \\print x;
    , out);

    try out.writeAll("\nlimits\n");
    try report(out, "recursion with no base case", run("fn loop(n) { return loop(n); } print loop(1);", out));
    try report(out, "calling something undeclared", run("print nope(1);", out));
    try report(out, "too few arguments", run("fn two(a, b) { return a; } print two(1);", out));

    try out.flush();
}

What just happened

fact(5) gave 120 and fib(10) gave 55, which means several ns were alive simultaneously and none of them interfered. fib(10) alone makes 177 calls, and each got a frame, used it, and gave it back.

bump(1) printed 2 and the global x stayed 100. The parameter is defined in the new frame, so it shadows the global rather than overwriting it. Look at lookup: current frame first, globals second, and nothing else. Two lines, and they are the entire scoping rule of this language.

return is implemented as an error. error.Return is not a failure and does not mean anything went wrong. It is the only mechanism Zig offers that unwinds through an arbitrary depth of recursive calls, and it is caught in exactly one place: call, immediately after execing the body. Catching it nowhere else is what turns it from an error into a jump to a known point.

This is a real technique rather than a hack, and interpreters written in languages with exceptions do the same thing with a ReturnException. What makes it safe here is that the error set is written out, so error.Return is visible in every signature that can produce it, and a catch that forgot to handle it would not compile.

Arguments are evaluated before the frame is pushed. They have to be: they are expressions in the caller’s scope. Evaluating them after pushing would let a parameter named n see the new, empty n instead of the caller’s, which is a bug that produces plausible-looking wrong answers rather than a crash.

The stack depth is 64 and the limit is enforced. loop(n) calling itself forever stops with StackOverflow rather than exhausting the host’s stack and taking the whole program down. That is the advantage of building your own call stack: the limit is a number you chose and can report on, instead of a segfault somewhere in the runtime.

Check yourself

fn declarations are registered when the fn statement executes. What happens if a function calls another one that is declared after it?

It works, as long as the call happens after both declarations have run. In the program above, fib refers to itself, and that is fine because by the time print fib(10); runs, the declaration has executed. But a program that called f() at the top and declared f at the bottom would fail with UnknownFunction. Languages that allow that do a separate pass to collect declarations before running anything, which is called hoisting, and it is one more thing a compiler does that an interpreter has to be told to do.

If you have written C

C gives you the frame for free: locals live on the machine stack, and return is an instruction. The work in this chapter is the work the C compiler is doing for you, which is why writing an interpreter is a good way to understand what your compiler emits.

Two comparisons worth drawing. C’s stack depth is whatever the OS gave the thread, and exceeding it is a segfault with no useful message, because nothing is checking. The interpreter above checks on every call, which costs one comparison and buys an error you can report with a line number.

And C’s return genuinely unwinds nothing, because there is nothing to unwind: the frame is popped by adjusting a register. The equivalent of what this chapter does with error.Return is longjmp, which C programs use for exactly this shape of problem and which comes with all the caveats about what is left in an inconsistent state when you jump past it.

Next: a compiler, which stops walking the tree and starts emitting instructions.