Skip to content

Instantly share code, notes, and snippets.

@spakin spakin/returnfrom.md
Last active Apr 1, 2019

Embed
What would you like to do?
Go 2 error handling based on non-local returns

Summary

Although in Error Handling — Problem Overview, Russ Cox does a great job motivating the need for lighter-weight error handling, I have a few concerns with the catch and handle mechanisms proposed for Go2. I most dislike the fact that only one handler can be active at any point in the code. I largely disapprove of recoverable errors not being supported. And I'm not particularly fond of check being hard-wired to work only with type error.

In my counter-proposal, I call for a single new keyword, returnfrom:

ReturnFromStmt = "returnfrom" Label [ExpressionList] .

The semantics is that returnfrom behaves like a return appearing in the same scope as a given label. It's therefore a bit like a goto but supports the handler nesting that Cox's article calls out as important for proper cleanup.

Relative to existing items in the Go 2 Error Handling Feedback, my proposal appears to be most similar to Patrick Kelly's makro proposal.

Examples

Consider the printSum example from Error Handling — Draft Design:

func printSum(a, b string) error {
	x, err := strconv.Atoi(a)
	if err != nil {
		return err
	}
	y, err := strconv.Atoi(b)
	if err != nil {
		return err
	}
	fmt.Println("result:", x + y)
	return nil
}

The handle and catch mechanisms from the design document can simplify the above by eliding the if err != nil checks:

func printSum(a, b string) error {
	handle err { return err }
	x := check strconv.Atoi(a)
	y := check strconv.Atoi(b)
	fmt.Println("result:", x + y)
	return nil
}

With returnfrom, the code defines its own handler using an ordinary function definition:

func printSum(a, b string) error {
Top:
	checkError := func(err error) { returnfrom Top, err }
	x, err := strconv.Atoi(a)
	checkError(err)
	y, err := strconv.Atoi(b)
	checkError(err)
	fmt.Println("result:", x + y)
	return nil
}

One could alternatively wrap strconv.Atoi with a function that error-checks the return value:

func printSum(a, b string) error {
Top:
	safeAtoi := func(s string) int {
		a, err := strconv.Atoi(s)
		if err {
			returnfrom Top, err
		}
		return a
	}
	x := safeAtoi(a)
	y := safeAtoi(b)
	fmt.Println("result:", x + y)
	return nil
}

As a longer example, consider the CopyFile example from Error Handling — Problem Overview:

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)
	}
}

The idea is that we always want to return a detailed error string on error, but we additional want to close and remove the destination file if an error occurs after that file is created. Here's how returnfrom might handle that situation:

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

	checkCopy := func(err error) {
		if err != nil {
			w.Close()
			os.Remove(dst)
			checkCreation(err)
		}
	}
	checkCopy(io.Copy(w, r))
	checkClose(w.Close())
}

Note that checkCopy is defined in terms of checkCreation, similar to how handle stacks but more explicit.

Here's some repetitive code that returns an empty string on error:

func greatGreatGrandfather(p string, fs map[string]string) string {
	f, ok := fs[p]
	if !ok {
		return ""
	}
	gf, ok := fs[f]
	if !ok {
		return ""
	}
	ggf, ok := fs[gf]
	if !ok {
		return ""
	}
	gggf, ok := fs[ggf]
	if !ok {
		return ""
	}
	return gggf
}

Because no error is involved, the handle/check approach doesn't apply here, but the returnfrom approach does:

func greatGreatGrandfather(p string, fs map[string]string) string {
Top:
	father := func(p string) string {
		f, ok := fs[p]
		if !ok {
			returnfrom Top, ""
		}
		return f
	}
	return father(father(father(father(p))))
}

Discussion

This proposal retains a number of features of the Go2 draft proposal:

  • Error checks and error handling remain explicit.

  • Existing code continues to work as is.

  • Handlers can be chained.

  • if err != nil blocks can be replaced with a single statement.

However, it enhances the draft proposal with the following improvements:

  • Any number of error handlers can be active at any point in the code.

  • Handlers can conditionally not exit the function, for example when an error condition can be recovered from.

  • Unlike check, this proposal's do-it-yourself error handling is not limited to values of type error. For example, a called function that returns true for success and false for failure can be handled just as easily as one that returns a nil or non-nil error value.

  • The returnfrom mechanism may have broader applicability than error handling.

All of the examples presented so far return from the function's top level, and this will almost certainly be the most common usage in practice. However, the use of a label to identify a scope implies that nested functions can utilize returnfrom just as easily:

func printSumOrNot(a, b string) {
	printSum := func(a, b string) error {
	Inner:
		checkError := func(err error) { returnfrom Inner, err }
		x, err := strconv.Atoi(a)
		checkError(err)
		y, err := strconv.Atoi(b)
		checkError(err)
		fmt.Println("result:", x + y)
		return nil
	}
	err := printSum(a, b)
	if err != nil {
		fmt.Println("I can't add those!")
	}
}

It's possible that returnfrom could enable faster returns from recursive functions. A standard binary-tree search looks like this:

func Search(k int, n *Node) (string, bool) {
	switch {
	case n == nil:
		return "", false
	case k == n.Key:
		return n.Value, true
	case k < n.Key:
		return Search(k, n.Left)
	case k > n.Key:
		return Search(k, n.Right)
	}
	return "", false
}

The following, newly enabled variation could save the generated code from having to unravel the stack level-by-level when a base case is reached and instead discard look's entire call stack at once when returning from Search:

func Search(k int, n *Node) (string, bool) {
Top:
	look := func(n *Node) {
		switch {
		case n == nil:
			returnfrom Top, "", false
		case k == n.Key:
			returnfrom Top, n.Value, true
		case k < n.Key:
			look(n.Left)
		case k > n.Key:
			look(n.Right)
		}
		returnfrom Top, "", false
	}
	look(n)
	return "", false
}

Issues

Go1's label-scoping rules prevent an inner function from seeing a label declared in an outer function. This restriction would need to be lifted, at least when used by returnfrom. However, doing so must be done with care. For example, what would it mean to returnfrom an inner function launched as a goroutine? Should that be a compile-time error? A run-time error? Goroutine termination and nothing else? There are presumably other reasons why an inner function cannot goto a label in an outer function, and these would also need to be considered when relaxing label-scope semantics to enable returnfrom.

@PeterRK

This comment has been minimized.

Copy link

PeterRK commented Sep 15, 2018

I love goto too. But gophers without C experience may not like it.

@cpacia

This comment has been minimized.

Copy link

cpacia commented Apr 1, 2019

Just adding my thoughts to this. This proposal is the closest I've read to my own thoughts. It also sticks very close to Go 1 syntax without making major changes.

My only tweak would be to make returnfrom _ return the outer function rather than writing top: at the beginning of each function.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.