Summary
A new try
construct is proposed that is designed specifically to eliminate if
-expressions commonly associated with error handling in Go. This is the only change in language. Authors support the use of defer
and standard library functions to enrich or wrap errors. This small extension is suitable for most scenarios, practically without complicating the language.
The try
construct is easy to explain, easy to implement, this functionality is orthogonal to other language constructs and is fully backward compatible. It is also extensible if we want it in the future.
The rest of this document is organized as follows: after a brief introduction, we give a definition of the built-in function and explain its use in practice. The discussion section reviews alternative suggestions and the current design. At the end, conclusions and an implementation plan with examples and a section of questions and answers will be given.
Introduction
At the last Gophercon conference in Denver, members of the Go team (Russ Cox, Marcel van Lohuizen) presented some new ideas on how to reduce the tediousness of manual error handling in Go ( draft design ). Since then we have received a huge amount of feedback.
As Russ Cox explained in his review of the problem , our goal is to make error handling more lightweight by reducing the amount of code devoted specifically to error checking. We also want to make writing error handling code more convenient, increasing the likelihood that developers will still devote time to correcting error handling. At the same time, we want to leave the error handling code clearly visible in the program code.
The ideas discussed in the draft draft are concentrated around the new unary check
statement, which simplifies the explicit verification of the error value obtained from some expression (usually a function call), as well as the declaration of error handlers ( handle
) and a set of rules connecting these two new language constructs.
Most of the feedback we received focused on the details and complexity of the handle
design, and the idea of โโa check
operator turned out to be more attractive. In fact, several members of the community took the idea of โโa check
operator and expanded it. Here are a few posts most similar to our offer:
- The first written proposal (known to us) to use the
check
construct instead of the operator was proposed by PeterRK in his post Key parts of error handling - Not so long ago, Markus proposed two new keywords,
guard
andmust
along with usingdefer
to wrap errors in # 31442 - Also pjebs suggested a
must
construct in # 32219
The current proposal, although different in detail, was based on these three and, in general, on the feedback received on the draft design proposed last year.
For completeness, we want to note that even more error handling suggestions can be found on this wiki page . It is also worth noting that Liam Breck came with an extensive set of requirements for the error handling mechanism.
Finally, after the publication of this proposal, we learned that Ryan Hileman implemented try
five years ago using the og rewriter tool and successfully used it in real projects. See ( https://news.ycombinator.com/item?id=20101417 ).
Built-in try function
Sentence
We suggest adding a new function-like language element called try
and called with a signature
func try(expr) (T1, T2, ... Tn)
where expr
means an expression of an input parameter (usually a function call) that returns n + 1 values โโof types T1, T2, ... Tn
and error
for the last value. If expr
is a single value (n = 0), this value must be of type error
and try
does not return a result. Calling try
with an expression that does not return the last value of type error
results in a compilation error.
The try
construct can only be used in a function that returns at least one value, and whose last return value is of type error
. Calling try
in other contexts leads to a compilation error.
Call try
with function f()
as in the example
x1, x2, โฆ xn = try(f())
leads to the following code:
t1, โฆ tn, te := f() // t1, โฆ tn, () if te != nil { err = te // te error return // } x1, โฆ xn = t1, โฆ tn // //
In other words, if the last error
type returned by expr
is nil
, then try
simply returns the first n values, removing the final nil
.
If the last value returned by expr
is not nil
, then:
- The
error
return value of the enclosing function (in the pseudocode above namederr
, although this can be any identifier or unnamed return value) receives the error value returned fromexpr
- there is an exit from the enveloping function
- if the enclosing function has additional return parameters, these parameters store the values โโthat were contained in them before the
try
call. - if the enclosing function has additional unnamed return parameters, the corresponding zero values โโare returned for them (which is identical to saving their original zero values โโwith which they are initialized).
If try
used in multiple assignments, as in the example above, and a non-zero error (hereinafter not-nil - approx. Per.) Is detected, the assignment (by user variables) is not executed, and none of the variables on the left side of the assignment does not change. That is, try
behaves like a function call: its results are available only if try
returns control to the caller (as opposed to the case with a return from the enclosing function). As a result, if the variables on the left side of the assignment are return parameters, using try
will result in behavior that is different from the typical code that is encountered now. For example, if a,b, err
are named return parameters of an enclosing function, here is this code:
a, b, err = f() if err != nil { return }
will always assign values โโto the variables a, b
and err
, regardless of whether the call to f()
returned an error or not. Contrary challenge
a, b = try(f())
in case of an error, leave a
and b
unchanged. Despite the fact that this is a subtle nuance, we believe that such cases are quite rare. If unconditional assignment behavior is required, you must continue to use if
expressions.
Using
The definition of try
explicitly tells you how to use it: a lot of if
expressions that check for an error return can be replaced with try
. For example:
f, err := os.Open(filename) if err != nil { return โฆ, err // }
can be simplified to
f := try(os.Open(filename))
If the calling function does not return an error, try
cannot be used (see the Discussion section). In this case, the error should in any case be processed locally (since there is no error return), and in this case, if
remains the appropriate mechanism for checking for errors.
Generally speaking, our goal is not to replace all possible error checks with a try
. Code that requires different semantics can and should continue to use if
expressions and explicit variables with error values.
Testing and try
In one of our earlier attempts to write a specification (see the design iteration section below), try
was designed to panic when an error occurs when used inside a function without a return error. This allowed using try
in unit tests based on the testing
package of the standard library.
As one of the options, it is possible to use test functions with signatures in the testing
package
func TestXxx(*testing.T) error func BenchmarkXxx(*testing.B) error
in order to allow the use of try
in tests. A test function that returns a nonzero error will implicitly call t.Fatal(err)
or b.Fatal(err)
. This is a small library change that avoids the need for different behaviors (return or panic) for try
, depending on the context.
One of the drawbacks of this approach is that t.Fatal
and b.Fatal
will not be able to return the line number on which the test fell. Another disadvantage is that we have to somehow change the subtests too. The solution to this problem is an open question; we do not propose specific changes to the testing
package in this document.
See also # 21111 , which suggests allowing example functions to return an error.
Error processing
The original draft design was largely about language support for wrapping or augmenting errors. The draft proposed a new keyword handle
and a new way to declare error handlers . This new language construct attracted problems like flies due to non-trivial semantics, especially when considering its effect on the flow of execution. In particular, the handle
functionality miserably crossed with the defer
function, which made the new language feature non-orthogonal to everything else.
This proposal reduces the original draft design to its essence. If enrichment or error wrapping is required, there are two approaches: attach to if err != nil { return err}
, or "declare" an error handler inside the defer
expression:
defer func() { if err != nil { // - err = โฆ // / } }()
In this example, err
is the name of the return parameter of type error
enclosing function.
In practice, we imagine such helper functions as
func HandleErrorf(err *error, format string, args ...interface{}) { if *err != nil { *err = fmt.Errorf(format + ": %v", append(args, *err)...) } }
or something similar. The fmt
package can become a natural place for such helpers (it already provides fmt.Errorf
). Using helpers, the definition of an error handler will in many cases be reduced to a single line. For example, to enrich the error from the copy function, you can write
defer fmt.HandleErrorf(&err, "copy %s %s", src, dst)
if fmt.HandleErrorf
implicitly adds error information. Such a construction is fairly easy to read and has the advantage that it can be implemented without adding new elements of the language syntax.
The main drawback of this approach is that the returned error parameter must be named, which potentially leads to a less accurate API (see the FAQ on this topic). We believe that we will get used to it when the appropriate style of writing code is established.
Efficiency defer
An important consideration when using defer
as an error handler is efficiency. The defer
expression is considered slow . We do not want to choose between efficient code and good error handling. Regardless of this proposal, the Go runtime and compiler teams discussed alternative implementation methods and we believe that we can make typical ways of using defer to handle errors comparable in efficiency to the existing โmanualโ code. We hope to add a faster implementation of defer
in Go 1.14 (see also ticket CL 171158 , which is the first step in this direction).
Special cases go try(f), defer try(f)
The try
construct looks like a function, and because of this, it is expected that it can be used anywhere where a function call is acceptable. However, if the try
call is used in the go
statement, things get complicated:
go try(f())
Here f()
is executed when the go expression is executed in the current goroutine, the results of calling f
are passed as arguments to try
, which starts in the new goroutine. If f
returns a nonzero error, try
is expected to return from the enclosing function; however, there is no function (and there is no return parameter of type error
), because the code is executed in a separate goroutine. Because of this, we propose disabling try
in a go
expression.
Situation with
defer try(f())
looks similar, but here the semantics of defer
mean that the execution of try
will be delayed until it returns from the enclosing function. As before, f()
evaluated when defer
, and its results are passed to the deferred try
.
try
checks the error f()
returned only at the very last moment before returning from the enclosing function. Without changing try
behavior, such an error can overwrite another error value that the enclosing function is trying to return. This is confusing at best, and at worst provokes errors. Because of this, we propose that you prohibit calling try
in the defer
statement defer
well. We can always reconsider this decision if there is a reasonable application of such semantics.
Finally, like the rest of the built-in constructs, try
can only be used as a call; it cannot be used as a value function or in a variable assignment expression as in f := try
(just as f := print
and f := new
are forbidden).
Discussion
Design Iterations
The following is a brief discussion of earlier designs that led to the current minimal proposal. We hope that this will shed light on the selected design solutions.
Our first iteration of this sentence was inspired by two ideas from the article โKey Parts of Error Handling,โ namely, using the built-in function instead of the operator and the usual Go function to handle errors instead of the new language construct. Unlike that publication, our error handler had a fixed signature func(error) error
to simplify matters. An error handler would be called by the try
function if there was an error before try
would exit the enclosing function. Here is an example:
handler := func(err error) error { return fmt.Errorf("foo failed: %v", err) // } f := try(os.Open(filename), handler) //
While this approach allowed the definition of effective user-defined error handlers, it also raised many questions that obviously did not have the correct answers: What should happen if nil is passed to the handler? Is it worth try
panic or regard this as a lack of a handler? What if the handler is called with a non-zero error and then returns a null result? Does this mean that the error is "canceled"? Or should an enclosing function return an empty error? There were also doubts that the optional transfer of an error handler would encourage developers to ignore errors instead of correcting them. It would also be easy to do the correct error handling everywhere, but skip one use of try
. Etc.
In the next iteration, the ability to pass a custom error handler was removed in favor of using defer
to wrap errors. This seemed like a better approach because it made error handlers much more noticeable in the source code. This step also eliminated all issues regarding the optional transfer of handler functions, but demanded that the returned parameters with the error
type be named if access was required (we decided that this was normal). Moreover, in an attempt to make try
useful not only within functions that return errors, it was necessary to make the behavior of try
context-sensitive: if try
used at the package level, or if it was called inside a function that does not return an error, try
automatically panicked when an error was detected. (And as a side effect, because of this property, the language construct was called must
instead of try
in that sentence.) The context-sensitive behavior of try
(or must
) seemed natural and also quite useful: it would eliminate many user-defined functions used in expressions initializing package variables. It also opened up the possibility of using try
in unit tests with the testing
package.
However, the context-sensitive behavior of try
was fraught with errors: for example, the behavior of a function using try
could quietly change (panic or not) when adding or removing a return error to the signature of the function. This seemed too dangerous a property. The obvious solution was to split the try
functionality into two separate must
and try
functions, (very similar to how it was suggested in # 31442 ). However, this would require two built-in functions, while only try
directly related to better error handling support.
Therefore, in the current iteration, instead of including the second built-in function, we decided to remove the dual semantics of try
and, therefore, allow its use only in functions that return an error.
Features of the proposed design
This suggestion is quite short and may seem like a step back compared to last year's draft. We believe that the selected solutions are justified:
First things first,
try
has exactly the same semantics of thecheck
statement proposed in the original with nohandle
. This confirms the fidelity of the original draft in one of the important aspects.
Choosing a built-in function instead of operators has several advantages. It does not require a new keyword like
check
, which would make the design incompatible with existing parsers. There is also no need to expand the syntax of expressions with a new operator. Adding a new built-in function is relatively trivial and completely orthogonal to other features of the language.
Using an inline function instead of an operator requires the use of parentheses. We should write
try(f())
instead oftry f()
. This is the (small) price we have to pay for backward compatibility with existing parsers. However, this also makes the design compatible with future versions: if we decide along the way that passing in some form an error handling function or adding an additional parameter totry
for this purpose is a good idea, adding an additional argument to thetry
call will be trivial.
As it turned out, the need to write brackets has its advantages. In more complex expressions with multiple
try
calls, parentheses improve readability by eliminating the need to deal with operator precedence, as in the following examples:
info := try(try(os.Open(file)).Stat()) // try info := try (try os.Open(file)).Stat() // try info := try (try (os.Open(file)).Stat()) // try
try
, : try
, .. try
(receiver) .Stat
( os.Open
).
try
, : os.Open(file)
.. try
( , try
os
, , try
try
).
, .. .
- . , . , , , .
conclusions
Go - , . , Go append
. append
, . , . , try
.
, , Go : panic
recover
. error
try
.
, try
, , โ โ , . Go:
- ,
try
- -
, , . if
-.
Implementation
:
- Go.
-
try
. , . . -
go/types
try
. . -
gccgo
. ( , ). - .
- , . , . .
Robert Griesemer go/types
, () cmd/compile
. , Go 1.14, 1 2019.
, Ian Lance Taylor gccgo
, .
"Go 2, !" , .
1 , , , Go 1.14 .
Examples
CopyFile
:
func CopyFile(src, dst string) (err error) { defer func() { if err != nil { err = fmt.Errorf("copy %s %s: %v", src, dst, err) } }() r := try(os.Open(src)) defer r.Close() w := try(os.Create(dst)) defer func() { w.Close() if err != nil { os.Remove(dst) // โtryโ } }() try(io.Copy(w, r)) try(w.Close()) return nil }
, " ", defer
:
defer fmt.HandleErrorf(&err, "copy %s %s", src, dst)
( defer
-), defer
, .
printSum
func printSum(a, b string) error { x := try(strconv.Atoi(a)) y := try(strconv.Atoi(b)) fmt.Println("result:", x + y) return nil }
:
func printSum(a, b string) error { fmt.Println( "result:", try(strconv.Atoi(a)) + try(strconv.Atoi(b)), ) return nil }
func localMain() error { hex := try(ioutil.ReadAll(os.Stdin)) data := try(parseHexdump(string(hex))) try(os.Stdout.Write(data)) return nil } func main() { if err := localMain(); err != nil { log.Fatal(err) } }
- try
, :
n, err := src.Read(buf) if err == io.EOF { break } try(err)
Questions and answers
, .
: ?
: check
handle
, . , handle
defer
, handle
.
: try ?
: try
Go . - , . , . , " ". try
, .. .
: try
try?
: , check
, must
do
. try
, . try
check
(, ), - . . must
; try
โ . , Rust Swift try
( ). .
: ?
Rust?
: Go ; , Go ( ; - ). , ?
, . , , , (package, interface, if, append, recover, ...), , (struct, var, func, int, len, image, ..). Rust ?
try
โ Go, , ( ) . , ?
. , , (, ..) . . , .
: ( error) , defer , go doc. ?
: go doc
, - ( _
) , . , func f() (_ A, _ B, err error)
go doc
func f() (A, B, error)
. , , , . , , . , , , -, (deferred) . Jonathan Geddes try()
.
: defer ?
: defer
. , , defer "" . . CL 171758 , defer 30%.
: ?
: , . , ( , ), . defer
, . defer
- https://golang.org/issue/29934 ( Go 2), .
: , try, error. , ?
: error
( ) , , nil
. try
. ( , . - ).
: Go , try ?
: try
, try
. super return
-, try
Go
. try
. .
: try , . What should I do?
: try
; , . try
( ), . , if
.
: , . try, defer . What should I do?
: , . .
: try
( catch
)?
: try
โ ("") , , ( ) . try
; . . "" . , . , try
โ . , , throw
try-catch
Go. , (, ), ( ) , . "" try-catch
, . , , . Go . panic
, .