Error handling in Go is typically performed by
returning an additional error
value. The pros/cons of it has been
largely debated: essentially, it’s more verbose that
what we could have had, but it’s dumb, simple,
and tend to encourage a thorough handling of error cases.
By comparison, a crucial drawback of a major alternative to this approach
– exceptions – is that they’re essentially a non-local goto:
in practice, they tend to hide where/when an error can occur, require a
smart IDE to understand which functions in a try{}
block can throw()
what; lazy programmers’s Gotta Catch ‘Em All! attitude further complicates
things.
But there are still exceptional cases where an exception-like error
handling makes sense, and Go does provide such a mechanism via panic()
/recover()
.
For instance, if you’ve ever used the standard
http.ListenAndServe()
, you may have noticed that a
goroutine crash – e.g. a panic()
caused by an out-of-bound – doesn’t crash
the server which happily keeps serving clients: the panic()
is “caught”
by a recover()
.
Exercise: Look for where/how this is implemented.
That’s one case where an exception-like handling makes sense: the motto is probably something along the line of:
- This is an exceptional case: it shouldn’t have happened nor should it happen often;
- The potential sources of errors are unclear from the outset (we’re in a generic library);
- We don’t want to bring down a production service because of a rare unexpected issue.
Another example, from the Go parser: what follows is an excerpt from
src/go/parser/interface.go
(pay attention
in particular to the defer()
/recover()
):
func ParseFile(fset *token.FileSet, filename string, src any, mode Mode) (f *ast.File, err error) {
if fset == nil {
panic("parser.ParseFile: no token.FileSet provided (fset == nil)")
}
// get source
text, err := readSource(filename, src)
if err != nil {
return nil, err
}
var p parser
defer func() {
if e := recover(); e != nil {
// resume same panic if it's not a bailout
bail, ok := e.(bailout)
if !ok {
panic(e)
} else if bail.msg != "" {
p.errors.Add(p.file.Position(bail.pos), bail.msg)
}
}
// set result values
if f == nil {
// source is not a valid Go source file - satisfy
// ParseFile API and return a valid (but) empty
// *ast.File
f = &ast.File{
Name: new(ast.Ident),
Scope: ast.NewScope(nil),
}
}
p.errors.Sort()
err = p.errors.Err()
}()
// parse source
p.init(fset, filename, text, mode)
f = p.parseFile()
return
}
The idea is that the actual parsing, initiated by parseFile()
from
src/go/parser/parser.go
, is encoded by a multitude
of mutually recursive functions – a FST. In some circumstances
– e.g. the default behavior to give up after 10 errors – we want to jump
away from the current state to an error state. Implementing this manually
by returning errors would be noisy, making the code harder to read/reason about.
But a panic()
/recover()
allows to jump from any state to an error
state in one fell swoop, very cleanly.
More precisely, most parsing errors are implemented by calling the following
error()
function from src/go/parser/parser.go
,
which accumulates errors in the parser.errors
field. In particular, by default,
once we get more than 10 errors, we panic()
to kill the parsing:
func (p *parser) error(pos token.Pos, msg string) {
if p.trace {
defer un(trace(p, "error: "+msg))
}
epos := p.file.Position(pos)
// If AllErrors is not set, discard errors reported on the same line
// as the last recorded error and stop parsing if there are more than
// 10 errors.
if p.mode&AllErrors == 0 {
n := len(p.errors)
if n > 0 && p.errors[n-1].Pos.Line == epos.Line {
return // discard - likely a spurious error
}
if n > 10 {
panic(bailout{})
}
}
p.errors.Add(epos, msg)
}
A similar example can be found in Rob Pike’s expr, see
main/expr.go
.
Note: If you want to know more about the internal of the
Go compiler, see those two articles
exploring how to add an until
statement to the Go compiler.
Comments
By email, at mathieu.bivert chez: