golang/proposal


Author: Robert Griesemer

Last update: 6/4/2019

Discussion atgolang.org/issue/32437.

Summary

We propose a new built-in function calledtry, designed specifically to eliminate the boilerplateifstatements typically associated with error handling in Go. No other language changes are suggested. We advocate using the existingdeferstatement and standard library functions to help with augmenting or wrapping of errors. This minimal approach addresses most common scenarios while adding very little complexity to the language. Thetrybuilt-in is easy to explain, straightforward to implement, orthogonal to other language constructs, and fully backward-compatible. It also leaves open a path to extending the mechanism, should we wish to do so in the future.

The rest of this document is organized as follows: After a brief introduction, we provide the definition of the built-in and explain its use in practice. The discussion section reviews alternative proposals and the current design. We’ll end with conclusions and an implementation schedule followed by examples and FAQs.

Introduction

At last year’s Gophercon in Denver, members of the Go Team (Russ Cox, Marcel van Lohuizen) presented some new ideas on how to reduce the tedium of manual error handling in Go (draft design). We have received a lot of feedback since then.

As Russ Cox explained in hisproblem overview, our goal is to make error handling more lightweight by reducing the amount of source code dedicated solely to error checking. We also want to make it more convenient to write error handling code, to raise the likelihood programmers will take the time to do it. At the same time we do want to keep error handling code explicitly visible in the program text.

The ideas discussed in the draft design centered around a new unary operatorcheckwhich simplified explicit checking of an error value returned by some expression (typically a function call), ahandledeclaration for error handlers, and a set of rules connecting the two new language constructs.

Much of the immediate feedback we received focused on the details and complexity ofhandlewhile the idea of acheck-like operator seemed more palatable. In fact, several community members picked up on the idea of acheck-like operator and expanded on it. Here are some of the posts most relevant to this proposal:

  • The first written-down suggestion (known to us) to use acheckbuilt-inrather than acheckoperatorwas byPeterRKin his postKey Parts of Error Handling.

  • More recently,Markusproposed two new keywordsguardandmustas well as the use ofdeferfor error wrapping in issue#31442.

  • Related,pjebsproposed amustbuilt-in in issue#32219.

The current proposal, while different in detail, was influenced by these three issues and the general feedback received on last year’s draft design.

For completeness, we note that more error-handling related proposals can be foundhere. Also noteworthy,Liamcame up with an extensive menu ofrequirementsto consider.

Thetrybuilt-in

Proposal

We propose to add a new function-like built-in calledtrywith signature (pseudo-code)

functry(t1T1, t1T2, … tnTn, teerror) (T1,T2, …Tn)

At each call site oftry, the typesT1toTnanderrormatch the types of the incoming arguments, usually the results of a (n+1)-valued function call. There may be no incomingTarguments at all (n may zero) in which casetrydoesn’t return any results either. But the last incoming parameter, which must be of typeerror(see also the FAQ), is always present. Callingtrywithout arguments or with a last argument that is not of typeerrorleads to a compile-time error.

Thetrybuilt-in mayonlybe used inside a function with at least one result parameter where the last result is of typeerror. Callingtryin a different context leads to a compile-time error.

Invokingtrywith a function callf()as in (pseudo-code)

turns into the following (in-lined) code:

t1, …tn,te:=f() //t1, … tn, te are local (invisible) temporaries
ifte !=nil{
        err=te    //assign te to the error result parameter
       return     //return from enclosing function
}
x1, … xn=t1, … tn //assignment only if there was no error

In other words, if the last argument supplied totry, of typeerror, is not nil, the enclosing function’s error result variable (callederrin the pseudo-code above, but it may have any other name or be unnamed) is set to that non-nil error value and the enclosing function returns. If the enclosing function declares other named result parameters, those result parameters keep whatever value they have. If the function declares other unnamed result parameters, they assume their corresponding zero values (which is the same as keeping the value they already have).

