It is fair to say that at this point I have stopped refreshing my knowledge of Go and I’m learning new things. Lots of them, actually; thanks to the toy programming language that I’m implementing.
One of those things is altering the flow of the program when implementing statements like return
, continue
or break
.
I am following Crafting Interpreters as reference for the implementation of an interpreter for my language. The book is implementing the tree-walk interpreter in Java and it can use exceptions, but those aren’t available in Go (which is a shame, I generally prefer languages that support exceptions).
Let’s look at an example of the book, converted to my language:
1def count(n number) number {
2 for n < 100 {
3 if n == 3 {
4 return n;
5 }
6 println(n);
7 n = n + 1;
8 }
9 return n;
10}
11
12count(1);
13// output:
14// 1
15// 2
Because the way a tree-walk interpreter works, when the return
in line 4 gets evaluated, the interpreter is a few functions deep:
- Called
count
–we should return here–. - Evaluate
count(1)
. - Evaluate
for
. - Evaluate
if
. - Evaluate
return
–this is where we are–.
In Java, going from the last point to the first one and return from there is quite simple because we have exceptions that will clear those function calls and take us to where we want to go –because we can catch the exception there–, but in Go we can’t do that. Instead we have panic and recover, and we can use them to do something similar –that I called panic jump, but that is probably not the technical name–.
In my interpreter I have:
type PanicJumpType int
const (
PanicJumpReturn PanicJumpType = iota
PanicJumpTailCall
PanicJumpContinue
PanicJumpBreak
)
type PanicJump struct {
typ PanicJumpType
value any
loc tokens.Location
}
Which defines a type that will allow me to:
- Say which type of “panic jump” I’m using, because it could be a return call but it could be other statements as well that have a similar behaviour.
- Provide a value (e.g. the value for
return
). - Track where that jump came from, so we can report useful errors (e.g. “return without function in filename:line:col”).
So in the evaluation of return
we can use it like this:
// value is the value to return, and v.Loc is the location of that value
panic(&PanicJump{typ: PanicJumpReturn, value: val, loc: v.Loc})
And in the code that evaluates a function call we have something like this:
func (i *Interpreter) call(call ast.Call) (result any, err error) {
//... more code irrelevant for the example ...
// handle return via panic call
defer func() {
if r := recover(); r != nil {
if val, ok := r.(*PanicJump); ok && val.typ == PanicJumpReturn {
result = val.value
err = nil
} else {
// won't be handled here
panic(r)
}
}
}()
// this function call may panic jump
_, err = fun.Call(i, args, call.Loc)
//... even more code ...
So before we call fun.Call
, that could “panic jump”, we set a handler for it that will check that the panic jump is the one we can handle (PanicJumpReturn
) and set the return values of Interpreter.call
.
If is not a panic jump that we can handle, including an actual panic –hopefully my code is perfect and that never happens–, we propagate it by calling panic
again, and it will be handled somewhere else.
The only thing I found slightly ugly is that the panic handler being a deferred function it can only set the return value of Interpreter.call
is by using named return parameters, which is definitely less readable than using explicit return values.
We also need a “catch all” handler on the interpreter, because return
could be called out of a function. Currently, that shouldn’t have use in my implementation because my parser already checks for that and it should never pass an AST (Abstract Syntax Tree) to the interpreter that is not 100% valid.
At the end is not too complicated, even if it is arguably less elegant than actual exceptions. It was a great Eureka! moment when I found the solution, even if I don’t know what is the impact performance-wise of this –I’m not really concerned about that for now!–.