Permalink
Branch: master
Find file Copy path
00fd2f6 Aug 28, 2018
1 contributor

Users who have contributed to this file

413 lines (319 sloc) 14.8 KB

Error Handling — Problem Overview

Russ Cox
August 27, 2018

Introduction

This overview and the accompanying detailed draft design are part of a collection of Go 2 draft design documents. The overall goal of the Go 2 effort is to address the most significant ways that Go fails to scale to large code bases and large developer efforts.

One way that Go programs fail to scale well is in the writing of error-checking and error-handling code. In general Go programs have too much code checking errors and not enough code handling them. (This will be illustrated below.) The draft design aims to address this problem by introducing lighter-weight syntax for error checks than the current idiomatic assignment-and-if-statement combination.

As part of Go 2, we are also considering, as a separate concern, changes to the semantics of error values, but this document is only about error checking and handling.

Problem

To scale to large code bases, Go programs must be lightweight, without undue repetition, and also robust, dealing gracefully with errors when they do arise.

In the design of Go, we made a conscious choice to use explicit error results and explicit error checks. In contrast, C most typically uses explicit checking of an implicit error result, errno, while exception handling—found in many languages, including C++, C#, Java, and Python—represents implicit checking of implicit results.

The subtleties of implicit checking are covered well in Raymond Chen’s pair of blog posts, "Cleaner, more elegant, and wrong" (2004), and "Cleaner, more elegant, and harder to recognize" (2005). In essence, because you can’t see implicit checks at all, it is very hard to verify by inspection that the error handling code correctly recovers from the state of the program at the time the check fails.

For example, consider this code, written in a hypothetical dialect of Go with exceptions:

func CopyFile(src, dst string) throws error {
	r := os.Open(src)
	defer r.Close()

	w := os.Create(dst)
	io.Copy(w, r)
	w.Close()
}

It is nice, clean, elegant code. It is also invisibly wrong: if io.Copy or w.Close fails, the code does not remove the partially-written dst file.

On the other hand, the equivalent actual Go code today would be:

func CopyFile(src, dst string) error {
	r, err := os.Open(src)
	if err != nil {
		return err
	}
	defer r.Close()

	w, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer w.Close()

	if _, err := io.Copy(w, r); err != nil {
		return err
	}
	if err := w.Close(); err != nil {
		return err
	}
}

This code is not nice, not clean, not elegant, and still wrong: like the previous version, it does not remove dst when io.Copy or w.Close fails. There is a plausible argument that at least a visible check could prompt an attentive reader to wonder about the appropriate error-handling response at that point in the code. In practice, however, error checks take up so much space that readers quickly learn to skip them to see the structure of the code.

This code also has a second omission in its error handling. Functions should typically include relevant information about their arguments in their errors, like os.Open returning the name of the file being opened. Returning the error unmodified produces a failure without any information about the sequence of operations that led to the error.

In short, this Go code has too much error checking and not enough error handling. A more robust version with more helpful errors would be:

func CopyFile(src, dst string) error {
	r, err := os.Open(src)
	if err != nil {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}
	defer r.Close()

	w, err := os.Create(dst)
	if err != nil {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}

	if _, err := io.Copy(w, r); err != nil {
		w.Close()
		os.Remove(dst)
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}

	if err := w.Close(); err != nil {
		os.Remove(dst)
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}
}

Correcting these faults has only made the code more correct, not cleaner or more elegant.

Goals

For Go 2, we would like to make error checks more lightweight, reducing the amount of Go program text dedicated to error checking. We also want to make it more convenient to write error handling, raising the likelihood that programmers will take the time to do it.

Both error checks and error handling must remain explicit, meaning visible in the program text. We do not want to repeat the pitfalls of exception handling.

Existing code must keep working and remain as valid as it is today. Any changes must interoperate with existing code.

As mentioned above, it is not a goal of this draft design to change or augment the semantics of errors. For that discussion see the error values problem overview.

Draft Design

This section quickly summarizes the draft design, as a basis for high-level discussion and comparison with other approaches.

The draft design introduces two new syntactic forms. First, it introduces a checked expression check f(x, y, z) or check err, marking an explicit error check. Second, it introduces a handle statement defining an error handler. When an error check fails, it transfers control to the innermost handler, which transfers control to the next handler above it, and so on, until a handler executes a return statement.

For example, the corrected code above shortens to:

func CopyFile(src, dst string) error {
	handle err {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}

	r := check os.Open(src)
	defer r.Close()

	w := check os.Create(dst)
	handle err {
		w.Close()
		os.Remove(dst) // (only if a check fails)
	}

	check io.Copy(w, r)
	check w.Close()
	return nil
}

The check/handle combination is permitted in functions that do not themselves return errors. For example, here is a main function from a useful but trivial program:

func main() {
	hex, err := ioutil.ReadAll(os.Stdin)
	if err != nil {
		log.Fatal(err)
	}

	data, err := parseHexdump(string(hex))
	if err != nil {
		log.Fatal(err)
	}

	os.Stdout.Write(data)
}

It would be shorter and clearer to write instead:

func main() {
	handle err {
		log.Fatal(err)
	}

	hex := check ioutil.ReadAll(os.Stdin)
	data := check parseHexdump(string(hex))
	os.Stdout.Write(data)
}

For details, see the draft design.