Iftryhappens to be used in a multiple assignment as in this illustration, and a non-nil error is detected, the assignment (to the user-defined variables) isnotexecuted and none of the variables on the left-hand side of the assignment are changed. That is,trybehaves like a function call: its results are only available iftryreturns to the actual call site (as opposed to returning from the enclosing function). As a consequence, if the variables on the left-hand side are named result parameters, usingtrywill lead to a different result than typical code found today. For instance, ifa,b, anderrare all named result parameters of the enclosing function, this code

a, b, err=f()
iferr !=nil{
       return
}

will always seta,b, anderr, independently of whetherf()returned an error or not. In contrast

will leaveaandbunchanged in case of an error. While this is a subtle difference, we believe cases like these are rare. If current behavior is expected, keep theifstatement.

Usage

The definition oftrydirectly suggests its use: manyifstatements checking for error results today can be eliminated withtry. For instance

f,err:=os.Open(filename)
iferr !=nil{
       return…, err //zero values for other results, if any
}

can be simplified to

f:=try(os.Open(filename))

If the enclosing function does not return an error result,trycannot be used (but see the Discussion section). In that case, an error must be handled locally anyway (since no error is returned), and then anifstatement remains the appropriate mechanism to test for the error.

More generally, it is not a goal to replace all possible testing of errors with thetryfunction. Code that needs different semantics can and should continue to use if statements and explicit error variables.

Testing andtry

In one of our earlier attempts at specifyingtry(see the section on Design iterations, below),trywas designed to panic upon encountering an error if used inside a function without anerrorresult. This enabled the use oftryin unit tests as supported by the standard library’stestingpackage.

One option is for thetestingpackage to allow test/benchmark functions of the form

funcTestXxx(*testing.T)error
func BenchmarkXxx(*testing.B) error

to enable the use oftryin tests. A test or benchmark function returning a non-nil error would implicitly callt.Fatal(err)orb.Fatal(err). This would be a modest library change and avoid the need for different semantics (returning or panicking) fortrydepending on context.

One drawback of this approach is thatt.Fatalandb.Fatalwould not report the line number of the actually failing call. Another drawback is that we must adjust subtests in some way as well. How to address these best is an open question; we do not propose a specific change to thetestingpackage with this document.

See also issue#21111which proposes that example functions may return an error result.

Handling errors

A significant aspect of the originaldraft designconcerned language support for wrapping or otherwise augmenting an error. The draft design introduced a new keywordhandleand a newerror handlerdeclaration. This new language construct was problematic because of its non-trivial semantics, especially when considering its impact on control flow. In particular, its functionality intersected with the functionality ofdeferin unfortunate ways, which made it a non-orthogonal new language feature.

This proposal reduces the original draft design to its essence. If error augmentation or wrapping is desired there are two approaches: Stick with the tried-and-trueifstatement, or, alternatively, “declare” an error handler with adeferstatement:

deferfunc() {
       iferr !=nil{ //no error may have occurred - check for it
                err=… //wrap/augment error
        }
}()

Here, erris the name of the error result of the enclosing function.

In practice, we envision suitable helper functions such as

funcHandleErrorf(err*error,formatstring,args...interface{}) {
       if*err !=nil{
                *err=fmt.Errorf(format, args…)
        }
}

or similar; thefmtpackage would be a natural place for such helpers (it already providesfmt.Errorf). Using a helper function, the declaration of an error handler will be reduced to a one-liner in many cases. For instance, one might write

deferfmt.HandleErrorf(&err, “foobar”)

which reads reasonably well and has the advantage that it can be implemented without the need for new language features.

The main drawback of this approach is that the error result parameter needs to be named, possibly leading to less pretty APIs (but see the FAQs on this subject). We believe that we will get used to it once this style has established itself.

Efficiency ofdefer

An important consideration with usingdeferas error handlers is efficiency. Thedeferstatement has a reputation of beingslow. We do not want to have to choose between efficient code and good error handling. Independently, the Go runtime and compiler team has been discussing alternative implementation options and we believe that we can make typicaldeferuses for error handling about as efficient as existing “manual” code. We hope to make this fasterdeferimplementation available in Go 1.14 (see alsoCL 171758which is a first step in this direction).

