go: proposal: Go2: introduce check keyword for errors on LHS of assignment

  • Would you consider yourself a novice, intermediate, or experienced Go programmer? Experienced

  • What other languages do you have experience with? C, C++, Assembly, Java, Python, Javascript, SML, Rust, Racket, Shell, SQL, LATEX.

  • Would this change make Go easier or harder to learn, and why? It would make Go harder to learn, since it is one more choice the user must make before handling an error. Hopefully though, it becomes the default. It is also the first instance of an identifier being a keyword only in certain contexts. This could be confusing in cases where forgetting to bind check to a variable turns it into a variable.

  • Has this idea, or one like it, been proposed before? The proposal that comes closest to this idea is #42318. Other similar issues are #32500 #32601 #32884 #33150.

  • If so, how does this proposal differ? 1. #42318 uses a special character to mark the variable, whereas this proposal uses a new keyword. 2. #42318 returns the zero value for unannotated variables, whereas this proposal relies on named return values to do the heavy lifting. 3. #42318 allows substituting a handler function for the annotation, whereas this proposal makes use of defer for handlers. 4. #32500 is far too broad in that it suggests panics, returns, and special operators, but doesn’t specify evaluation order. 5. #32884, #32601, and #33150 do not assign an identifier to the variable on the left-hand side, and don’t specify how that variable is mapped to the return value, leaving us to assume that it relies on some convention around the last return value being an error. They also use an operator instead of a keyword, which obscures the early exit that can happen. 6. #33150 introduces a separate mechanism for wrapping an error.

  • Who does this proposal help, and why? 1. It helps authors of Go source code avoid three extra lines of code in the common case where they early exit on an error. 2. It helps readers of Go source code follow a function’s core logic more easily, by collapsing the gap between two successive lines of logic. 3. Preserves the imperative style of Go source code. Unlike the try proposal, this proposal can’t be abused to favor function composition.

  • What is the proposed change? 1. A new keyword check is introduced. 2. The keyword is only valid on the left-hand-side of an assignment or short variable declaration. 3. The keyword is only valid if it precedes an identifier that refers to a variable of type error. 4. The keyword can only be used in a function that has named return values. 5. In all other cases, check is a normal identifier. This keeps the change backwards-compatible to Go1. 6. Given a function like this:

    func foo() (v1 T, err error) {
      x, check err := f()
    }
    

    is equivalent to the following code:

    func foo() (v1 T, err error) {
      x, err := f()
      if err != nil {
        return v1, err
      }
    }
    
  • Is this change backward compatible? Yes

  • Show example code before and after the change. Before

    func printSum(a, b string) error {
    	x, err := strconv.Atoi(a)
    	if err != nil {
    		return err
    	}
    	y, err := strconv.Atoi(b)
    	if err != nil {
    		return err
    	}
    	fmt.Println("result:", x + y)
    	return nil
    }
    

    After (normal return) https://play.golang.org/p/nYugCFk4o-Z

    func printSum(a, b string) (err error) {
    	x, check err := strconv.Atoi(a)
    	y, check err := strconv.Atoi(b)
    	fmt.Println("result:", x + y)
    	return nil
    }
    

    After (return with additional context) https://play.golang.org/p/zaTO6KjUXmF

    func printSum(a, b string) (err error) {
    	defer func() {
    		if err != nil {
    			err = fmt.Errorf("printSum: %w", err)
    		}
    	}()
    	x, check err := strconv.Atoi(a)
    	y, check err := strconv.Atoi(b)
    	fmt.Println("result:", x + y)
    	return nil
    }
    

    After (with a hypothetical error wrapper) https://play.golang.org/p/LQCn_Nj_I4C

    func printSum(a, b string) (err error) {
    	defer errors.Wrap(&err, "printSum")
    	x, check err := strconv.Atoi(a)
    	y, check err := strconv.Atoi(b)
    	fmt.Println("result:", x + y)
    	return nil
    }
    
  • What is the cost of this proposal? (Every language change has a cost).

    • How many tools (such as vet, gopls, gofmt, goimports, etc.) would be affected? As long as they upgrade to the latest libraries for parsing/type-checking packages, most of their static analyses should be unaffected. I’m unsure if there are outliers that check for weird properties on the LHS of an assignment, or if their error handling analyses that would trigger false positives because they expect if err != nil.
    • What is the compile time cost? Time-complexity-wise the same. Can be implemented entirely in the frontend.
    • What is the run time cost? Nothing
  • Can you describe a possible implementation? I have a partial implementation on https://github.com/smasher164/check-go. I haven’t had time to finish changes to the typechecker, so all it does right now is tokenize and parse the check keyword on the LHS of an assignment.

  • Does this affect error handling? Yes

  • Why are named return values necessary? When annotating the error result, the natural question to ask is “what happens when the error is non-nil?”

    • Some proposals argue that we return zero values for everything except the error value, but that forces us to adopt the convention that there is only a single error value returned as the last. If we went this route, then named return values aren’t necessary, but we would be enforcing a convention.
    • We could do a naked return, and was my original plan. But the assignment would fail in a nested scope where the err variable is shadowed. If we went this route, then named return values are still necessary, since they permit the naked return.
    • We could return the values of the currently shadowed names for the named return values. This permits the user to redeclare err in a new scope. This is the approach I preferred, since it provides the most flexibility around where the assignment can happen.

