Interpreter Project: Statements

Published on May 15, 2018

This section of Crafting Interpreters felt rather long to me, but it's starting to look like a real programming language now. Creating variables, assigning values to variables and variable scopes are all fundamental features of any programming language.

You can see my progress at this point in the project here.

Testing Output

One thing that gave me some trouble during this update was how to effectively test the interpreter. Previously the interpreter was simply executing a single expression at a time. It was easy to modify the interpret method to return the resulting value and use that to validate the code. You can see in the code below that I was just expecting the result of interpreting an expression to be equal to a particular value.

expression = make_expression('2 + 4')
expect(interpreter.interpret(expression)).to eq('6.0')

Now the interpreter executes a whole program. It parses all of the source code and executes the statements one at a time and does not return a value.

After a little searching I found that RSpec allows you to capture STDOUT and STDERR and match against the output. I never knew about that feature.

With that capability I could just sprinkle print statements into the source code being tested and validate the output.

statements = make_statements('print 2 + 4;')
expect{subject.interpret(statements)}.to output("6.0\n").to_stdout

Scopes

I'm not sure I like how scopes are handled. Right now when the interpreter is created a top level environment is created. The Environment class is just a thin wrapper around a Ruby Hash. Defining a variable sets the variable name as the key and its value as the hash value.

This is fine for a global scope. That single environment holds all of the global variables. However, when the following code is executed:

var x = 20;
{
  var x = 1.0;
  var y = 2.9;
  print x + y;
}

There is a new scope created inside the block. So the x variable inside the block is found and used in the print statement.

The way this is handled in the interpreter code is the top level environment is stored in a backup variable and the local scope is made the top level environment while the block statements are executed. Finally, when the block is complete the backed up top level environment is set as the current environment again. Perhaps the code will make is more clear.

# Found a block of source code, execute the block with a new
# environment. The existing environment is passed in as a
# parent environment.
def visit_block(block)
  execute_block(block.statements, Ringo::Environment.new(@environment)
  return nil
end

...

def execute_block(statements, environment)
  # Save the current environment.
  previous = @environment

  begin
    # The environment created for the block overrides the
    # class variable.
    @environment = environment

    # Execute the block statements.
    statements.each do |statement|
      execute(statement)
    end
  ensure
    # Reassign the previous block back to the class variable.
    @environment = previous
  end
end

It seems messy to me to overwrite the class variable for every scope change. If I were doing this for real I'd probably make a stack of environments. When a new scope is entered create the new environment and push it onto the stack. When the scope is no longer needed just pop the environment off the stack.

Moving Forward

I'm about half way through the Tree Walk Interpreter section now. Next up is control flow statements; if, while, for. That should be interesting.

I'm having a blast with this project so far, but I am also really looking forward to Bytecode Virtual Machine section, even though it is far from finished. I have been brushing up on my C skills a little bit each night in preparation.