Skip to main content
  1. Books/
  2. From Interpreter To Compiler In Zig/
Chapter 7

Compiling Expressions

8 mins
On this page
Table of Contents

We extend the compiler and VM to handle all expression types: infix operators (+, -, *, /, ==, !=, >, <), boolean literals (true, false), and prefix operators (-, !). Follow these steps in order.


New opcodes
#

Extend the Opcode enum in code.zig:

pub const Opcode = enum(u8) {
    op_constant = 0,
    op_add = 1,
    op_pop = 2,
    op_sub = 3,
    op_mul = 4,
    op_div = 5,
    op_true = 6,
    op_false = 7,
    op_equal = 8,
    op_not_equal = 9,
    op_greater_than = 10,
    op_minus = 11,
    op_bang = 12,
};

All new opcodes have zero operands. Update lookup:

pub fn lookup(op: Opcode) Definition {
    return switch (op) {
        .op_constant => .{ .name = "OpConstant", .operand_widths = &[_]u8{2} },
        .op_add => .{ .name = "OpAdd", .operand_widths = &[_]u8{} },
        .op_pop => .{ .name = "OpPop", .operand_widths = &[_]u8{} },
        .op_sub => .{ .name = "OpSub", .operand_widths = &[_]u8{} },
        .op_mul => .{ .name = "OpMul", .operand_widths = &[_]u8{} },
        .op_div => .{ .name = "OpDiv", .operand_widths = &[_]u8{} },
        .op_true => .{ .name = "OpTrue", .operand_widths = &[_]u8{} },
        .op_false => .{ .name = "OpFalse", .operand_widths = &[_]u8{} },
        .op_equal => .{ .name = "OpEqual", .operand_widths = &[_]u8{} },
        .op_not_equal => .{ .name = "OpNotEqual", .operand_widths = &[_]u8{} },
        .op_greater_than => .{ .name = "OpGreaterThan", .operand_widths = &[_]u8{} },
        .op_minus => .{ .name = "OpMinus", .operand_widths = &[_]u8{} },
        .op_bang => .{ .name = "OpBang", .operand_widths = &[_]u8{} },
    };
}

The less-than trick
#

There is no OpLessThan. The compiler handles a < b by reordering the operands and using OpGreaterThan. Instead of a < b, it compiles b > a. One fewer opcode to worry about in the VM.


Step 2: update compiler.zig
#

Update compileExpression in compiler.zig. Replace the existing .infix case (which only handled +) and add new cases for .boolean and .prefix.

Infix expressions
#

Replace the .infix case in compileExpression with:

.infix => |inf| {
    // Special case: < (less than) — swap operands, use greater-than
    if (std.mem.eql(u8, inf.operator, "<")) {
        try self.compileExpression(inf.right.*);  // compile right FIRST
        try self.compileExpression(inf.left.*);   // then left
        _ = try self.emit(.op_greater_than, &[_]usize{});
        return;
    }

    // Normal case: compile left, then right
    try self.compileExpression(inf.left.*);
    try self.compileExpression(inf.right.*);

    if (std.mem.eql(u8, inf.operator, "+")) {
        _ = try self.emit(.op_add, &[_]usize{});
    } else if (std.mem.eql(u8, inf.operator, "-")) {
        _ = try self.emit(.op_sub, &[_]usize{});
    } else if (std.mem.eql(u8, inf.operator, "*")) {
        _ = try self.emit(.op_mul, &[_]usize{});
    } else if (std.mem.eql(u8, inf.operator, "/")) {
        _ = try self.emit(.op_div, &[_]usize{});
    } else if (std.mem.eql(u8, inf.operator, "==")) {
        _ = try self.emit(.op_equal, &[_]usize{});
    } else if (std.mem.eql(u8, inf.operator, "!=")) {
        _ = try self.emit(.op_not_equal, &[_]usize{});
    } else if (std.mem.eql(u8, inf.operator, ">")) {
        _ = try self.emit(.op_greater_than, &[_]usize{});
    } else {
        return error.UnknownOperator;
    }
},

