Zig NEWS

loading...
Cover image for Crafting an Interpreter in Zig - part 4

Crafting an Interpreter in Zig - part 4

Andres
・5 min read

This is the fourth post of Crafting an Interpreter in Zig, the blog series where I read the book Crafting Interpreters, implement the III part using Zig and highlight some C feature used in the book and compare it to how I did it using Zig. You can find the code here, and if you haven't read the first two parts go read them!

In this chapter, chapter 17, we implement the first version of our compiler-parser. At this point it can only parse numbers and the basic arithmetic operations, but this is the first time we finally see our interpreter working end to end.

This was a long chapter and a lot of things happen in it. We implement a Vaughan Pratt’s Parser and we implement error handling in our compiler.

Let's start with error handling. If something differitates C and Zig is error handling. Error handling in Zig is first class, errors are values that can be returned from a function and must be handled by the caller of the function. In C, errors are handled a bit different, they are not first class values and usually are represented by a specific value of the type a function returns, for example, in C if you return other than 0 from the main function it means there was an error, or if you try to alloc some memory and get NULL back it means an error happened. Let's see it with an example in the book.

InterpretResult interpret(const char* source) {
  Chunk chunk;
  initChunk(&chunk);

  if (!compile(source, &chunk)) {
    freeChunk(&chunk);
    return INTERPRET_COMPILE_ERROR;
  }

  vm.chunk = &chunk;
  vm.ip = vm.chunk->code;

  InterpretResult result = run();

  freeChunk(&chunk);
  return result;
}
Enter fullscreen mode Exit fullscreen mode

In this code we are trying to compile the Lox code. The compile function returns false if there was an error compiling the code or true otherwise. In case there was an error compiling, we return INTERPRET_COMPILE_ERROR from the interpret function, INTERPRET_COMPILE_ERROR is one of the values of the InterpretResult enum.

This way of handling errors is error prone (no pun intended). You have to know which values of the set of possible return values represent errors and which not, and the compiler will not warn you or force you to handle such error. Not only that but resource management becomes complex. See that we have to call freeChunk on error as well as when there is no error.

In Zig the story is a bit different, let's take a look at the equivalent code and see what is going on.

pub fn interpret(self: *Self, source: []const u8, allocator: *Allocator) InterpretError!void {
    var chunk = Chunk.init(allocator);
    defer chunk.deinit();

    compiler.compile(source, &chunk) catch return InterpretError.CompileError;
    self.chunk = &chunk;
    self.ip = 0;

    try self.run();
}
Enter fullscreen mode Exit fullscreen mode

Our function now returns a InterpretError!void, which is an error union, it means that we can either have and error or success, we are telling the caller right away that calling this function might cause an error that you must handle, and not only that, but the compiler will force you to do it. When we call the compile function, instead of it return true or false when there is an error, it returns an error union as well and we have to handle it. In this case we want to convert whatever error we have at our compile stage and covert it in a InterpretError.CompileError. Also it is important to note that to free our resources we only had to call defer chunk.deinit() right after initializing our chunk and it will be called if we return either an error or if we get to the end successfully.

How does our compile function looks?

pub fn compile(source: []const u8, chunk: *Chunk) !void {
    var scanner = Scanner.init(source);
    var parser = Parser.init(&scanner, chunk);
    try parser.advance();
    try parser.expression();

    // Make sure there are no tokens left. if there are, it is an error.
    if (scanner.nextToken()) |_| {
        parser.errAtCurrent("Expect end of expression.");
        return CompileError.CompileError;
    }
    parser.endCompiler();
}
Enter fullscreen mode Exit fullscreen mode

You can see it has a bunch of trys which means we care about the error but don't or can't handle it right now, so we forward it to the caller. Also we can return an error anywhere in our function. In this case if we still have unconsumed tokens at the end of our process we want to return an error. See that parser.errAtCurrent that is one of the things I will say I missed when handling errors in Zig. I missed being able to add context to the errors. I which I could have write something like

return CompileError.CompileError("Expect end of expression.");
Enter fullscreen mode Exit fullscreen mode

but in this case I had to print and "handle" the error even before returning it and probably not in the best possible place. However, as you can see there are work arounds, for example, I could have more specific errors, instead of returning CompileError.CompileError I could have returned CompileError.NoEndOfExpression (very bad name) and handle it accordingly in the caller.

Other interesting thing we did in this chapter was implementing a Pratt Parser. I am not going to try to explain what a Pratt Parser is, I don't fully understand it myself yet, please go read it directly from Crafting Interpreters itself. What I can tell you is that this kind of parser requires a mapping from token types to a struct that we are going to call ParseRule this struct holds two function pointers and a number that indicates the precedence of that rule, very important when parsing infix operations. This is our struct defined in Zig.

const ParseFn = fn (parser: *Parser) anyerror!void;
const ParseRule = struct {
    prefix: ?ParseFn,
    infix: ?ParseFn,
    precedence: Precedence,
}
Enter fullscreen mode Exit fullscreen mode

I really want to note how good this syntax for function types is. It is very clear and resembles how we normally define functions in other contexts.

For comparison this is how the definition of ParseFn is donde in C.

typedef void (*ParseFn)();
Enter fullscreen mode Exit fullscreen mode

I don't know about you, but I couldn't understand what was going on.
Then for the mapping we use a simple switch expression.

    return switch (ty) {
        .LEFT_PAREN => ParseRule.init(Parser.grouping, null, .precNone),
...
        .MINUS => ParseRule.init(Parser.unary, Parser.binary, .precTerm),
        .PLUS => ParseRule.init(null, Parser.binary, .precTerm),
        .SEMICOLON => ParseRule.init(null, null, .precNone),
        .SLASH => ParseRule.init(null, Parser.binary, .precFactor),
        .STAR => ParseRule.init(null, Parser.binary, .precFactor),
        .BANG => ParseRule.init(Parse.unary, null, .precNone),
...
}
Enter fullscreen mode Exit fullscreen mode

I am omitting a lot of the tokens, but what I want to highlight is how simple are the switchs in Zig. I've found myself using switch even in situations where I would normally use an if in any other language, it is very powerful and can be use as a statement and as an expression which is a great feature that feels very modern. Also note that passing a function to be use as a function Pointer is as simple as writing its name. In this case the functions I wanted to use were defined inside of the Parser struct, and to use them as function pointers I just had to think about Parser as a namespace.

Zig truly feels like a modern language, the error handling features are very powerful and really makes us thing twice about when and where errors can happen in our code, making it robust. Zig's syntax while very close to C's syntax improves in some key concepts over C, in this entry of the series we noted two significant improvements, function pointers and switch expressions/statements. We will talk about other features that improve over C syntax in feature posts, spoiler alert tagged unions.

This is all for today, hope you liked it and see you in the next one.

Cover Photo by Ann H from Pexels

Discussion (0)