Edit:

  1. I have relaxed the requirement on excluding underscores in named return values. We can simply return the zero value of that type when there’s an underscore.
  2. Referenced other similar issues.
  3. Added playground links for the “After” examples.
  4. Explain why named return values are necessary.
  5. Mention that check being a context-dependent keyword is different from the way identifiers work today, and could potentially be confusing to work with.

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 41
  • Comments: 29 (20 by maintainers)

Most upvoted comments

This seems substantively the same as the last check proposal, except on the LHS instead of a magic expression on RHS. The reason the last proposal was rejected AFAICT was that it turned out that check messed up code coverage percentages deeply (ie a line will count as covered even with no testing of error handling!), and no one wanted to bite that bullet. This proposal is not sufficiently different to get past that limitation, so I don’t think it has much chance of being accepted.

I think better error-handling

expr? // return if err

@carlmjohnson If line coverage is a blocker for error handling proposals, then I think it’s fundamentally impossible for a language change that simultaneously removes the error handling boilerplate while making independent lines show up in a coverage report.

That being said, I’m sympathetic to the idea that coverage tools should show if an error path is exercised. Any coverage tool/instrumentation/report would have to accommodate operations within a single line (or at least special-case the check).


Also when you refer to the previous check proposal, do you mean the check/handle one, or the try one? Unlike either of those, this proposal does not rely on a handler block and keeps the imperative style by annotating the LHS. It eliminates ambiguity around what happens when invoking a function that doesn’t return an error as its last parameter.

func f() (error, error, int)

func g() (i int) {
    check e1, check e2, v := f()
    return v
}

desugars into

func g() (i int) {
    e1, e2, v := f()
    if e1 != nil {
        return i
    }
    if e2 != nil {
        return i
    }
    return v
}

the language doesn’t give them any special treatment.

It kind of does though. The error interface is a universal-scope lower-cased exported type.

I believe most of the error handling proposals fall into this category, so this flaw is not unique to this proposal.

Yeah, pretty much all of them do. Not quite all though, however the ones that don’t aren’t very elegant… Not sure how possible it would be to do successfully.

There are generalizations of this proposal that could be applied to non-error values, but I want to keep this narrow in scope. For example, check could be made to work with any interface or even nillable type.

I think it would be quite strange (at least with the spelling check) for this to apply outside of errors. I’m not trying to say it should by any means. I just think that a “perfect” feature wouldn’t treat errors as special values. Go doesn’t need to shoot for perfect, though.

I believe check can be extended when that time comes to apply to Result values as well, as a form of destructuring assignment. But we should cross that bridge when we get to it.

This would be really cool provided that some kind of Result would end up being used. However a benefit of a system which doesn’t treat errors specifically would work with Results as well

@qingtao Clearly there is a subjective element to this. If you feel that strongly about it, please file a separate proposal.

That being said, on one hand @ianlancetaylor has stated that

check seems to emphasize the error result

while you state that ? and check have the same level of readability. This is something that I presume will be decided through this review process.

  • A keyword takes up more space.
  • It can be syntactically highlighted as a keyword in an editor.
  • It is consistent with the other mechanisms of altering control flow in Go programs, those being return and panic, both of which use an identifier.

Therefore, I would not argue that a keyword and ? are the same.

@ianlancetaylor

This seems similar to #32500 #32601 #32884 #33150.

I can update my proposal to mention these. However,

  • #32500 is far too broad in that it suggests panics, returns, and special operators, but doesn’t specify evaluation order.
  • #32884, #32601, and #33150 do not assign an identifier to the variable on the left-hand side, and don’t specify how that variable is mapped to the return value, leaving us to assume that it relies on some convention around the last return value being an error. They also use an operator instead of a keyword, which obscures the early exit that can happen.
  • #33150 introduces a separate mechanism for wrapping an error.

I don’t understand why this requires named result variables.

I should have explained this more in the original proposal, but when annotating the error result, the natural question to ask is “what happens when the error is non-nil?”

  1. Some proposals argue that we return zero values for everything except the error value, but that forces us to adopt the convention that there is only a single error value returned as the last. If we went this route, then named return values aren’t necessary, but we would be enforcing a convention.
  2. We could do a naked return, and was my original plan. But the assignment would fail in a nested scope where the err variable is shadowed. If we went this route, then named return values are still necessary, since they permit the naked return.
  3. We could return the values of the currently shadowed names for the named return values. This permits the user to redeclare err in a new scope. This is the approach I preferred, since it provides the most flexibility around where the assignment can happen.

Purely as a matter of style, the new use of check seems to emphasize the error result, which tends to obscure the more important non-error code.

Edit: This is a tradeoff made for readability. Anything less than a keyword, and the code might be too cryptic because the function exits early.