Booleans
#

Add this case to compileExpression in compiler.zig:

.boolean => |b| {
    if (b.value) {
        _ = try self.emit(.op_true, &[_]usize{});
    } else {
        _ = try self.emit(.op_false, &[_]usize{});
    }
},

Booleans are not stored in the constant pool. They get dedicated opcodes (OpTrue, OpFalse) because there are only two possible values.

Prefix expressions
#

Add this case to compileExpression in compiler.zig:

.prefix => |p| {
    try self.compileExpression(p.right.*);

    if (std.mem.eql(u8, p.operator, "-")) {
        _ = try self.emit(.op_minus, &[_]usize{});
    } else if (std.mem.eql(u8, p.operator, "!")) {
        _ = try self.emit(.op_bang, &[_]usize{});
    } else {
        return error.UnknownOperator;
    }
},

Step 3: update vm.zig
#

Constant boolean objects
#

Add these at the top of vm.zig (module-level, outside the VM struct):

const TRUE = object.Object{ .boolean = .{ .value = true } };
const FALSE = object.Object{ .boolean = .{ .value = false } };
const NULL = object.Object{ .null = .{} };

Arithmetic operations
#

Add this method to the VM struct:

fn executeBinaryOperation(self: *VM, op: code.Opcode) !void {
    const right = self.pop();
    const left = self.pop();

    if (left == .integer and right == .integer) {
        const result = switch (op) {
            .op_add => left.integer.value + right.integer.value,
            .op_sub => left.integer.value - right.integer.value,
            .op_mul => left.integer.value * right.integer.value,
            .op_div => @divTrunc(left.integer.value, right.integer.value),
            else => return error.UnknownOperator,
        };
        try self.push(.{ .integer = .{ .value = result } });

        return;
    }

    return error.UnsupportedTypes;
}

Comparison operations
#

Add this method to the VM struct:

fn executeComparison(self: *VM, op: code.Opcode) !void {
    const right = self.pop();
    const left = self.pop();

    if (left == .integer and right == .integer) {
        const result = switch (op) {
            .op_equal => left.integer.value == right.integer.value,
            .op_not_equal => left.integer.value != right.integer.value,
            .op_greater_than => left.integer.value > right.integer.value,
            else => return error.UnknownOperator,
        };
        try self.push(if (result) TRUE else FALSE);
        return;
    }

    if (left == .boolean and right == .boolean) {
        const result = switch (op) {
            .op_equal => left.boolean.value == right.boolean.value,
            .op_not_equal => left.boolean.value != right.boolean.value,
            else => return error.UnknownOperator,
        };
        try self.push(if (result) TRUE else FALSE);
        return;
    }

    return error.UnsupportedTypes;
}

Prefix operations
#

Add these methods to the VM struct:

fn executeMinus(self: *VM) !void {
    const operand = self.pop();
    if (operand != .integer) return error.UnsupportedType;
    try self.push(.{ .integer = .{ .value = -operand.integer.value } });
}

fn executeBang(self: *VM) !void {
    const operand = self.pop();
    switch (operand) {
        .boolean => |b| try self.push(if (b.value) FALSE else TRUE),
        .null => try self.push(TRUE),
        else => try self.push(FALSE),
    }
}

Updated run loop
#

Replace the run method in the VM struct with this expanded version that handles all new opcodes:

pub fn run(self: *VM) !void {
    var ip: usize = 0;

    while (ip < self.instructions.len) {
        const op: code.Opcode = @enumFromInt(self.instructions[ip]);

        switch (op) {
            .op_constant => {
                const idx = std.mem.readInt(u16, self.instructions[ip + 1 ..][0..2], .big);
                ip += 3;
                try self.push(self.constants[idx]);
            },
            .op_add, .op_sub, .op_mul, .op_div => {
                try self.executeBinaryOperation(op);
                ip += 1;
            },
            .op_equal, .op_not_equal, .op_greater_than => {
                try self.executeComparison(op);
                ip += 1;
            },
            .op_true => {
                try self.push(TRUE);
                ip += 1;
            },
            .op_false => {
                try self.push(FALSE);
                ip += 1;
            },
            .op_minus => {
                try self.executeMinus();
                ip += 1;
            },
            .op_bang => {
                try self.executeBang();
                ip += 1;
            },
            .op_pop => {
                _ = self.pop();
                ip += 1;
            },
        }
    }
}