Special cases:go try(f)anddefer try(f)

Thetrybuilt-in looks like a function and thus is expected to be usable wherever a function call is permitted. But if atrycall is used in agostatement, things are less clear:

Here,fis evaluated when thegostatement is executed in the current goroutine, and then its results are passed as arguments totrywhich is launched in a new goroutine. Iffreturns a non-nil error,tryis expected to return from the enclosing function, but there isn’t any such (Go) function (nor a last result parameter of typeerror) since we are running in a separate goroutine. Therefore we suggest to disallowtryas the called function in agostatement.

The situation with

appears similar but here the semantics ofdefermean that the execution oftrywould be suspended until the enclosing function is about to return. As before, the argumentfis evaluated when thedeferstatement is executed, andf’s results are passed to the suspendedtry.

Only when the enclosing function is about to return doestrytest for an error returned byf. Without changes to the behavior oftry, such an error might then overwrite another error currently being returned by the enclosing function. This is at best confusing, and at worst error-prone. Therefore we suggest to disallowtryas the called function in adeferstatement as well. We can always revisit this decision if sensible applications are found.

Finally, like other built-ins, the built-intrymust be called; it cannot be used as a function value, as inf :=try(just likef :=printandf :=neware also disallowed).

Discussion

Design iterations

What follows is a brief discussion of earlier designs which led to the current minimal proposal. We hope that this will shed some light on the specific design choices made.

Our first iteration of this proposal was inspired by two ideas fromKey Parts of Error Handling, which is to use a built-in rather than an operator, and an ordinary Go function to handle an error rather than a new error handler language construct. In contrast to that post, our error handler had the fixed function signaturefunc(error) errorto simplify matters. The error handler would be called bytryin the presence of an error, just beforetryreturned from the enclosing function. Here is an example:

handler:=func(errerror)error{
       returnfmt.Errorf("foo failed:%v", err) //wrap error
}

f:=try(os.Open(filename), handler)             //handler will be called in error case

While this approach permitted the specification of efficient user-defined error handlers, it also opened a lot of questions which didn’t have obviously correct answers: What should happen if the handler is provided but is nil? Shouldtrypanic or treat it as an absent error handler? What if the handler is invoked with a non-nil error and then returns a nil result? Does this mean the error is “cancelled”? Or should the enclosing function return with a nil error? It was also not clear if permitting an optional error handler would lead programmers to ignore proper error handling altogether. It would also be easy to do proper error handling everywhere but miss a single occurrence of atry. And so forth.

The next iteration removed the ability to provide a user-defined error handler in favor of usingdeferfor error wrapping. This seemed a better approach because it made error handlers much more visible in the code. This step eliminated all the questions around optional functions as error handlers but required that error results were named if access to them was needed (we decided that this was ok). Furthermore, in an attempt to maketryuseful not just inside functions with an error result, the semantics oftrydepended on the context: Iftrywere used at the package-level, or if it were called inside a function without an error result,trywould panic upon encountering an error. (As an aside, because of that property the built-in was calledmustrather thantryin that proposal.) Havingtry(ormust) behave in this context-sensitive way seemed natural and also quite useful: It would allow the elimination of many user-definedmusthelper functions currently used in package-level variable initialization expressions. It would also open the possibility of usingtryin unit tests via thetestingpackage.

