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.
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))))
}
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 typeerror
. For example, a called function that returnstrue
for success andfalse
for failure can be handled just as easily as one that returns anil
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
}
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
.
I love goto too. But gophers without C experience may not like it.