go: proposal: Go 2: error handling with functions and an error return?
Background (go 2 Process)
go 2 has laid out the problem of error handling (Please read before reading this proposal).
I am told that alternative proposals should be raised as git issues here. Please add anyone else you think would like to join the discussion.
Introduction
It is amazing to see the error handling problem being properly addressed. The existing draft proposal is good, but I think we can iterate to make it better. To avoid confusion by comparison to the existing proposal, I will avoid mentioning it in this one. However, if you are a supporter of the existing proposal, please separately read my critique of it.
It’s useful to take a step back and clearly state what we are trying to do with our implementation:
- provide an abstraction that allows for the insertion of a return statement for errors.
- compose handler functions together before they are used with the error return
In the existing go language, I cannot write a handler function which will create an early return from the function. There are a few approaches that use existing languages features for this control flow:
- Macros (e.g. Rust originally used a
try!macro). - Ruby supports anonymous functions that return from the enclosing function (method)
- exceptions
- Sequencing code with short-circuits. Some usage of monads in Haskell are a particularly good example of this.
For sequences with short-circuits, see the errors are values post for how this can be done in go. However, this severely alters how one would write go code.
Proposal: handlers as functions, just a special check
Lets repeat our goals again:
- provide an abstraction that allows for the insertion of a return statement for errors.
- compose handler functions together before they are used with the error return
Composition can be handled with ordinary functions that take and return an error.
That means we just need a mechanism to insert a return.
For early return in my proposal, I will use a question mark operator ? rather than a check keyword. This is for two reasons
- the operator can be used postfix, which has readability advantages
- the original draft proposal used
check, but it functions differently, so this may help avoid confusion.
See “Appendix: Operator versus check function” for a discussion on using ? or a check keyword.
Implementation as syntactic expansion
v := f() ? handler
expands to
v, err := f()
if err != nil {
return Zero, handler(err)
}
Where handler is a function that takes an error and returns one. Zero is the zero value for the (success) value returned before the error, assuming the function returns a single success value. A function that returns 4 values, the last one being an error, would have.
return Zero, Zero, Zero, handler(err)
This is a simple, easy to understand transformation. It is easy to underestimate the value from being able to understand the usage site without searching for context. I am trying to avoid comparisons to other proposals, but I want to say that none of the others I have seen can be described this simply.
All of the transformation is performed entirely by ?. It inserts the nil check, the return, and creates the needed zero values. The handler is just a normal function and an argument to ?.
For some small convenience in writing cleanup handlers, the return section would actually expand to this:
return Zero, handler.ToModifyError()(err)
See the section on handler types and the appendix section on ThenErr and ToModifyError.
Basic example from the original proposal, re-written
Putting this together, lets re-write SortContents, which wants different handlers in different places.
func SortContents(w io.Writer, files []string) error {
handlerAny := func(err error) error {
return fmt.Errorf("process: %v", err)
}
lines := []strings{}
for _, file := range files {
handlerFiles := func(err error) error {
return fmt.Errorf("read %s: %v ", file, err)
}
scan := bufio.NewScanner(os.Open(file) ? handlerFiles)
for scan.Scan() {
lines = append(lines, scan.Text())
}
scan.Err() ? handlerFiles
}
sort.Strings(lines)
for _, line := range lines {
io.WriteString(w, line) ? handlerAny
}
}
Let’s show another example from the proposal (slightly simplified) that has handler composition:
func process(user string, files chan string) (n int, err error) {
ahandler := func(err error) error { return fmt.Errorf("process: %v", err) }
for i := 0; i < 3; i++ {
bhandler := func(err error) error { return fmt.Errorf("attempt %d: %v", i, err) }
do(something()) ? ahandler.ThenErr(bhandler)
}
do(somethingElse()) ? ahandler
}
It is possible to combine handlers in the same way one would combine functions:
do(something()) ? ahandler.ThenErr(func(err error) error {
return fmt.Errorf("attempt %d: %v", i, err) }
)
Or
do(something()) ? func(err error) { return ahandler(bhandler(err)) }
The example uses a .ThenErr method (see appendix) as a way to compose error handler functions together.
Results
- This alternative proposal introduces just one special construct,
? - The programmer has control and flexibility in the error handling.
- Handlers can be naturally composed as functions
- The code is much more succinct and organized than current go error handling code.
- errors can be returned from
defer.
Checking error returns from deferred calls
This alternative proposal can support returning errors from defer:
defer w.Close() ? closeHandler
Notes on error handler function types
To respond to errors we want to do one of two things:
- cleanup (side-effecting):
(error) -> nilor() -> nil - modify the error:
(error) -> error
An error handler function must always have the type of the modifier, but we may not want the extra noise when writing a purely cleanup handler. The question mark operator can accept all forms. A cleanup function can be automatically converted to return the original error that would have been passed to it.
This is also true of helpers that compose error functions such as ThenErr.
See the Appendix section on ThenErr to see how this is implemented.
Appendix
Appendix: Handle and anonymous function syntax
This proposal is slightly more verbose than others that introduce a special anonymous function syntax that is lighter-weight and infers types.
handle err { return fmt.Errorf("process: %v", err) }
Without this syntax, the proposal would read:
handle func(err error) error { return fmt.Errorf("process: %v", err) }
I think it is worthwhile to explore having anonymous functions that are lighter-weight. However, I think this should be usable anywhere rather than just with a single keyword.
But please leave this for another proposal rather than throw it in the mix with error handlers!
Appendix: unary and binary.
The question mark operator can be used as a unary to just return the exception without any handlers running.
something()?
This is equivalent to
something() ? func(err error) error { return err }
I am favoring writing the unary form without any spaces in this case (more similar to Rust), but we should use whatever syntax the community finds best.
Appendix: Handling errors within the handler itself
A cleanup handler may generate a new error that should be propagated in addition to the current error. I believe this should just be handled by a multi-error technique, e.g. multierr.
Appendix: custom error types
The existing proposal seems like it would cast a concrete error type to the error interface when it is passed to a handler.
I don’t think this proposal is fundamentally different.
I think this issue should be solved by the generics proposal.
Appendix: ThenErr and ToModifyErr
An implementation of ThenErr and ToModifyErr. See the syntax expansion section for how the ? operator uses ToModifyError.
type Cleanup func(error)
type CleanupNoError func()
type ModifyError func(error) error
type ToModifyError interface {
ToModifyError() ModifyError
}
func (fn1 ModifyError) ToModifyError() ModifyError {
return fn1
}
func (fn1 CleanupNoError) ToModifyError() ModifyError {
return func(err error) error {
fn1()
return err
}
}
func (fn1 Cleanup) ToModifyError() ModifyError {
return func(err error) error {
fn1(err)
return err
}
}
// Its easier to write this once as a function
func CombineErrs(funcs ...ToModifyError) ModifyError {
return func(err error) error {
for _, fn := range funcs {
err = fn.ToModifyError()(err)
}
return err
}
}
// But method syntax is convenient
type ErrorHandlerChain interface {
ThenErr(ToModifyError) ModifyError
}
func (fn1 ModifyError) ThenErr(fn2 ToModifyError) ModifyError {
return func(err error) error {
return fn1(fn2.ToModifyError()(err))
}
}
func (fn1 Cleanup) ThenErr(fn2 ToModifyError) ModifyError {
return func(err error) error {
fn1(err)
return fn2.ToModifyError()(err)
}
}
func (fn1 CleanupNoError) ThenErr(fn2 ToModifyError) ModifyError {
return func(err error) error {
fn1()
return fn2.ToModifyError()(err)
}
}
Appendix: Operator versus check function
The original proposal rejected the question mark and gave some reasons why. Some of those points are still valid with this proposal, and others are not.
Here is another proposal that I believe advocates the same solution proposed in this alternative, but with a check function. I would be happy with that as a solution, but below I give my preference for ?.
The original proposal had just one argument given to check. This alternative favors the question mark in large part because there are now 2 arguments.
The original proposal states that there is a large readability difference in these two variants:
check io.Copy(w, check newReader(foo))
io.Copy(w, newReader(foo)?)?
However, I think this is a matter of personal preference. Once there is a left-hand-side assignment, the readability opinion may also change.
copied := check io.Copy(w, check newReader(foo))
copied := io.Copy(w, newReader(foo)?)?
Now lets add in a handlers and check our preference again.
copied := check(io.Copy(w, check(newReader(foo), ahandler), bhandler)
copied := io.Copy(w, newReader(foo) ? ahandler) ? bhandler
I believe ? will be slightly nicer to use due to
- fewer parantheses
- putting error handling solely on the right-hand-side rather than both the left and right.
Note that it is also possible to put all the error handling on the left-hand-side of the error source.
copied := check(bhandler, io.Copy(w, check(ahandler, newReader(foo)))
But I prefer keeping error handling on the right-hand-side for two reasons
- a success result is still transferred to the left
- it is possible to write an anonymous handler rather than being forced to declare it ahead of time
Appendix: built-in result type
A go programmer that has used Rust, Swift, Haskell, etc will be missing a real result type.
I would like to see a go 2 proposal for discriminated unions which includes a result type.
However, I think both the original proposal and this alternative proposal would work fine with the addition of a result type.
This is because go effectively already has a result type when dealing with errors. It is a tuple where the last member is of type error.
A future version of go with discriminated unions should be able to use ? for dealing with a discriminated union result type.
Appendix: intermediate bindings for readability
Error handling on the right-hand-side may increase line length undesirably or seem to be easy to miss. Its always possible to use an intermediate binding.
v, err := f(...) // could be a million lines long
err ? handler
Appendix: left-hand-side
It is possible to support placing the handler on the left-hand-side.
v := handler ? f(...)
This could make more sense for check. One of the ideas behind this would be to emphasize the handler, for example in the case where f(...) is an enormous expression (see above section on intermediate bindings which is another way to handle this).
Appendix: returning the zero value
This proposal does not allow for the defensive practice of returning -1 as the success value, along with the error. Where -1 is useful because zero or a positive number are an allowed value in the problem domain, so someone may notice a -1 propagating. I don’t think we need to support this use case for a few reasons:
- It is not generally applicable anyways (consider a
uint). - The contract of using the function is already that errors must be checked before looking at success values.
- There are standard linters (errcheck) that will warn people about ignoring errors: we should instead ship this ability with
go vet.
Appendix: all proposal examples re-written
Below are the rest of the code snippets shown in the original proposal, transformed to this alternative proposal.
func TestFoo(t *testing.T) {
handlerFatal := func(err error) { t.Fatal(err) }
for _, tc := range testCases {
x := Foo(tc.a) ? handlerFatal
y := Foo(tc.b) ? handlerFatal
if x != y {
t.Errorf("Foo(%v) != Foo(%v)", tc.a, tc.b)
}
}
}
func printSum(a, b string) error {
handler := func(err error) error { fmt.Errorf("printSum(%q + %q): %v", a, b, err) }
x := strconv.Atoi(a) ? handler
y := strconv.Atoi(b) ? handler
fmt.Println("result:", x + y)
return nil
}
func printSum(a, b string) error {
fmt.Println("result:", strconv.Atoi(x)? + strconv.Atoi(y)?)
return nil
}
func CopyFile(src, dst string) error {
handlerBase := func(err error) error {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
r := os.Open(src) ? handlerBase
defer r.Close()
w := os.Create(dst) ? handlerbase
handlerWithCleanup := handlerBase.ThenErr(func(err error) {
w.Close()
os.Remove(dst) // (only if a check fails)
})
check io.Copy(w, r) ? handlerWithCleanup
check w.Close() ? handlerWithCleanup
return nil
}
func main() {
handlerAll := func(err error) error {
log.Fatal(err)
}
hex := check ioutil.ReadAll(os.Stdin) ? handlerAll
data := check parseHexdump(string(hex)) ? handlerAll
os.Stdout.Write(data)
}
About this issue
- Original URL
- State: closed
- Created 6 years ago
- Reactions: 5
- Comments: 55 (7 by maintainers)
@gomarcus, I encourage you to paste your first comment here into a gist linked from the feedback wiki, as it’s mostly a critique of the error handlers concept, not the substance of this proposal 😃
@deanveloper, this issue isn’t a forum to debate a commenter who took issue with handlers 😃
Regarding @gomarcus’ critique of handlers, I have tried to document all the possible requirements for a new errors idiom (and thus benefits) on golang-dev.
I would hereby like to follow @freman’s line of reasoning and would like to express my dislike of the new
error handlerproposals. Personally, I still consider the central principles of GO to be extremely precious and fear that the demands for syntactic sugar only lead to a dilution of the previous clarity and purity. The manual and explicit error checking/handling is in my opinion one of GO’s core strengths.Currently,
type erroris an interface like any other. A special syntax for handling errors would be a fundamental change.Interesting observation Many who start learning GO complain about the repetitive explicit error checking, but most get used to it and soon appreciate it in the vast majority of cases.
cases:
Should the repeated use of
if err != nil {…}be a visually disturbance for some users… a different color scheme in the editor could easily solved this problem for them…Should the introduction of
error handlerbe focused on enrichment of error information… It may be better to think about why the received error messages are incomplete. It might be beneficial to improve the code of the error returning function, instead of creating new syntax to iron out the initial fault.@leafbebop argued that
error handlermay improve chaining. And even though this may be true in some cases, I would like to question the premise here. In my opinion, chaining does not necessarily result in less writing effort, more comprehensible structures nor easy to maintain program code. → In addition, chaining is only possible if all functions involved have a exact argument order. This would result in chaining being used in some cases and the conventional way in others, creating two parallel paradigms.The proposal of @gregwebs suggests that one of the goals is reducing the program code and/or the writing effort for the programmer, as well as a new syntactic expression for reformatting a received error. → It is particularly difficult for me to follow this reasoning, because it is already optional to use a formatting function and also the amount / readability of the program code is not improved. → The proposal introduces a new function type
type errorHandler func(error)error. However the proposal seems to disregard that such a formatting function has only a limited selection of information, which is a bad prerequisite as a formatting function (onlye). Unless such a function would be specially incorporated in the calling function and get access to all variables (with all disadvantages). → The use oferrorHandler’s not only changes the language appearance, but also the reading flow. This effect is reinforced byerrorHandler’s defined outside of the calling function body.Current
proposal of @gregwebs
The confusion becomes more evident when different formatting functions are called. Current
proposal of @gregwebs
Syntax highlighting is never a solution to a problem and a programming language should NOT rely on syntax highlighting to make it readable.
Here are a few lines of code peeled from the draft:
15 lines of error handling code, 5 lines of “what we want to do” code. That’s why it’s visually unappealing - there is way too much boilerplate and code repetition.
Go is all about writing programs that do what we want them to do, and it gets a lot harder to do that when we need at least 3 lines of code on every function call that even has a chance of returning an error.
But I want to code in the Go language, not the Rust language - or I’d be coding in the Rust language 😃
Are our goals to be like other languages? Error handling Go results in the same questions and issues every time a new one of our devs picks it up. Over time, so long as they actually care to improve their craft they usually come to find it refreshing, especially as they learn of better ways of dealing with the errors.
Sure it’s not entry level easy, and it can be repetitive… but visual basic has
on error resume nextwhich has to be the easiest least repetitive way toignore errorscreate unreliable code beyond underscoring them in Go.If I want more sugar, I can go back to perl where half of what I write in a maintainable way in Go can be squeezed into one line of code.
That’s not what I’m trying to say here - what I’m saying is that the zero-value of
intis meaningful in theCountOccurencesfunction, so I would much rather return a-1to make it clear that the function doesn’t return meaningful information if an error occurs.I want to be clear. I don’t want the caller to see
0, err, as it could be mistaken for “zero occurrences were found before finding the following error”, I want them to see values from the function indicating that the function does not return useful information (other than the error) if an error occurs, which can be done by returning-1, err.Most of the time
0, errworks, but in my experience, returning-1, erris not an uncommon caseThanks @networkimprov for trying to keep the discussion on track: I would like to keep it focused on the proposal at hand. I see one point that is quite relevant to address: Is it bad to introduce this error handler concept if it doesn’t scale down to simple usage? That is, if I don’t need a shared handler, would I use this “improved” error handling? This is the benefit of the handler being a second argument to the check: you can write your handler inline without the overhead of naming it or registering it. So my version given in the above examples would still be:
I don’t think this is inherently better than doing the traditional
err != nil, but it is something you could do if you wanted to consistently do error handling the same way. Note that if you are not just side-effecting but also return error values, the?will generate zero values for you also.But this example is quite contrived as is: the real way you would write it is
This is why I believe it is important to use normal functions as handlers: function composition is such a powerful tool.
It is probably possible to come up with an example more like the first that doesn’t benefit from handler helper functions. If go lang had a good anonymous function syntax (type inference and no
return), I would still prefer?.This is the most natural way for programmers who use the Rust language. There are some risks in introducing the ‘check/handle’ keywords.
I think our key goal is making the consequence of error handling more clear. When I read a
checkor?, I want to know two things:@gregwebs @ianlancetaylor