Skip to Content
Course content

185: Building a Simple Compiler/Interpreter for a Toy Language

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Look, I've seen a lot of developers freeze up when they hear the word "compiler." They treat it like some mystical art practiced only by PhDs in computer science who enjoy reading 800-page manuals on x86 architecture. This leads to a very specific, very paralyzing misconception: that you can't build a "real" compiler unless you are generating machine code or assembly.

The "Assembly-Only" Myth

Many learners think that if they aren't outputting MOV EAX, 1 or targeting a specific CPU instruction set, they are just writing a "scripting tool" and not a compiler. They assume the jump from a C++ program to a binary executable is a single, giant leap of faith. This mindset makes the task feel impossible because it forces you to learn the entire Intel or ARM manual before you've even figured out how to parse a parenthesis.

Here is why that's wrong: a compiler is simply a pipeline of transformations. It takes Source A and turns it into Representation B. Representation B doesn't have to be machine code; it could be a different high-level language (a transpiler), a custom bytecode for a virtual machine, or even an Abstract Syntax Tree (AST) that you evaluate on the fly. If you're transforming code into something else to execute it, you're doing compiler work. Period.

The Pipeline Reality: Lexing, Parsing, and Evaluating

Instead of worrying about registers and memory offsets, let's focus on the actual logic. To build our toy language—let's call it CalcLang—we need a three-stage pipeline. In CalcLang, we want to support basic variable assignments and arithmetic, like let x = 10; x + 5;.

First, we have the Lexer (or Tokenizer). Its only job is to turn a raw string into a list of "tokens." It doesn't care about grammar; it just knows that "let" is a keyword and "10" is a number. I usually implement this as a simple loop with a pointer moving across the string.

struct Token {
    enum Type { KEYWORD_LET, IDENTIFIER, EQUALS, NUMBER, PLUS, SEMICOLON } type;
    std::string value;
};

Next is the Parser. This is where most people get stuck. They try to write a complex state machine when they should be using Recursive Descent. A recursive descent parser is just a set of functions that call each other to match the grammar of your language. If the parser sees let, it knows it must see an identifier next, then an equals sign, then an expression. If it doesn't, it throws a syntax error.

The parser outputs an Abstract Syntax Tree (AST). I highly recommend using std::unique_ptr for AST nodes to avoid the nightmare of manual memory management. Here is a glimpse of how I'd structure a simple expression node:

struct Node {
    virtual ~Node() = default;
    virtual double evaluate(std::map<std::string, double>& vars) = 0;
};

struct NumberNode : public Node {
    double val;
    NumberNode(double v) : val(v) {}
    double evaluate(std::map<std::string, double>& vars) override { return val; }
};

struct BinaryOpNode : public Node {
    std::unique_ptr<Node> left, right;
    char op;
    double evaluate(std::map<std::string, double>& vars) override {
        if (op == '+') return left->evaluate(vars) + right->evaluate(vars);
        // ... other ops
        return 0;
    }
};

Turning the Tree into Action

Finally, we have the Evaluator (or the Interpreter). In a full compiler, this stage would generate bytecode. In our toy version, we simply "walk" the tree. We pass a std::map around to keep track of our variables. When the evaluator hits a VariableNode, it looks up the name in the map; when it hits a BinaryOpNode, it recursively evaluates the children and adds them together.

This approach decouples the meaning of the code from the syntax of the code. If you decide tomorrow that you want CalcLang to use set instead of let, you only change the Lexer and Parser. The AST and Evaluator remain completely untouched. That's the power of a proper compiler pipeline.




📋 Practical Task

Implementing a Basic Variable Store for CalcLang

You are provided with a skeleton of a Recursive Descent Parser and an AST structure. Currently, the interpreter can handle basic numbers and addition (e.g., 5 + 10), but it crashes or fails when it encounters a variable identifier because the VariableNode::evaluate method is unimplemented.

Your Task:

  1. Implement the VariableNode::evaluate method so that it looks up the variable name in the provided std::map<std::string, double>& symbol_table.
  2. Implement a AssignmentNode::evaluate method that takes a variable name and a value, stores it in the symbol_table, and returns that value.
  3. Test your implementation by passing the string "let x = 50; x + 25;" through the pipeline and ensure the final output is 75.

Constraint: Ensure that if a variable is accessed before it is defined, your evaluator throws a std::runtime_error with a descriptive message.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.