Discussion and Open Questions

These draft designs are meant only as a starting point for community discussion. We fully expect the details to be revised based on feedback and especially experience reports. This section outlines some of the questions that remain to be answered.

Check versus try. The keyword check is a clear statement of what is being done. Originally we used the well-known exception keyword try. This did read well for function calls:

data := try parseHexdump(string(hex))

But it did not read well for checks applied to error values:

data, err := parseHexdump(string(hex))
if err == ErrBadHex {
	... special handling ...
}
try err

In this case, check err is a clearer description than try err. Rust originally used try! to mark an explicit error check but moved to a special ? operator instead. Swift also uses try to mark an explicit error check, but also try! and try?, and as part of a broader analogy to exception-handling that also includes throw and catch.

Overall it seems that the draft design’s check/handle are sufficiently different from exception handling and from Rust and Swift to justify the clearer keyword, check, over the more familiar one, try.

Both Rust and Swift are discussed in more detail below.

Defer. The error handling is in some ways similar to defer and recover, but for errors instead of panics. The current draft design makes error handlers chain lexically, while defer builds up a chain at runtime depending on what code executes. This difference matters for handlers (or deferred functions) declared in conditional bodies and loops. Although lexical stacking of error handlers seems like a marginally better design, it may be less surprising to match defer exactly. As an example where defer-like handling would be more convenient, if CopyFile established its destination w as either os.Stdout or the result of os.Create, then it would be helpful to be able to introduce the os.Remove(dst) handler conditionally.

Panics. We’ve spent a while trying to harmonize error handling and panics, so that cleanup due to error handling need not be repeated for cleanup due to panics. All our attempts at unifying the two only led to more complexity.

Feedback. The most useful general feedback would be examples of interesting uses that are enabled or disallowed by the draft design. We’d also welcome feedback about the points above, especially based on experience with complex or buggy error handling in real programs.

We are collecting links to feedback at golang.org/wiki/Go2ErrorHandlingFeedback.

Designs in Other Languages

The problem section above briefly discussed C and exception-based languages.

Other recent language designs have also recognized the problems caused by exception handling’s invisible error checks, and those designs are worth examining in more detail. The Go draft design was inspired, at least in part, by each of them.

Rust

Like Go, Rust distinguishes between expected errors, like "file not found", and unexpected errors, like accessing past the end of an array. Expected errors are returned explicitly while unexpected errors become program-ending panics. But Rust has little special-purpose language support for expected errors. Instead, concise handling of expected errors is done almost entirely by generics.

In Rust, functions return single values (possibly a single tuple value), and a function returning a potential error returns a discriminated union Result<T, E> that is either the successful result of type T or an error of type E.

enum Result<T, E> {
	Ok(T),
	Err(E),
}

For example, fs::File::Open returns a Result<fs::File, io::Error>. The generic Result<T, E> type defines an unwrap method that turns a result into the underlying value (of type T) or else panics (if the result represents an error).

If code does want to check an error instead of panicking, the ? operator macro-expands use(result?) into the Rust equivalent of this Go code:

if result.err != nil {
	return result.err
}
use(result.value)

The ? operator therefore helps shorten the error checking and is very similar to the draft design’s check. But Rust has no equivalent of handle: the convenience of the ? operator comes with the likely omission of proper handling. Rust’s equivalent of Go’s explicit error check if err != nil is using a match statement, which is equally verbose.

Rust’s ? operator began life as the try! macro.

Swift

Swift’s try, catch, and throw keywords appear at first glance to be implementing exception handling, but really they are syntax for explicit error handling.

Each function’s signature specifies whether the function can result in ("throw") an error. Here is an example from the Swift book:

func canThrowErrors() throws -> String
func cannotThrowErrors() -> String

These are analogous to the Go result lists (string, error) and string.

Inside a "throws" function, the throw statement returns an error, as in this example, again from the Swift book:

throw VendingMachineError.insufficientFunds(coinsNeeded: 5)

Every call to a "throws" function must specify at the call site what to do in case of error. In general that means nesting the call (perhaps along with other calls) inside a do-catch block, with all potentially-throwing calls marked by the try keyword:

do {
	let s = try canThrowErrors()
	let t = cannotThrowErrors()
	let u = try canThrowErrors() // a second call
} catch {
	handle error from try above
}

The key differences from exception handling as in C++, Java, Python, and similar languages are:

  • Every error check is marked.
  • There must be a catch or other direction about what to do with an error.
  • There is no implicit stack unwinding.

Combined, those differences make all error checking, handling, and control flow transfers explicit, as in Go.

Swift introduces three shorthands to avoid having to wrap every throwing function call in a do-catch block.

First, outside a block, try canThrowErrors() checks for the error and re-throws it, like Rust’s old try! macro and current ? operator.

Second, try! canThrowErrors() checks for the error and turns it into a runtime assertion failure, like Rust’s .unwrap method.

Third, try? canThrowErrors() evaluates to nil on error, or else the function’s result. The Swift book gives this example:

func fetchData() -> Data? {
	if let data = try? fetchDataFromDisk() { return data }
	if let data = try? fetchDataFromServer() { return data }
	return nil
}

The example discards the exact reasons these functions failed.

For cleanup, Swift adds lexical defer blocks, which run when the enclosing scope is exited, whether by an explicit return or by throwing an error.