Yet, the context-sensitivity oftrywas considered fraught: For instance, the behavior of a function containingtrycalls could change silently (from possibly panicking to not panicking, and vice versa) if an error result was added or removed from the signature. This seemed too dangerous a property. The obvious solution would have been to split the functionality oftryinto two separate functions,mustandtry(very similar to what is suggested by issue#31442). But that would have required two new built-in functions, with onlytrydirectly connected to the immediate need for better error handling support.

Thus, in the current iteration, rather than introducing a second built-in, we decided to remove the dual semantics oftryand consequently only permit its use inside functions that have an error result.

Properties of the proposed design

This proposal is rather minimal, and may even feel like a step back from last year’s draft design. We believe the design choices we made to arrive attryare well justified:

  • First and foremost,tryhas exactly the semantics of the originally proposedcheckoperator in the absence of ahandledeclaration. This validates the original draft design in an important aspect.

  • Choosing a built-in function rather than an operator has several advantages. There is no need for a new keyword such ascheckwhich would have made the design not backward compatible with existing parsers. There is also no need for extending the expression syntax with the new operator. Adding a new built-in is a comparatively trivial and completely orthogonal language change.

  • Using a built-in function rather than an operator requires the use of parentheses. We must writetry(f())rather thantry f(). This is the (small) price we pay for being backward compatible with existing parsers. But it also makes the design forward-compatible: If we determine down the road that having some form of explicitly provided error handler function, or any other additional parameter for that matter, is a good idea, it is trivially possible to pass that additional argument to atrycall.

  • As it turns out, having to write parentheses has its advantages. In more complex expressions with multipletrycalls, writing parentheses improves readability by eliminating guesswork about the precedence of operators, as the following examples illustrate:

info:=try(try(os.Open(file)).Stat())   //proposed try built-in
info:=try(try os.Open(file)).Stat()   //try binding looser than dot
info:=try(try(os.Open(file)).Stat()) //try binding tighter than dot

The second line corresponds to atryoperator that binds looser than a method call: Parentheses are required around the entire innertryexpression since the result of thattryis the receiver of the.Statcall (rather than the result ofos.Open).

The third line corresponds to atryoperator that binds tighter than a method call: Parentheses are required around theos.Open(file)call since the results of that are the arguments for the innertry(we don’t want the innertryto apply only toos, nor the outer try to apply only to the innertry’s result).

The first line is by far the least surprising and most readable as it is just using the familiar function call notation.

  • The absence of a dedicated language construct to support error wrapping may disappoint some people. However, note that this proposal does not preclude such a construct in the future. It is clearly better to wait until a really good solution presents itself than prematurely add a mechanism to the language that is not fully satisfactory.

Conclusions

The main difference between this design and the originaldraft designis the elimination of the error handler as a new language construct. The resulting simplification is huge, yet there is no significant loss of generality. The effect of an explicit error handler declaration can be achieved with a suitabledeferstatement which is also prominently visible at the opening of a function body.

In Go, built-ins are thelanguage escape mechanism of choicefor operations that are irregular in some way but which don’t justify special syntax. For instance, the very first versions of Go didn’t define theappendbuilt-in. Only after manually implementingappendover and over again for various slice types did it become clear thatdedicated language supportwas warranted. The repeated implementation helped clarify how exactly the built-in should look like. We believe we are in an analogous situation now withtry.

It may also seem odd at first for a built-in to affect control-flow, but we should keep in mind that Go already has a couple of built-ins doing exactly that:panicandrecover. The built-in typeerrorand functiontrycomplement that pair.

In summary,trymay seem unusual at first, but it is simply syntactic sugar tailor-made for one specific task, error handling with less boilerplate, and to handle that task well enough. As such it fits nicely into the philosophy of Go:

  • There is no interference with the rest of the language.
  • Because it is syntactic sugar,tryis easily explained in more basic terms of the language.
  • The design does not require new syntax.
  • The design is fully backwards-compatible.

This proposal does not solve all error handling situations one might want to handle, but it addresses the most commonly used patterns well. For everything else there areifstatements.

Implementation

The implementation requires:

  • Adjusting the Go spec.
  • Teaching the compiler’s type-checker about thetrybuilt-in. The actual implementation is expected to be a relatively straight-forward syntax tree transformation in the compiler’s front-end. No back-end changes are expected.
  • Teaching go/types about thetrybuilt-in. This is a minor change.
  • Adjusting gccgo accordingly (again, just the front-end).
  • Testing the built-in with new tests.

As this is a backward-compatible language change, no library changes are required. However, we anticipate that support functions for error handling may be added. Their detailed design and respective implementation work is discussedelsewhere.

Robert Griesemer will do the spec and go/types changes including additional tests, and (probably) also the cmd/compile compiler changes. We aim to have all the changes ready at the start of theGo 1.14 cycle, around August 1, 2019.

Separately, Ian Lance Taylor will look into the gccgo changes, which is released according to a different schedule.

As noted in our“Go 2, here we come!” blog post, the development cycle will serve as a way to collect experience about these new features and feedback from (very) early adopters.

At the release freeze, November 1, we will revisit this proposed feature and decide whether to include it in Go 1.14.

Examples

TheCopyFileexample from theoverviewbecomes

funcCopyFile(src,dststring) (errerror) {
       deferfunc() {
               iferr !=nil{
                        err=fmt.Errorf("copy%s%s:%v", src, dst, err)
                }
        }()

       r:=try(os.Open(src))
       deferr.Close()

       w:=try(os.Create(dst))
       deferfunc() {
                w.Close()
               iferr !=nil{
                        os.Remove(dst)//only if a “try” fails
                }
        }()

       try(io.Copy(w, r))
       try(w.Close())
       returnnil
}

Using a helper function as discussed in the section on handling errors, the firstdeferinCopyFilebecomes a one-liner:

deferfmt.HandleErrorf(&err,"copy%s%s:%v", src, dst, err)

It is still possible to have multiple handlers, and even chaining of handlers (via the stack ofdefer’s), but now the control flow is defined by existingdefersemantics, rather than a new, unfamiliar mechanism that needs to be learned first.

TheprintSumexample from thedraft designdoesn’t require an error handler and becomes

funcprintSum(a,bstring)error{
       x:=try(strconv.Atoi(a))
       y:=try(strconv.Atoi(b))
        fmt.Println("result:", x + y)
       returnnil
}

or even simpler:

funcprintSum(a,bstring)error{
        fmt.Println(
               "result:",
               try(strconv.Atoi(a)) +try(strconv.Atoi(b)),
        )
       returnnil
}

Themainfunction ofthis useful but trivial programcould be split into two functions:

funclocalMain()error{
       hex:=try(ioutil.ReadAll(os.Stdin))
       data:=try(parseHexdump(string(hex)))
       try(os.Stdout.Write(data))
       returnnil
}

funcmain() {
       iferr:=localMain(); err !=nil{
                log.Fatal(err)
        }
}

Sincetryrequires at a minimum anerrorargument, it may be used to check for remaining errors:

n,err:=src.Read(buf)
iferr==io.EOF{
       break
}
try(err)

FAQ

This section is expected to grow as necessary.

Q: What were the main criticisms of the originaldraft design?

A: The draft design introduced two new keywordscheckandhandlewhich made the proposal not backward-compatible. Furthermore, the semantics ofhandlewas quite complicated and its functionality significantly overlapped withdefer, makinghandlea non-orthogonal language feature.

Q: Why istrya built-in?

A: By makingtrya built-in, there is no need for a new keyword or operator in Go. Introducing a new keyword is not a backward-compatible language change because the keyword may conflict with identifiers in existing programs. Introducing a new operator requires new syntax, and the choice of a suitable operator, which we would like to avoid. Using ordinary function call syntax has also advantages as explained in the section on Properties of the proposed design. Andtrycan not be an ordinary function, because the number and types of its results depend on its input.

Q: Why istrycalledtry?

A: We have considered various alternatives, includingcheck,must, anddo. Even thoughtryis a built-in and therefore does not conflict with existing identifiers, such identifiers may still shadow the built-in and thus make it inaccessible.tryseems less common a user-defined identifier thancheck(probably because it is a keyword in some other languages) and thus it is less likely to be shadowed inadvertently. It is also shorter, and does convey its semantics fairly well. In the standard library we use the pattern of user-definedmustfunctions to raise a panic if an error occurs in a variable initialization expression;trydoes not panic. Finally, both Rust and Swift usetryto annotate explicitly-checked function calls as well (but see the next question). It makes sense to use the same word for the same idea.

Q: Why can’t we use?like Rust?

A: Go has been designed with a strong emphasis on readability; we want even people unfamiliar with the language to be able to make some sense of Go code (that doesn’t imply that each name needs to be self-explanatory; we still have a language spec, after all). So far we have avoided cryptic abbreviations or symbols in the language, including unusual operators such as?, which have ambiguous or non-obvious meanings. Generally, identifiers defined by the language are either fully spelled out (package,interface,if,append,recover, etc.), or shortened if the shortened version is unambiguous and well-understood (struct,var,func,int,len,imag, etc.). Rust introduced?to alleviate issues withtryand chaining – this is much less of an issue in Go where statements tend to be simpler and chaining (as opposed to nesting) less common. Finally, using?would introduce a new post-fix operator into the language. This would require a new token and new syntax and with that adjustments to a multitude of packages (scanners, parsers, etc.) and tools. It would also make it much harder to make future changes. Using a built-in eliminates all these problems while keeping the design flexible.

Q: Having to name the final (error) result parameter of a function just so thatdeferhas access to it screws upgo docoutput. Isn’t there a better approach?

A: We could adjustgo docto recognize the specific case where all results of a function except for the final error result have a blank (_) name, and omit the result names for that case. For instance, the signaturefunc f() (_ A, _ B, err error)could be presented bygo docasfunc f() (A, B, error). Ultimately this is a matter of style, and we believe we will adapt to expecting the new style, much as we adapted to not having semicolons.

Q: Isn’t usingdeferfor wrapping errors going to be slow?

A: Currently adeferstatement is relatively expensive compared to ordinary control flow. However, we believe that it is possible to make common use cases ofdeferfor error handling comparable in performance with the current “manual” approach. See alsoCL 171758which is expected to improve the performance ofdeferby around 30%.

Q: Won’t this design discourage adding context information to errors?

A: We think the verbosity of checking error results is a separate issue from adding context. The context a typical function should add to its errors (most commonly, information about its arguments) usually applies to multiple error checks. The plan to encourage the use ofdeferto add context to errors is mostly a separate concern from having shorter checks, which this proposal focuses on. The design of the exactdeferhelpers is part ofgolang.org/issue/29934(Go 2 error values), not this proposal.

Q: The last argument passed totrymustbe of typeerror. Why is it not sufficient for the incoming argument to beassignabletoerror?

A: Acommon novice mistakeis to assign a concrete nil pointer value to a variable of typeerror(which is an interface) only to find that that variable is not nil. Requiring the incoming argument to be of typeerrorprevents this bug from occurring through the use oftry. (We can revisit this decision in the future if necessary. Relaxing this rule would be a backward-compatible change.)

Q: If Go had “generics”, couldn’t we implementtryas a generic function?

A: Implementingtryrequires the ability to return from the function enclosing thetrycall. Absent such a “super return” statement,trycannot be implemented in Go even if there were generic functions.tryalso requires a variadic parameter list with parameters of different types. We do not anticipate support for such variadic generic functions.

Q: I can’t usetryin my code, my error checks don’t fit the required pattern. What should I do?

A:tryis not designed to addressallerror handling situations; it is designed to handle the most common case well, to keep the design simple and clear. If it doesn’t make sense (or it isn’t possible) to change your code such thattrycan be used, stick with what you have.ifstatements are code, too.

Q: In my function, most of the error tests require different error handling. I can usetryjust fine but it gets complicated or even impossible to usedeferfor error handling. What can I do?

A: You may be able to split your function into smaller functions of code that shares the same error handling. Also, see the previous question.

Read More

LEAVE A REPLY

Please enter your comment!
Please enter your name here