You may want to inform yourself about human rights in China.

On Exceptions in Go

date: 2024-09-22
Gouache study for a kids’ course

Gouache study for a kids’ course by M. Bivert through instagram.com

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:

  1. This is an exceptional case: it shouldn’t have happened nor should it happen often;
  2. The potential sources of errors are unclear from the outset (we’re in a generic library);
  3. 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:

email