Step 4: update tests in vm_test.zig
#

Replace the “integer arithmetic” test from chapter 6 with this expanded version, and add the boolean test. These use the same testVMRun helper.

test "integer arithmetic" {
    const allocator = std.testing.allocator;

    const tests = [_]struct { input: []const u8, expected: i64 }{
        .{ .input = "1", .expected = 1 },
        .{ .input = "2", .expected = 2 },
        .{ .input = "1 + 2", .expected = 3 },
        .{ .input = "1 - 2", .expected = -1 },
        .{ .input = "1 * 2", .expected = 2 },
        .{ .input = "4 / 2", .expected = 2 },
        .{ .input = "50 / 2 * 2 + 10 - 5", .expected = 55 },
        .{ .input = "5 + 5 + 5 + 5 - 10", .expected = 10 },
        .{ .input = "2 * 2 * 2 * 2 * 2", .expected = 32 },
        .{ .input = "5 * 2 + 10", .expected = 20 },
        .{ .input = "5 + 2 * 10", .expected = 25 },
        .{ .input = "5 * (2 + 10)", .expected = 60 },
        .{ .input = "-5", .expected = -5 },
        .{ .input = "-10", .expected = -10 },
        .{ .input = "-50 + 100 + -50", .expected = 0 },
        .{ .input = "(5 + 10 * 2 + 15 / 3) * 2 + -10", .expected = 50 },
    };

    for (tests) |tt| {
        var arena = std.heap.ArenaAllocator.init(allocator);
        defer arena.deinit();
        const result = try testVMRun(&arena, tt.input);
        try std.testing.expectEqual(tt.expected, result.integer.value);
    }
}

test "boolean expressions" {
    const allocator = std.testing.allocator;

    const tests = [_]struct { input: []const u8, expected: bool }{
        .{ .input = "true", .expected = true },
        .{ .input = "false", .expected = false },
        .{ .input = "1 < 2", .expected = true },
        .{ .input = "1 > 2", .expected = false },
        .{ .input = "1 < 1", .expected = false },
        .{ .input = "1 > 1", .expected = false },
        .{ .input = "1 == 1", .expected = true },
        .{ .input = "1 != 1", .expected = false },
        .{ .input = "1 == 2", .expected = false },
        .{ .input = "1 != 2", .expected = true },
        .{ .input = "true == true", .expected = true },
        .{ .input = "false == false", .expected = true },
        .{ .input = "true == false", .expected = false },
        .{ .input = "true != false", .expected = true },
        .{ .input = "(1 < 2) == true", .expected = true },
        .{ .input = "(1 < 2) == false", .expected = false },
        .{ .input = "(1 > 2) == true", .expected = false },
        .{ .input = "(1 > 2) == false", .expected = true },
        .{ .input = "!true", .expected = false },
        .{ .input = "!false", .expected = true },
        .{ .input = "!5", .expected = false },
        .{ .input = "!!true", .expected = true },
        .{ .input = "!!false", .expected = false },
        .{ .input = "!!5", .expected = true },
    };

    for (tests) |tt| {
        var arena = std.heap.ArenaAllocator.init(allocator);
        defer arena.deinit();
        const result = try testVMRun(&arena, tt.input);
        try std.testing.expectEqual(tt.expected, result.boolean.value);
    }
}

Verify it works
#

Run zig build test. No output means all tests passed.


In chapter 8 we add conditional expressions (if/else) with jump instructions, the first time the compiler needs to emit instructions whose targets aren’t known yet.