-
Notifications
You must be signed in to change notification settings - Fork 17.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
proposal: spec: reduce error handling boilerplate using "? return" and "? err" #71528
Comments
This comment has been minimized.
This comment has been minimized.
Since the usage of It is a disadvantage for the transition though. Because if the intention is to apply this automatically to as many places as possible ( |
Could you clarify if this would be permitted only at statement level, or could it be used inside expressions? E.g. x := &SomeStruct{
Field1: calc1() ? return,
Field2: calc2() ? return,
} For me, it's this sort of non-side-effecting but possibly failing logic where the current approach to error handling feels most egregiously verbose. To write the above currently, I need to either introduce variables duplicating the fields, or build the struct incrementally (increasing the risk of missing something if it grows more fields in future). |
@aksdb wrote:
You are correct that not rewriting this at all remains an option. I included that disadvantage mainly because Ian's proposal had a similar disadvantage and since I didn't fully solve it with my small changes it seemed dishonest to delete it entirely. With that said, I expect that in a hypothetical future world where documentation and books have all been updated to demonstrate this new approach as the primary/default way to handle errors that a newcomer would first try to write it with @jwebb wrote:
As with Ian's proposal, this is allowed only at the very end of assignment expressions and expression statements. It is not an expression operator and is not valid to use within a nested expression. For your example, it would still be necessary to use some temporary variables from earlier statements that handle the errors: field1 := calc1() ? return
field2 := calc2() ? return
x := &SomeStruct{
Field1: field1
Field2: field2,
} The ability for something in the middle of an expression to cause control flow other than returning a value or panicking was one of the repeated concerns in previous error handling proposals, and so Ian's proposal aimed to avoid that by restricting it to the whole-statement level and my modifications further reinforce the idea of "predictable control flow" by promising that the exceptional block will definitely prevent sequential execution of the following statement unless clearly marked with a |
These are, IMO, all improvements over Ian's proposal, if the scope of the proposal is kept the same. If the argument wins out that All in all, IMO, this is the best version of Ian's proposal. |
Seems like this proposal has reduced syntax as much as possible without creating an entirely unfamiliar experience in Go. It's still explicit enough that it's very easy to read the syntax, even for those that are unfamiliar. That being said, I still struggle with this and Ian's proposal in that, aside from reducing verbosity, I don't really feel it does a ton for improving error handling in Go in general. |
I'm somewhat concerned that if the syntax is I definitely concerned that the block after the os.Symlink(target, link) ? {
t.Fatal(err)
} With this proposal, that presumably would be written as os.Symlink(target, link) ? err {
t.Fatal(err)
panic("unreachable")
} That additional if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
} While I'm not personally fond of |
I thought that return values were just omitted from the example for brevity. I completely agree. If this syntax doesn't require specifying returned values e.g. |
In recent comments just above this one, @ianlancetaylor and @chad-bekmezian-snap both expressed similar reservations about using the Since this point is subjective I don't really have any significant argument about it. To my tastes, this seems like a second special case similar to the existing "naked return" special case which I am choosing to interpret as "return in a default way that makes sense for this context". For existing "naked return" it means "return with whatever values are currently assigned to the result parameters". For naked return after I can see that current "naked return" is only allowed in contexts where a non-naked return would also be allowed, and so I'm sympathetic to the idea of also allowing a normal, "non-naked" return immediately after SomethingThatMightFail() ? return nil, fmt.Errorf("doing something: %w", err) Personally at this point I find this one-liner is getting too long and would rather use the block form instead, but I can't argue against the fact that supporting an explicit-value return here is a use of the |
@ianlancetaylor dislikes the requirement for an explicit terminating statement:
I certainly acknowledge that this form of the proposal is less ideal for the typical idioms of test code, where (unlike in the rest of the language) we prefer to halt execution by directly terminating the test goroutine rather than by explicitly returning. It is unfortunate that testing idiom is different from normal code idiom in this way, but it's true nonetheless. I don't have any good answer to this complaint except the one I already mentioned as part of disadvantage 10: a subsequent, separate language change to explicitly model functions that are guaranteed not to return normally. But I do still trust your assertion that such a proposal is unlikely to be accepted for Go. Edit: After finding an earlier proposal for guaranteed-no-return functions that was declined in part because the existing language didn't make enough use of "terminating statements" for it to be worthwhile, and recognizing that this proposal introduces a new use of "terminating statements" that might change that calculus, I opened #71553 to see what happens. However, I must admit that I am not optimistic about it. 😀 |
I think it's wrong to make or require it to terminate. it should behave like a normal anonymous block and pashthrough automatically to next line. While not the most used pattern, sometimes "handling" an error means just setting some defaults or running a fallback action before resuming the normal flow. any error handling solution should not make those scenarios harder. |
@doggedOwl says:
I suppose it's a matter of opinion whether this qualifies as "harder", but FWIW the proposal does allow this pattern in return for writing somethingFallible() ? err {
log.Printf("failed to do the thing: %s", err)
fallthrough
} I would use essentially the same framing as you used in order to justify this: continuing immediately after the exception block is sometimes the right thing to do, but it's not the common case and so the proposal deprioritizes it by requiring an additional keyword. Prioritizing a control flow change as the default behavior means that a reader can ignore the details of what's in the block when reading the happy path, unless the extra keyword is present. Otherwise the reader must manually verify that every codepath within the block ends with a control flow statement. I see this as a similar tradeoff to what Go does with the |
The proposed solution of using a shorthand ? to handle errors seems a good idea at first sight, but is a small solution to the problem as it still creates boilerplate code of handling the errors individually. It only saves a few tokens which does not seem worth it compared to the proposed solution. I write large-scale api's in Go and the main problem is that handling every single error just doesn't make sense when writing handlers. I'm thinking of bubbling errors upwards: func main() {
// here we do handle the error
// if functionA returns an error, it will be bubbled upwards and return as an error in functionB
result, err := functionB()
if err {
panic(err)
}
fmt.Println(result)
}
func functionA() (int64, error) {
variableA := 10
// ... logic
return variableA, nil
}
func functionB() (int64, error) {
variableB := 10
variableB += functionA() // no error handling in the code
return variableB, nil
} If you want you can handle the error at the function level or bubble it upwards to the nearest error handling, and otherwise panic if none are found. I understand that this idea is probably not feasible, but excuse my lack of knowledge in the go codebase (yet). It may also be looking different, as in we won't specify the return type error but always add that automatically when writing functions as an optional return value. And when you want to explicitly return an error, you can just return it as the last parameter like we're used to. |
Hi @arjendevos, From your example I believe you are offering a counterproposal something like: in an assignment expression, make it valid for the left side to list one fewer than the number of items produced by the expression on the right side as long as the omitted item is of an interface type that implements If I understood correctly, I think that idea is interesting but it has a significant consequence: if we rely only on the number of values on the left side of the assignment as the signal of author intent for returning early with an error, it's not clear to me how that would be adapted to the case of a function that returns only an error, without any other results, since those could not be used in an assignment with one result omitted: then there would be zero items on the left side of the assignment. Aside from that concern, I also have some subjective concerns. I wrote this proposal in large part as responses to feedback on Ian's previous proposal that the return behavior wasn't explicit enough. I addressed that by adding the additional Out of curiosity I tried adjusting your example to use the features in my proposal: func main() {
result := functionB() ? err {
panic(err)
}
fmt.Println(result)
}
func functionA() (int64, error) {
variableA := 10
// ... logic
return variableA, nil
}
func functionB() (int64, error) {
variableB := 10
variableB += functionA() ? return
return variableB, nil
} |
In that case maybe only
That means you see that function can return an error but you decide to not handle it Having an empty |
The goal of the empty return is to avoid a parsing ambiguity with the optional block. |
Yes, I see... Then like this:
or like this:
If we would have syntax like this:
we could just skip err variable, like we now do
|
@apparentlymart Thanks for your detailed answer. I mostly agree and don't think my proposed solution is in line with go. Your solution does make it simpler but i'm still not convinced it's better because it breaks the conventional syntax. ? is in most languages used to signal as an if but for nullable values, although i'm aware error is also a nullable value it still doesn't feel intuitive to me and feels weird to write in a codebase without ":" for example. I came across the "guard" proposal (#31442), which is in my opinion a better solution as it doesn't change the syntax of golang and makes sense in the scope of golang. This would result in something like this: func main() {
result := functionB() ? err {
panic(err)
}
fmt.Println(result)
}
func functionA() (int64, error) {
variableA := 10
// ... logic
return variableA, nil
}
func functionB() (int64, error) {
variableB := 10
variableB += guard functionA()
return variableB, nil
} |
It is true that the extra token after It's also true that I chose For what it is worth, Go already allows a (See also #71528 (comment) for my response to some earlier feedback about the naked |
Hi all, Earlier discussion suggested that this proposal could only be sufficiently ergonomic if something like #71553 were also accepted, but that proposal was unpopular and so therefore this proposal seems non-viable and so I'm going to retract it. Thanks for the discussion! |
Thanks for exploring this space. |
The following is a language proposal that is strongly inspired by Ian's earlier proposal for error handling using
?
, but with some slight modifications that I hope will help to address some of the most commonly-raised concerns in the discussion of Ian's proposal.At this point Ian's proposal discussion has become very large and hard to consume for a newcomer, so for ease of consuming this the following are the specific complaints from that that I'm aiming to address here:
The shorthand
?
syntax without an explicit exceptional block seems easy for a human to miss when trying to scan the body of a function to find all of its exit points.Making the error handling easier to ignore when you want to was one of the goals of the original proposal, and so this modified proposal aims to find a compromise that still pushes all of the error-handling content to the right but hopefully makes it still stand out a little more than in the original proposal: it now includes a mandatory
return
keyword for that case in addition to the question mark.Ian's proposal included an implicit declaration of a local symbol called
err
in the exceptional block. However, the discussion also included a sub-thread with voting for two other possibilities: a predeclared identifier callederv
, or an additional identifier before the block to declare the name explicitly.The second of those alternatives has a plurality of votes at the time I'm writing this, so I've adopted it here.
Ian's proposal discussion had a sub-thread for the question of whether it should be invalid for control to implicitly "fall through" the end of an exceptional block, and that idea seemed pretty popular based on the discussion and voting.
This proposal adopts that requirement and makes a specific proposal for how it might be specified as a compile-time check.
As with Ian's discussion, let's please use this only to discuss advantages and disadvantages of this proposal and how they might be directly addressed by modifying this proposal, rather than discussing entirely different design ideas here. It's admittedly tough sometimes to differentiate between "new proposal" and "refinement of this proposal" but for the sake of this request I'd like to assert that any new idea that doesn't aim to meet all of the design goals mentioned throughout is off-topic for this discussion.
I have created this as an issue instead of a discussion because only the Go team can create new discussions, but I expect to quickly regret that decision since any proposal related to error handling tends to quickly attract a high volume of comments, many of which are redundant due to GitHub's poor handling of long issue discussions.
I know it's hard, but please try to make sure that any comment you are adding is adding new information that wasn't already covered by an earlier comment and could not be represented simply as an emoji reaction. In particular, "I think Go's current error handling is fine and no changes are needed" is best represented by voting 👎; no comment is needed because I am already very familiar with all of the objections of that kind from previous discussions. For what it's worth, I am also pretty skeptical that a change is needed, but I also acknowledge that the user study run by Todd Kulesza found that at least some newcomers stumble reading and understanding the current idiom.
I am nowhere near as familiar with the Go source code rewriting facilities as Ian, so I have not produced a tool to automatically implement these changes nor a copy of the standard library with the changes applied. For now I hope that this proposal is similar enough to Ian's that it's possible for a reader to mentally translate Ian's standard library patch series into the slightly-modified equivalents for this proposal. As before, let's please try to keep the discussion focused on real examples of code someone might want to write, rather than on highly contrived hypotheticals.
Background
The goal of this proposal is to introduce a new syntax that improves the readability and conciseness of code that checks errors, without obscuring control flow, and with an explicit distinction between mainline code that is "on the left" and exceptional code that is "on the right" or indented.
I readily acknowledge the various complaints in the earlier discussions asserting that Go's current error handling is fine already, or that returning errors without wrapping them is a "code smell", etc, but this proposal does not aim to address those concerns. If you did not like Ian's proposal for these reasons then this proposal does not substantially change those tradeoffs and so there is no need to repeat all of those objections here: a 👎 vote on this comment would suffice to represent that.
Refer to the previous proposal's discussion for more background information. This proposal is strongly inspired by Ian's and I have made only small changes to it here, based on the three specific concerns I enumerated above.
New syntax
This section is an informal description of the proposal, with examples. A more precise description appears below.
I propose permitting statements of the form:
to be written as:
The
?
absorbs the error result of the function. It introduces a new exceptional block, which is executed if the error result is notnil
. Within the new block, the identifier declared immediately after the?
token refers to the absorbed error result.Similarly, statements of the form:
may be written as:
Further, I propose a shorthand form which replaces the optional block and its declaration identifer with the
return
keyword. In this form it acts as though there were a block that simply returns the error from the function. For example, code like:may in many cases be rewritten as:
Formal proposal
This section presents the formal proposal.
An assignment or expression statement may be followed by a question mark (
?
). The question mark is a new syntactic element, the first permitted use of?
in Go outside of string and character constants. The?
causes conditional execution similar to anif
statement.A
?
uses a value as described below, referred to here as the qvalue.For a
?
after an assignment statement, the qvalue is the last of the values produced by the right hand side of the assignment. The number of variables on the left hand side of the assignment must be one less than the number of values produced by the right hand side (the right hand side values may come from a function call as usual). It is not valid to use a?
if there is only one value on the right hand side of the assignment.For a
?
after an expression statement the qvalue is the last of the values of the expression. It is not valid to use a?
after an expression statement that has no values.The qvalue must be of an interface type and must implement the predeclared type
error
. That is, it must have the methodError() string
. In most cases it will simply be of typeerror
.A
?
may be followed by either an identifier or by thereturn
keyword. Thereturn
keyword is valid only if the statement using?
appears in the body of a function, and the enclosing function has at least one result, and the qvalue is assignable to the last result. (This means that the type of the last result must implement the predeclared typeerror
, and will often simply beerror
.)Execution of the
?
depends on the qvalue. If the qvalue isnil
, execution proceeds as normal, skipping over the exceptional block if there is one.If the
?
is followed byreturn
, and the qvalue is notnil
, then the function returns immediately. The qvalue is assigned to the final result. If the other results (if any) are named, they retain their current values. If they are not named, they are set to the zero value of their type. The results are then returned. Deferred functions are executed as usual.If the
?
is followed by an identifier -- the error variable name -- then the identifier must in turn by be followed by a block called the exceptional block. If the qvalue is notnil
in this case then the block is executed. Within the block a new variable whose name matches the error variable name is declared, possibly shadowing a variable of the same name from an ancestor scope. The value and type of this variable will be those of the qvalue. The new variable is not available to the parent scope of the exception block.It is a compile-time error if the exceptional block does not end with either a terminating statement or a
fallthrough
statement explicitly representing intent to continue execution with the first following after the block. Control flow statements inside the exceptional block behave equivalently to how they are specified in the current language specification with the exceptional block treated as the block from anif
statement.1Discussion
This new syntax is partly inspired by Rust's question mark operator, though Rust permits
?
to appear in the middle of an expression and does not support the optional block. Also, I am suggesting that gofmt will enforce a space before the ?, which doesn't is not how Rust is normally written. Rust'slet .. else
is similar to the form with an optional block, except that it does not allow the program to access the error value.Absorbing the error returned by a function, and optionally returning automatically if the error is not
nil
, is similar to the earlier try proposal. However, it differs in that:?
is an explicit syntactic element, not a call to a predeclared function, and?
may only appear at the end of two specific kinds of statement, and not in the middle of an expression.Declaring the error variable name
An identifier immediately after the
?
symbol explicitly names the variable used to represent the error in the exceptional block. This is intentionally different to Ian's proposal which instead always implicitly chose the nameerr
. Based on discussion from Ian's proposal, I hope that this one additional token remains true to the goal of reducing boilerplate while mitigating some of the concerns around implicit shadowing and of how code intelligence tools would interact with a local variable that has no explicit declaration site.In practice, the variable will almost always be named
err
. However, authors working in codebases where shadowing is discouraged or prohibited may choose to use a different name whenever there is already a variableerr
in the parent scope, and in particular in the (hopefully-rare) situation where one exceptional block is nested inside another.This proposal explicitly rejects Ian's alternative of a predeclared identifier named
errval
orerv
due to concerns in the previous discussion that authors are likely to be confused by something which appears on initial inspection to be a local variable but actually behaves as a global, based in particular on experience with similar constructs in other languages such as JavaScript'sthis
symbol. A local variable that is explicitly named in the program is a concept already used in many other parts of Go and so most likely to be understood by a newcomer.I'm hopeful that even when using the common generic name
err
the presence of that name will help an unfamiliar reader who is already somehow familiar with that name being an abbreviation for "error" (either from previous Go experience or from experience in another language with a similar naming convention) to more quickly infer that this new language feature represents error handling.Pros and cons
(For the advantages and disadvantages that are common between this proposal and Ian's previous proposal I have retained Ian's numbering just so that we might be able to cross-reference the feedback from the previous discussion that this proposal has or has not addressed. This means that there are some gaps in the numbering, which are intentional.)
Pros
Advantage 1: Rewriting
to
reduces the error handling boilerplate from 9 tokens to 6, and 3 boilerplate lines to 2.
Rewriting
to
reduces boilerplate from 9 tokens to 2, and 3 boilerplate lines to 0.
Advantage 2: This change turns the main code flow into a straight line with no intrusive
if err != nil
statements and no obscuringif v, err = F() { ... }
statements. All error handling either disappears or is indented into a block.Advantage 3: That said, when a block is used the
}
remains on a line by tself, unindented, as a signal that something is happening. (This is also listed as a disadvantage below.)Advantage 4: Unlike the try proposal and some other earlier error handling proposals, there is no hidden control flow. The control flow is called out by an explicit
?
symbol that can't be in the middle of an expression, followed by either a code block or areturn
keyword to make it more visible.Advantage 5: To some extent this reduces a couple of common error handling patterns to just one, as there is no need to decide between the
if v, err = F(); err != nil
form and thev, err := F(); if err != nil
form.Instead, people can consistently write:
Advantage 6: Someone who is intending to focus only on the main code (the "happy path") and disregard the details of the error handling can more readily assume that a
?
always diverts control flow somewhere other than the statement following the one containing?
, unless there is an exception block and it ends with thefallthrough
keyword.Subjectively, this reduces the cognitive load of reviewing each error-handling branch at least enough to determine whether it diverts control flow, and thus the reader can assume that whatever represents "success" for the operation in question is definitely true when reviewing the following statements, at least for otherwise-linear code within a single block.
Cons
Disadvantage 1: This is unlike existing languages, which may make it harder for newcomers to understand. As noted above, it is cosmetically similar to the Rust
?
operator, but still different. However, it may not be too bad: Todd Kulesza ran a user study and discovered that people unfamiliar with the syntax were able to see that code from Ian's earlier proposal had to do with error handling, and this proposal only slightly modifies Ian's proposal.Disadvantage 2: The benefits of this proposal are not so clear in more complicated situations where the error must be stored somewhere other than an exception-block-scoped local variable.
For example, in
fmt/scan.go
:In this example the assignment
err = nil
must change theerr
variable that exists outside of thefor
loop. Using the?
symbol would force declaring a new local variable inside the block that is separate from the outer one, and a newcomer naively applying the usual pattern would probably useerr
for their error variable name and inadvertently shadow theerr
from the containing scope.To successfully rewrite this example the author must choose a different name for either the parent scope's
error
or the exception block'serror
:(Note for this comparing this to Ian's earlier proposal: the new ability to explicitly select a variable name for the exception block has at least introduced a potential workaround that doesn't involve choosing a less-conventional name for a named return value that might be part of a function's documented signature.)
Disadvantage 3: When using an exception block, the
}
remains on a line by itself, taking up vertical space as pure boilerplate. (This is also listed as an advantage above.)(Disadvantages 4 and 5 from Ian's proposal do not seem to apply to this one. They both related to concerns caused by the exception block being optional, but the requirement for the
return
keyword in the shorthand case both avoids the accidental addition of a newline changing a valid program into another valid program with a different meaning, and hopefully makes the shorthand case more visible on the page by allowing editors/etc to highlight it as an already-familiar keyword.)Disadvantage 6: This proposal has no support for chaining function calls, as in
F().G().H()
, whereF
andG
also have an error result.Disadvantage 7: This proposal makes it easier to simply return an error than to annotate the error, by using the
? return
form with no exception block. This may encourage programmers to skip error annotations even when they are desirable.(Some in the previous discussion asserted that error annotations are always desirable, and that a lack of annotation is always an antipattern or code-smell. I find that framing too reductive and think this tradeoff has considerably more nuance, but I acknowledge and respect that others differ.)
Disadvantage 8: We really only get one chance to change error handling syntax in Go. We aren't going to make a second change that touches 1.5% of the lines of existing Go code. Is this proposal the best that we can do?
Disadvantage 9: We don't actually need to make any changes to error handling. Although it is a common complaint about Go, it's clear that Go is usable today. Perhaps no change is better than this change. Perhaps no change is better than any change.
Disadvantage 10: As compared to Ian's previous proposal, the requirement that the end of the exception block be unreachable requires a redundant control flow statement in any situation involving a function that is documented not to return, because the Go language spec does not currently include any concept of a function that is guaranteed not to return.
For example, in test code using
t.Fatalf
:Under this proposal, if that situation were to become a concern then a separate proposal would need to somehow solve for the compiler recognizing calls to
t.Fatalf
(and many other functions with this characteristic, likeos.Exit
andlog.Fatal
) as another kind of terminating statement, which implies a rather complicated interaction between library code and the language specification that is perhaps unlikely to be accepted2.(Ian proposed addressing this by handling this problem at runtime rather than compile time, such as by panicking if control reaches the end of an exceptional block. I am concerned that an automatic panic in a codepath already associated with error handling will mislead newcomers that this automatic panic is a desirable way to respond to the error, particularly if their previous experience is with languages that use structured exception handling as the primary error handling concept.)
Transition
If we adopt this proposal, we should provide tools that can be used to automatically rewrite existing Go code into the new syntax. Not everyone will want to run such a tool, but many people will. Using such a tool will encourage Go code to continue to look the same in different projects, rather than taking different approaches. This tool can't be gofmt, as correct handling requires type checking which gofmt does not do. It could be an updated version of
go fix
. See also modernizers.We will need to update the go/ast package to support the use of
?
, and we will need to update all packages that use go/ast to support the new syntax. That is a lot of packages.We will also need to update the introductory documentation and the tour, and existing Go books will be out of date and will need updating by their authors. The change to the language and the compiler is likely to be the easiest part of the work.
Footnotes
I recall that there is an existing proposal somewhere for a shorthand form of
return
that allows automatically zeroing all but the final result values of the function, using syntax likereturn ..., err
.Although that is not a part of this proposal, I think it would complement this proposal well by making an error-wrapping exceptional block focus only on the error and not include distracting expressions for the other results:
↩The earlier proposal https://github.com/golang/go/issues/69591 was declined, but it remains to be seen whether this proposal would constitute new information that would warrant revisiting that decision. I've opened https://github.com/golang/go/issues/71553 to find out. ↩
The text was updated successfully, but these errors were encountered: