go: proposal: check - a minimal error handling improvement for readability
Author: I have been programming in Go since before the 1.0 release, have also used C/C++, java and python and other languages professionally.
Background: There have been many proposals regarding error handling, such as https://github.com/golang/go/issues/32437 which this proposal have some similarities with. Most Go programmers agree that something could be done to improve error handling, but so far no proposal has been accepted.
What makes this proposal different: In my opinion, current error handling in Go have these 3 problems:
- verbose
- err variable pollutes the local namespace
- happy path and error handling are interleaved, making code (IMO) less readable
Most error handling proposals have focused on solving problem 1 - this proposal aims to solve all 3 problems. If this proposal is not accepted (statistically likely given the history of error handling proposals š I at least hope that it will influence other proposals to pay more attention to problem 2 and 3.
Example: Letās take this example code which IMO illustrates the 3 problems mentioned above:
func readFile(name string) ([]byte, error) {
f, err := Open(name) // err unnecessarily pollutes local namespace
if err != nil { // can't indent this line
return nil, err
}
defer f.Close()
contents, err := readContents(f) // err variable is reassigned a new value
if err != nil { // can't indent this line
return nil, fmt.Errorf("error reading %s: %w", name, err)
}
// the validate call is in the middle of a lot of boilerplate, impacting readability
// here a new err variable shadows the existing one, a potential for confusion/bugs
if err := validate(contents); err != nil {
return nil, err
}
return contents, nil
}
Here is how the code would look like with this proposal accepted:
func readFile(name string) ([]byte, error) {
f := check Open(name)
defer f.Close()
contents := check readContents(f); err {
return nil, fmt.Errorf("error reading %s: %w", name, err)
}
check validate(contents)
return contents, nil
}
Further discussion: This proposal aims to:
- mimimize boilerplate
- avoid polluting local namespace with error variables
- keep the happy path to the left of each line, move error handling to indented blocks to improve readability
- the ācheckā keyword still makes it clear where the function can return
- keep it easy to handle errors when necessary, such as logging or wrapping them
Details: This is obviously not up to language spec standard, but I have tried to address all the specific details I could think of:
-
The check keyword can only be used as a top-level statement in a code block or on the right hand side of a assignment statement.
-
The check keyword is followed by a function call which has a last return parameter of type error OR an expression which has type error.
-
The resulting values of a check statement on a function call is the remaining return parameters without the last error return parameter.
-
If the check statement is applied to an expression with type error there is no resulting values and it is a compile error to try to use it on the right side of an assignment statement.
-
The function call/expression can be followed by a semicolon, a variable name and a code block. If the error value that was extracted by the check statement is not nil, the variable name will be assigned the error value and the code block will be executed. The variable is local to the check statements code block. If the error value is nil, no assignment will happen and the code block is not executed.
If the check statement does not have a code block then it behaves like this:
check f(); err {
return ..., err
}
Where ⦠is replaced with the zero values of the remaining return types of the enclosing function. If a return parameter in ⦠is a named returned parameter, the current value of that parameter is returned.
Omitting the error handling block is only allowed inside a function which has a last return parameter of type error.
Alternative 1a/b: Check could be replaced with ātryā. The semicolon could be replaced with else.
func readFile(name string) ([]byte, error) {
f := try Open(name)
defer f.Close()
contents := try readContents(f) else err {
return nil, fmt.Errorf("error reading %s: %w", name, err)
}
try validate(contents)
return contents, nil
}
Alternative 2: Alternatively a postfix operator could be used instead of the ācheckā keyword:
func readFile(name string) ([]byte, error) {
f := Open(name)?
defer f.Close()
contents := readContents(f)? err {
return nil, fmt.Errorf("error reading %s: %w", name, err)
}
validate(contents)?
return contents, nil
}
Alternative 3: Put the check keyword first on the line to simplify the grammar and maximize clarity of where the function can return.
func readFile(name string) ([]byte, error) {
check f := Open(name)
defer f.Close()
check contents := readContents(f); err {
return nil, fmt.Errorf("error reading %s: %w", name, err)
}
check validate(contents)
return contents, nil
}
About this issue
- Original URL
- State: closed
- Created a year ago
- Reactions: 18
- Comments: 22 (8 by maintainers)
I like the implicit non
nilchecks with the explicit return of the error from your proposal. I understand why you write the declaration of err but I think this could still be part of the normal variable declaration. The Ā“checkĀ“ could be tied to the variable instead of the function call, indicating that the variable will be nonnilcheckedThe following would be a āsmallerā language change, could work with other types than
errorand would still fullfill most of the goals of this proposal:Since it is impossible to confuse assignments and comparissons in declarations because you cannot compare a not declared variable. This could actually use the existing
ifkeyword instead ofcheck:It seems like the closest current form to whatās being proposed here is the
ifstatement with a āsimple statementā appearing before the predicate, like this part of the motivating example:The form above already confines the
errsymbol to the body of theifstatement, solving your problem statement 2, and the happy/error path interleaving seems the same as your proposal and so I think both this and your proposal are equal under problem statement 3.I believe the following is the closest equivalent to the above example under your proposal:
This has:
ifwithcheck.err != nilpredicate implied rather than explicit.errvariable declaration to after the semicolon, replacing the now-implied predicate.I would agree that thereās less boilerplate in this form than in the
ifstatement form I included above, and therefore it might represent an improvement to problem statement 1: āverboseā.A typical reason Iāve heard for not using this
if err := ...; err != nil {pattern is that to some readers it āhidesā the most important information ā the function call ā behind some other content. It therefore makes it harder to skim the code. I donāt personally have a strong opinion on that, but it does seem like your proposal reduces the amount of content appearing before the function call, reducing it to just the single keywordcheck. Perhaps with some time to grow accustomed to it Go readers would find that comfortable to skim.Based just on the above I donāt feel super convinced this is enough of an improvement to justify the exception in the language. I do agree that it is an improvement, but the improvement feels marginal by this criteria.
I think youāve buried the lede a bit in your proposal because what you suggested does introduce something new thatās materially different from the typical
ifstatement but that I didnāt quite lock onto until a second read: it allows declaring new symbols both in the parent scope (the non-error results) and the child scope (the error result) without requiring a separate declaration statement.I missed it on my first read because the example you chose for the
ifblock was a function that only returns anerror, but letās consider how myifstatement would look for yourreadContentsexample.I see two main ways to lay it out. The first would be to indent both the happy path and the error path so that neither appears in the parent scope:
Iām sure most would agree that the above is not idiomatic because we typically expect the happy path to be unindented and only the error branch to be indented.
The other way is to hoist one of the variable declarations out of the
if:Of course in practice Iād probably write this just like you wrote it in your original example, with the whole call on the line above the
ifstatement and onlyif err != nil, but Iāve written it this way because itās the closest I can get to meeting your criteria 2 (erronly being declared in the nested scope) with todayās Go. And of course this is only true if you exclusively write it this way, so that no other earlier statements have already declared anerrin the parent scope.One thing I quite like about your proposal then is that it quite neatly handles this āscope splittingā goal, allowing
contentsanderrto both be declared by the same statement but forerrto belong only to the child scope:I will say that the exact details of the syntax feel a little awkward to me ā it isnāt super clear that the
errafter the semicolon is effectively a variable declaration ā but I think the broad idea is interesting and does add something new to the language, albeit perhaps a relatively small thing. It is at least cosmetically similar to theswitchstatement form that has a simple statement embedded in it, likeswitch foo := bar(); foo {, but even in this case that terminalfoois an expression rather than a declaration.But I think itās probably more important to discuss what the desirable properties of a solution are than to debate the exact details of syntax, so I wonāt quibble too much about the syntax details. If it seems (to others in this discussion) like this āscope splittingā behavior is desirable then Iām sure there are various other possible syntax shapes to consider that would make different tradeoffs of terseness vs. similarity to existing language constructs.
Iām also not super convinced that this new capability is sufficiently useful to justify the extra language complexity, but I could imagine myself getting used to this pattern and enjoying it if it were in the language.
Why donāt make it as a directive ? like
//go:check {NilType Return Name}, this can accept a wide range and not just errors but any types that can have nil value. in codeSince
func() (err error) āerrācan either return the default or the value assigned to before//go:checkRegarding how this would be integrated into the language grammar - there are of course several possible ways to do it depending on how many places you would want to allow checks to be used. Given that one of the criticisms of proposal https://github.com/golang/go/issues/32437 was that try could be used in any expression and thus lead to very complicated code my suggestion would be the following:
ā;ā would be replaced with āelseā if that is considered preferable.
From a grammar point of view it would be cleaner to put the check keyword first in the statement to be more similar to āifā, āforā etc⦠in that case the grammar would be:
This is not as clean from a readability standpoint IMO, but it would on the other hand make check always be first on the line so that it becomes as visible as return.
Thanks, appreciate it!
Again the main argument is that this:
is more readable than this:
while also always keeping err variables local to the error handling scope, thanks.