golang / go

The Go programming language
https://go.dev
BSD 3-Clause "New" or "Revised" License
124.31k stars 17.7k forks source link

proposal: Go 2: `on err` or `catch` statement for error handling #32611

Closed networkimprov closed 5 years ago

networkimprov commented 5 years ago

The try() proposal #32437 has met with significant complaint, especially about a) lack of support for decorating/wrapping returned errors b) lack of support for widespread alternatives to returning error, e.g. log.Fatal(), retry, etc. c) obscured function exit d) difficulty of inserting debugging logic

If a separate line is usually required for decoration/wrap, let's just "allocate" that line.

f, err := os.Open(path)  // normal Go \o/
on err, return fmt.Errorf("Cannot open %s, due to %v", path, err)

(This may overlap too much with if ... { <stmt> }. An error-specific variant is given in Critiques no. 1.)

This plugs easily into existing code, as it leaves the err variable intact for any subsequent use.

This supports any single-statement action, and can be extended with named handlers:

err := f() // followed by one of

on err, return err            // any type can be tested for non-zero
on err, return fmt.Errorf(...)

on err, fmt.Println(err)      // doesn't stop the function
on err, continue              // retry in a loop

on err, goto label            // labeled handler invocation
on err, hname                 // named handler invocation
on err, ignore                // logs error if handle ignore() defined

handle hname(clr caller) {    // type caller has results of runtime.Caller()
   if err == io.Bad { return err } // non-local return
   fmt.Println(clr, err)
}

Keyword on borrows from Javascript. It's preferrable not to overload if. A comma isn't essential, but semicolon doesn't seem right. Maybe colon?

Go 1 boilerplate is 20 characters on 3 lines: if err != nil {\n\t\n}\n This boilerplate is 9 characters on 1 line: on err, \n

Specifics

// Proposed
on expression, statement

// Equivalent Go
var zero_value T // global, not on stack
if expression != zero_value {
   statement
}

// Disallowed; these require a named handler or function call
on expression, if/for/switch/func { ... }
on expression, { statement; statement }

The expression is constrained to ensure that its use (if any) within the statement is correct. For example, expression cannot be a function call or channel receive. It could be limited to an lvalue, literal, or constant,

Possible Extensions

For an assignment or short declaration with a single lvalue, on could test that value. (Note, I don't recommend this; better to have on err consistently on its own line.)

on err := f(), <stmt>

An error inspection feature would be helpful...

on err, os.IsNotExist(err):  <stmt>
on err, err == io.EOF:       <stmt>
on err, err.(*os.PathError): <stmt>    // doesn't panic if not a match

on err, <condition>: <stmt>
on err: <stmt>              // this pair provides if/else in 2 lines

A condition list suggests parentheses:

on (err) <stmt>
on (err, <condtion>) <stmt>

Critiques

  1. The above may be too general-purpose. A post-assignment alternative is:

    err := f()
    catch <stmt>                  // test last var in preceding assignment for non-zero
    catch (err == io.EOF) <stmt>  // test last var and boolean condition
    catch (ep, ok := err.(*os.PathError); ok) <stmt>  // assignment preceding condition
  2. This doesn't accommodate narrowly scoped error variables if err := f(); err != nil { ... }. A way to scope the variable to the stmt is:

    _ = f()
    catch err, <stmt>
    catch err, (err != io.EOF) <stmt>
  3. go fmt could just allow single-line if statements: Would it also allow single-line case, for, else, var () ? I'd like them all, please ;-) The Go team has turned aside many requests for single-line error checks.

  4. It's as repetitive as the current idiom: on err, return err statements may be repetitive, but they're explicit, terse, and clear.

@gopherbot add Go2, LanguageChange

danrl commented 5 years ago

I was just thinking how inlining a handler would look like.

on err, { 
  do()
  things()
  return err
}

The comma doesn't feel right in that case. This makes me think if using curly brackets instead of the comma is an option. However, the code then becomes more like the boilerplate that we have already with if.

gertcuykens commented 5 years ago

I would opt for a ,= reverse nil operator that does the same but keeps the clean line of side

err := f() // followed by one of
err ,= return err            // any type can be tested for non-zero
err ,= return fmt.Errorf(...)
err ,= fmt.Println(err)      // doesn't stop the function
err ,= continue              // retry in a loop
err ,= hname err             // named handler invocation without parens
err ,= ignore err            // logs error if handle ignore() defined
err ,= { 
   do()
   things()
   return err
}
handle hname(err error, clr caller) { // type caller has results of runtime.Caller()
   if err == io.Bad { return err } // non-local return
   fmt.Println(clr, err)
}
networkimprov commented 5 years ago

/cc @daved @brynbellomy @guybrand

guybrand commented 5 years ago

@networkimprov

  1. on err, return err // any type can be tested for non-zero

The comma between "on err, return" bugs me, as comma is most commonly used between two variables. it doesnt look much like go syntax

  1. Quoting @griesemer from https://github.com/golang/go/issues/32437#issuecomment-501878888 (And was also the approach on the try() proposal itself) : "minimal idea which addresses one part of error handling well"

I think the above proposal offers a wide variety of solutions as you stated:

The try() proposal #32437 has met with significant complaint, especially about a) decorating/wrapping returned errors, and b) lack of support for widespread alternatives to returning error, e.g. log.Fatal(), retry, etc.

And even some more, but I'm not sure there's a bandwidth to address it, I tried to advocate a rating mechanism for try() or any other proposal (BTW, can be a good concept not only for the Go 2 error handling improvements but a general cost-effective per major development effort criteria), but I dont see the concept is well understood, If there's no bandwidth the options are: a. Narrow down the proposal to be "affordable" - and try to compromise as little as possible. b. Perfect it as much as possible, so perhaps one day when it will be affordable it would be a lead option c. break it down to sub requests so perhaps it can be implemented in steps.

alexhornbake commented 5 years ago

@networkimprov I agree a conditional return without assignment (ie, not try) on it's own line make a lot of sense, and seems to strike a good balance between being clear, and convenient.

, bugs me as well, and is a new meaning for the seperator. on <x> == if <x> != nil does not seem like the best meaning for the word "on"

What if it was 1 line if statements with semicolon as the seperator? (not sure how this jives with the lexer)

if err != nil; return err
if err != nil; return fmt.Errorf(...)

There is already a history of using ; in a similar way

if thing, err := doThing(); err != nil {
...
}
rsc commented 5 years ago

This proposal is missing the "precise statement of the proposed change" part. It is difficult to infer that precise statement from examples. As best I can tell, the proposal is to add

on X, Y

where X must have type error, and which expands to

if X != nil { Y }

Is that a correct and complete statement of what you are proposing?

networkimprov commented 5 years ago

@rsc, thanks for pointing that out. I've added a Specifics section, which says

// Proposed
on expression, statement

// Equivalent Go
var zero_value T
if expression != zero_value {
   statement
}

The expression is constrained to ensure that its use (if any) within the statement is correct. For example, expression cannot be a function call or channel receive. It could be limited to an lvalue, literal, or constant,

IOW, it's not specific to type error.

EDIT: I've also added a Possible Extensions section.

dpinela commented 5 years ago

As you hinted at yourself, the proposed syntax doesn't gain much over just putting the equivalent if statement in one line:

if err != nil { return err }
on err, return err

I don't think it's worth making a breaking change to the language for something you can almost get with a gofmt tweak.

JAicewizard commented 5 years ago

I don't get the point of "but if we have on line if-statements". We do now have them, and AFAIK the go team doesn't want them. We don't have them for a reason. If we add them that would pretty much nullify this proposal, but that doesn't mean that this proposal is any worse because of it. this has a clear advantage over on-line if-statements, it is very clear what is going to happen, and is less general then an if-statement. You cant just use this wherever.

peterbourgon commented 5 years ago

I support this proposal.

It is important that any change to error handling allows each error in a function to be annotated explicitly and independently from other errors. Returning the naked error, or using the same annotation for every error return in a function, may be useful in some exceptional circumstances, but are not good general practices.

I also support using a keyword instead of a builtin. If we add a new way to exit a function, it is important that it isn't nestable within other expressions. If we have to wait until further improvements to the language allow this to be accomplished without breaking the Go 1 compatibility promise, then we should do that, rather than make the change now.

griesemer commented 5 years ago

@peterbourgon Can you explain why you think on err is so much better than if err != nil that we need new syntax?

Also, there is a mantra that every error needs to be decorated somehow. I agree that this is important at the library boundary level. But there are plenty of package interior functions where decorating an error is not needed; in fact it only leads to over-decorated errors. Can you expand on the statement of bad practice that you are referring to?

peterbourgon commented 5 years ago

@griesemer

Can you explain why you think on err is so much better than if err != nil that we need new syntax?

To be clear, I'm in the "leave if err != nil alone" camp, like many other veterans of the language. But it seems like the core team has decided this is a "problem" that's worth solving, and I have sympathy for the perspective that it might encourage more not-yet-Gophers to become Gophers, so I'm speaking from that assumption.

edit: From that assumption, I think it's important that any proposal not affect the semantics (for lack of a better word) of error handling as it exists today (by which I mean that errors are generally totally handled adjacent to the error-causing expression) and instead affect only the verbosity of error handling. And the only commonality between the error handling blocks I write which could be reduced are

if err != nil { // <-- this line
    ...         //
    return ...  // <-- this keyword
}               // <-- and this line

For that reason, I feel like on is about as good as it can get, I guess.

Also, there is a mantra that every error needs to be decorated somehow. I agree that this is important at the library boundary level. But there are plenty of package interior functions where decorating an error is not needed; in fact it only leads to over-decorated errors. Can you expand on the statement of bad practice that you are referring to?

I think this is one of many points that demonstrate a disconnect between the core Go team and Go as it is used in practice, at least as I have always experienced it. Over the years I've taught dozens Go courses, consulted for nearly a hundred companies, small and large, startups and Fortune-500; I suspect I've interacted with thousands of Go programmers, and seen nearly as many discrete Go codebases. They are almost exclusively private, and their developers infrequently respond to surveys. As I see it, this is the primary market for Go the language. (Of course, I may be wrong. Grain of salt and all.)

In these contexts there are almost no packages which are imported by more than one binary. In fact I'd estimate that ~50% of Go code I've seen has just been in package main. These developers rarely have enough time or experience to properly structure the main business logic of their programs, much less think deeply about the structure or cleanliness of their errors. Many, many developers have the instinct to write functions like this, and use them everywhere:

func checkErr(err error) {
    if err != nil {
        panic(err)
    }
}

So, given this reality, where package boundaries are rarely well-defined, where functions instead serve as the primary way to hew borders in programs, the risk is never over-decoration. I've never once given a code review where I requested the author to de-annotate an error because it was too noisy. But I've given plenty of reviews where I've had to convince the author to remove their checkErr helper or equivalent and actually return errors at all rather than just terminating the process from somewhere deep in the callstack. So if a new language feature provides most of the "I don't have to think about this" convenience of checkErr and requires something like a deferred block to do annotation, I'm positive that almost no developer as I've described them will perform any annotation whatsoever, and I fear we'll move the resiliency and coherence of most Go codebases backwards, not forwards.

Of course, on err allows this to happen, too, but at least in code review we can suggest in-line amendments, which I believe would be more eagerly adopted.

griesemer commented 5 years ago

Thanks, @peterbourgon for your detailed comment. We've picked up error checking because the community has repeatedly and strongly complained about the verbosity of error handling. And yes, we also think that it might be nice if there were a less verbose approach where possible. So we're trying to move this forward if we can.

From what you're saying, try would already be a big step up from checkErr - for one it takes care of dealing with an error in the first place, and it also promotes at least an error return rather than just a panic. Because it removes the need for a checkErr function and extra call on a separate line, I'd expect those checkErr programmers to flock to try.

I see your point that on err might be easier to change into proper error handling (i.e., decoration), but again, judging from the reality you are describing it seems that checkErr would still be the much easier way out. If these programmers have rarely enough time or experience to do error handling "properly", why not provide a tool that gives them a strong incentive to check and forward errors? And rather than insist on decorating each error separately, use a deferred error handler (which can be one line)? This seems like a much easier sell than having them replace checkErr with an on err statement.

(As an aside, I've written plenty of little tools that just panic when I don't care about the error - not great, but good enough when time is of the essence. try would make my life much easier and allow me to do something better than panic.)

networkimprov commented 5 years ago

@peterbourgon @griesemer, the last few comments haven't considered that on err provides handling of recoverable errors; not just ones that return. These are very common, and try is no help to them.

networkimprov commented 5 years ago

I've added error inspection to the Possible Extensions section:

on err, os.IsNotExist(err):  <stmt>
on err, err == io.EOF:       <stmt>
on err, err.(*os.PathError): <stmt>    // doesn't panic if not a match
ulikunitz commented 5 years ago

Does this proposal reinvent C semantics for if and calls it on? The statement is executed if the expression is non-zero. The two differences are that C allows compound statements (aka blocks) and supports additionally an else branch.

ohir commented 5 years ago

If we're at a new keyword, why not generalize it as an "alias" to the if var != zero_value { block }. Current whines about 'verbosity of error handling' IMO stem mostly not from the language spec per se but from the parser shortcut then gofmt rules that make simple if three lines high (whats not that bad as the stuttering != nil ;).

"on" statements specify the conditional execution of a single branch according to the immediate result of a boolean expression, or a boolean result of an implicit comparison of the non-boolean value of an expression with the zero value of its type. If the result of either is true, the "on" branch is executed, otherwise it is not executed.

OnStmt = "on" [ SimpleStmt ";" ] Expression Block.

on err {
    return err
}
on !err {"\n"}
on ok := call(); ok {"\n"}
on ok := call(); !ok {"\n"}
on !(x%d) {"\n"}
on a - b {"\n"}

Then after a slight reduction, we may just add this pinch of implicit nonmagic to the "if" itself: "if the Expression value is not of boolean type...".

Magic semicolon rules, with the closing parenthesis relaxing rule are more magic than that.

if err { 
    return err
}
if !err {"\n"}
if ok := call(); ok {"\n"}
if ok := call(); !ok {"\n"}
if !(x%d) {"\n"}
if a - b {"\n"}

The real change would be to allow single statement blocks to occupy a line. But in fact I now value that space hinting at the possible change of the control flow.

@ulikunitz Yes, indeed :)

ianlancetaylor commented 5 years ago

I think the boilerplate that counts is what people see.

This reduces three lines to one line.

It reduces if err != nil {} to on err,, which by my count is reducing 12 characters to 6.

This adds a new kind of constrained expression which does not exist elsewhere in the language. The suggested extensions seem to mostly try to unconstrain the expression a bit by writing || as ,.

To me personally I don't think the savings are enough to justify the language change.

ohir commented 5 years ago

@ianlancetaylor

the savings are enough to justify the language change.

For me personally with try there are none savings only more mud. I am voting for the status quo, but if we're thinking changes lets think about what a possible outcome of stepping back to C if expression semantics would be. Being implicit it might even tackle at immortal if interface == nil gotcha.

PS. (Edit)

I think the boilerplate that counts is what people see.

But wasn't this given as a rationale for the try proposal? "On popular demand...".

(I remember your rebuke to mine (at go-newbie times) proposal of iirc watch some years ago. **Spooky action at distance**. I was then wrong, you were right. Try will allow for the spooky action not only at distance but also well hidden in layers of wrapped calls.)

networkimprov commented 5 years ago

@ianlancetaylor there's nothing like try() (either its arguments or effects) in the language now either, so apparently it's ok to devise novel schemes to reduce error handling boilerplate :-)

What on err offers that try() lacks is application to virtually all errors, including recoverable ones.

Sure, the concept needs work to support a variety of use cases in a way that blends with the rest of Go.

ianlancetaylor commented 5 years ago

I did not mean to imply that it was not OK to devise novel schemes. But any novel scheme is a cost, which must be balanced by some benefit. I was listing the benefits and costs I see of this scheme.

networkimprov commented 5 years ago

The bet here is that on err use at almost all call sites that yield an error delivers far greater benefit for essentially the same cost. Actually lower cost, since control-flow cannot become buried, and integration into existing code is trivial.

If data from a revised tryhard bears that out, what argument would remain for try()?

guybrand commented 5 years ago

@ohir

Regardless of liking (or not) the on err syntax, whether it needs more/less work and whether "it look like go", I dont think its very comparable to try() as it does not aim "to remove some boilerplate in the error handling", but rather tries to give a complete set of error handling that will remove the boilerplate for readability yet allow most of the functionality (inc. error decoration, flow control etc).

And although there will probably not be two paths for error handling improvement (well - Is/As...), on the one hand, and that on err is suggested as "an alternative", I would suggest to "judge" on err as an independent proposal, and only then compare cost<>effective vs try()

peterbourgon commented 5 years ago

@griesemer

I'm sorry to have overemphasized checkErr, I didn't mean to suggest it was the default case in code that I review, I only wanted to use it as an example of how error handing in "real world" Go is probably a lot less sophisticated than one might think. I agree that try is a step up from checkErr, but then again, any use of returned errors instead of blanket panics would be an improvement.

I'm pretty sure more explanation from me won't help to sell my case any more at this point, but I do want to respond to this:

I see your point that on err might be easier to change into proper error handling (i.e., decoration) but . . . rather than insist on decorating each error separately, use a deferred error handler (which can be one line) . . . seems like a much easier sell . . .

Prior to the check/handle proposal, I had never once considered, or seen in the wild, the general pattern of decorating errors with a deferred function. (The only times I'd ever used a deferred function to interact with errors were in the exceptional cases where that pattern was more convenient for complex resource cleanup.) And I'm not alone: I was just on a Go Time podcast panel where everyone else said roughly the same thing. While I understand your perspective that a one-line deferred error handler may seem like an easier sell—less typing, and less repetition—my experience tells me that I'd have a much harder time selling that sort of "look over here!" indirection as a general solution to novice programmers, versus short, in-situ repetition of on err expressions—of which we already have a reasonably effective (if more verbose) version in if err != nil.

I believe the try proposal significantly under-values the costs of physically separating error decoration from the error-generating statements, and of preventing many other kinds of error handling altogether. It seems to me that if you boil Go down to its barest essence, one of the properties that surely must remain is (waving my hands and trying to paraphrase a lot of stuff here) that errors are not exceptional, that the "sad path" of code is at least as important as the "happy path", and that the control flow from error handling should not be treated any differently from the control flow of business logic. It seems pretty clear to me that try subverts this essential property, and that on err does not. And if, as @ianlancetaylor says,

I don't think the savings are enough to justify the language change

Then let us not change the language.

lootch commented 5 years ago

On 7/3/19, Peter Bourgon notifications@github.com wrote:

[ ... ] I'd like to make one final and higher-level point. I believe the try proposal significantly under-values the costs of physically separating error handling from the error-generating statements. It seems to me that if you boil Go down to its barest essence, one of the properties that surely must remain is (waving my hands and trying to paraphrase a lot of stuff here) that errors are not exceptional, that the "sad path" of code is at least as important as the "happy path", and that the control flow from error handling should not be treated any differently from the control flow of business logic. It seems pretty clear to me that try subverts this essential property, and that on err does not.

I think you and I see things pretty much the same way, if it makes you feel any better.

My weak proposal is that we suck into the invoked function the responsibility to report what you call the need to treat the "sad path" (one of a number) on exit as if it were as common as the one, usually) "happy path" and use a different "return" command to do so: we expect the invoker to respond to the "sad" situation differently and we use a different reporting method to emphasise the difference.

I was just making a note to myself that the identity of the invoked (and failing or "unhappy") function should not be part of the decoration of an error message, because that would be repeated within the function as many times as there are unhappy exits, whereas the invoker only needs to add that decoration at the site of invocation. That in some way connects to the "deferred error handling" presented as the counter to your proposal. I think the more explicit "on err" or my own also explicit "fail" are clearer and more flexible.

The reality is that Rob Pike's "errors are values" is too powerful a meme to be messed with until one realises that the problem that needs solving is not verbosity, but repetition: it's that "if err != nil {", that bugs everyone and an abbreviation that conveys in one word that the invoker needs to be informed of one additional condition should probably not be solved by subsuming that condition "in band" with less optional parameters.

Putting it another way: each "sad" exit from a function is different, normalising them all into one-size-fits-all has a price and too much of Go's philosophy depends on accepting (as I do) that the price is worth paying. If you don't like that price, the answer is not a "better Go", it is a "different" Go.

ngrilly commented 5 years ago

@peterbourgon As much as I'd like the try proposal, I have to admit that the "indirection" necessary to decorate the error with a deferred function is convoluted, and could be difficult to teach to novice Go programmers. I hope that another iteration of the proposal will lead to the right solution.

Then let us not change the language.

Then let us improve the try proposal 😉

errors are not exceptional [...] the "sad path" of code is at least as important as the "happy path"

I think we all agree with this. The issue is that each time we query an external system, and this query can fail in multiple ways, we have 1 line to query the system, and 3 lines to check, decorate and return the error. This makes the "sad path" three times more visible, more significant, than the "happy path".

I notice we are not the only ones struggling with this. Rust, Swift and Zig, for example, are looking for the right solution to this issue.

peterbourgon commented 5 years ago

@ngrilly

The issue is that each time we query an external system, and this query can fail in multiple ways, we have 1 line to query the system, and 3 lines to check, decorate and return the error.

This on err proposal reduces it to one, which I think is the Pareto optimal number of lines, for the reasons I've discussed. Can not on err be the improved try?

griesemer commented 5 years ago

@peterbourgon Thanks for your response.

It has become clear from much of this discussion that many are disappointed that try doesn't do more for error handling. Maybe it's not emphasized enough, but the proposal is clear that if decorating/handling an error at the point of the error check is desired, the existing if err != nil pattern is the recommended approach. We are at this point not convinced that there is anything else to do in that case (but see below).

But there is the legitimate use case where errors are simply returned. And there is the legitimate use case where all errors returned from an API entry point are decorated uniformly (the same way) - which is where the deferred handler is suggested (which is independent and orthogonal to try). We hear that these cases may not be the majority of cases, but they are not negligible, and they are the cases where the boilerplate becomes egregious because it adds only clutter. In those cases I would want to factor out that code, like one would with everything else that's repeated and the same, but because we can't do this in Go (and won't be able to do this in any foreseeable future version of Go), the helper function try seems like a good compromise. It's truly a minor addition to the language. If it doesn't fit a specific program design, don't use it.

I think there's a fundamental misunderstanding in this general discussion (not between you and I), a fear that try is the "must-use" mechanism for error handling, or perhaps more accurately, that try might become the "must-use" mechanism. That is not our intention, nor does the proposal say as much.

Regarding the need for a more comprehensive error handling construct: philosophically, Go has never been in the business of promoting a specific programming paradigm (notwithstanding the fact that it is an imperative, statically typed, type-safe language). For instance, Go supports OO programming but doesn't mandate it. Instead it provides all the building blocks (methods, interfaces, embedding) to achieve that. Go supports exception handling but doesn't encourage programming with exceptions via a try-catch construct. Instead it provides the building blocks (panic, defer, etc.) to do so if desired. That is not to say that Go is not opinionated - of course it is. But that opinion is not enforced so much by the language but by convention and by tools (go fmt, go vet, go lint, etc.).

Whether the language or the tools enforce a paradigm doesn't make much of a difference for a specific piece of software at a given time, the outcome is the same: a certain style is encouraged or enforced. But it matters in the long run: conventions can evolve as we learn more, and tools can change and adapt. Existing programs will continue to run. On the other hand, if we enshrine paradigms into the language it will become much harder or impossible to change them because it might invalidate a lot of existing code.

For that reason I am much more reluctant to make more significant language changes such as adding "on err" or the like (*). try is simply factoring code. on err is adding a new paradigm.

At this point, I think the question we need to answer is this: Is it reasonable to add a minor built-in to the language that facilitates error checking for a significant number of cases (but not most), and which can be safely ignored if one desires to do so, or is even that not justified and we should leave the language alone.

Thanks.

(*) Adding the predeclared type error was of course the first (and perhaps the biggest) step in this direction: It enshrined that errors should be of error type. But by making them more abstract (as error is an interface type) it increased interoperability between components while introducing a standard, yet errors remain "just values". This was clearly a win-win.

networkimprov commented 5 years ago

Is it reasonable to add a minor built-in ... or is even that not justified and we should leave the language alone.

You already have a loud & clear answer to that question from the community.

Re "minor" built-in, please consider whether that's a rationalization.

ianlancetaylor commented 5 years ago

@guybrand I am not comparing on err to try; I am comparing it to if err != nil.

guybrand commented 5 years ago

@ianlancetaylor Your right, I read the correspondence over the mobile, it was actually the response to your comment that compared (and not for the first ... or last in this thread ...)

Sorry, I will correct.

campoy commented 5 years ago

Hey @griesemer, regarding the line

For that reason I am much more reluctant to make more significant language changes such as adding "on err" or the like (*). try is simply factoring code. on err is adding a new paradigm.

I'm curious about how is try simply factoring code when it's able to make the function return? Not criticizing, just trying to figure out why try is seen as something "simple", while on err is a new paradigm.

balasanjay commented 5 years ago

Alright, I hacked tryhard to report the necessary counts (diff here: https://github.com/griesemer/tryhard/compare/master...balasanjay:tryhard-data), and ran it over the Go corpus 0.01 (https://github.com/rsc/corpus).

I believe there's lots of vendoring and such in the corpus, so there is likely to be double-counting. Also, I didn't spend very long on hacking tryhard. Caveats aside, here's the data:

  All code All code (%) Non-test code Non-test code (%) Test code Test code (%)
COUNT-1-STMT-RETURN 202932 57.22% 200999 70.07% 1933 2.85%
COUNT-1-STMT-OTHER-SINGLE 114447 32.27% 57966 20.21% 56481 83.32%
COUNT-2+-STMTS 34711 9.79% 25960 9.05% 8751 12.91%
COUNT-1-STMT-OTHER-COMPOUND 2064 0.58% 1902 0.66% 162 0.24%
COUNT-0-STMTS 486 0.14% 26 0.01% 460 0.68%

Note: tryhard only operates on functions which return an error as their last response type, I removed this filtering to be fair to this proposal, so not everything on the first row can be handled by try.

griesemer commented 5 years ago

@campoy try is just a macro, fashioned into a built-in function. If we could write try in Go, one might write it and use it like any other function we use to factor out shared code (one sort of can, using ugly hacks with panic and defer, for specialized calls, but that's beside the point). try doesn't introduce new syntax or new keywords; and since it lives in the universe scope, it can be shadowed like any other name. As a consequence, it won't affect any existing code.

on err is a new statement and requires a new keyword. It will interfere with any code that uses on as an identifier. It also overlaps directly with the if statement, something we try to avoid for major language constructs. The syntax on err, <statement> is unusual for Go, one would expect on err { ... }. I don't know why there needs to be a comma. Details.

Of course we could say that on err is simply a form of syntactic sugar for a common pattern, very much like try. And that if one could write on err in Go some would (like some would with try).

But there is really a difference between a built-in function and a new statement. Built-ins can easily be ignored (because they don't interfere with existing code). With few exceptions (len, append), they can be skipped when learning the language, and introduced on demand. In the spec they show up at the end. Several built-ins are not strictly "needed" and one could write the same code by hand (append, copy, new come to mind) - these are here purely for convenience. try falls into this category.

A statement cannot be ignored - it is either used or it must be avoided (in the sense that we cannot have identifiers that collide with keywords of statements). It must be learned by everybody for that reason. In the spec it shows up prominently.

Generally, a statement in Go doesn't enshrine a single use case: An if statement is simply for conditional branching, a for statement for looping, a switch statement for selecting between many alternatives, and so on. They operate on arbitrary data types and in different kinds of coding styles.

But an on err statement is only for checking and handling errors. There's no other use case. As such it will become the recommended way of checking errors, even though there is absolutely nothing wrong with if err != nil {}. So on err - by sheer existence - promotes a certain coding style, or what I called a paradigm (maybe paradigm is not the best word).

Not only does on err overlap with if, it also does not make code a lot more compact. Code that is now dominated by if err != nil will become dominated with on err - there's not much gained, and a lot lost (introducing a new language statement comes at a big cost).

try also has a very narrow use case. It's only good for one thing: checking an error and return. But it doesn't affect existing code. Because it's much more narrow than on err, the savings are much bigger. It's also more powerful than on err because it can be used in an expression, something we can't do with on err. Yet it comes at much less cost. It doesn't scream "you must use me for error handling" in the language spec. It's just a little built-in for convenience (see above). It terms of complexity of implementation it is trivial compared to say append, copy, etc. It is tremendously helpful in many situations, very much like append is, but it doesn't have to be used.

One more thing, unrelated to your question: We (the Go team) have considered many many alternatives (some of which I mentioned in the design doc). But for minor syntax differences, most alternative proposals brought up in this public discussion we have already considered one way or the other (we have been discussing error handling internally on a regular basis for more than a year). We ended up with try because it seemed to have the least negative impact on existing code and the language, and because it seemed easy to explain and very clear. Where it can be used it has a big impact on the readability of the code (in our minds) because it reduces so much boilerplate. It opens new avenues of use because it can be used in expressions where before we needed separate statements. The same cannot be said for most alternative proposals that have been brought up so far (but the ideas that make error checking invisible and implicit - nonstarters). But it does require getting used to. It's a bit of a shift in thinking about errors. It only solves error checking, not error handling. It takes time. And maybe the time is not now, and maybe try is not it. We don't know without try-ing :-)

Apologies for the lengthy reply (I didn't take enough time to make it shorter). I hope this helps explaining a bit better what I tried to say here.

lootch commented 5 years ago

I think there are two insurmountable issues with try, the function: the obligation to deal only with functions that have a common, but very unique and in a sense "weird" signature (the last argument is of type "error") and the implicit "return".

Each of these flies in the face of the Go philosophy and is an aesthetic failure. Imagine providing the two "features" as individual (that is, not necessarily combined) properties of arbitrary, user defined functions. Extending the compiler in that direction without offering the same ability to the developer is what "smells", I believe.

Would the designers really like to define and implement such extensions for arbitrary use? Are they going to make sense in a user manual? And yet, internally and quite visibly, they will become"available", but just to this one "macro".

It's too small a target for such a big projectile.

Worse, it may be a great idea if it IS implemented as suggested, but with a view to EXPERIMENT (is that an option?) and to nurture it until the seed develops into something that can be offered to Go developers as a compatible or otherwise way to deal with error conditions.

Rob Pike first identified something that is special to Go - errors are values. But that made us forget that we like to think of errors as rare exceptions when in fact they are less "common", but more "numerous" than normal function terminations. Are the words I picked sufficiently clear in distinguishing these situations?

Doug Gwyn would suggest not dealing "in-band" with something that is intrinsically "out of band", incidentally, which is what Rob's "errors are values" blog entry promotes instead. It is important to know the distinction and make a decision accordingly - I doubt we can or even wish to change that now.

Let me add that encouragement to terminate a function at arbitrary points is also muddling things up: it multiplies the number of "normal exits" - easier debugging, more difficult reading (subjectively, though). It matters, because it characterises the Go philosophy more accurately.

In summary, the Go Team needs to see the impact "try" will have on the language and the community, which requires experimentation, probably more of a social than a technical type. Let them proceed so they can convince the community that the eventual outcome will originate from the results of the experiment and not from their failure so far to come up with something that satisfies our expectations.

It's in time like these that leadership gets tempted to throw in the towel. At such times, bad decisions are easily seen as the only way forward. Let's not let that happen here. For one, we need the discussion - like it or not - more than we need the outcome (my opinion, of course).

(I really feel there is an emotional vein running through this discussion, one that has not yet been acknowledged - just shows we're (mostly?) human; humans can overcome such obstacles. Go may not be perfect, but it "is great" )

griesemer commented 5 years ago

@lootch Thanks for your comment. There's definitively a social engineering aspect to all of this - we're proposing to extend Go in unfamiliar ways, and if it's the language you use day in and day out that is very concerning, perhaps even scary; after all it changes how one is supposed to "speak" in this language. You are absolutely right that there are a lot of emotions running through this discussion - I can't see how a purely technical exchange would collect so much feedback in so little time. As to the decision, it's still early days. I'm not the only decision maker here but I suspect we will stick to the process and let this run its course and decide how to proceed by the end of July.

Regarding the insurmountable issues with try: not to be facetious, but it seems to me that the existence of try is proof that these issues are not insurmountable :-). Yes, try has odd requirements and behavior. The design doc is explicit on this subject. I note that new and make require a first argument which is a type, followed by regular arguments. No Go function has such a signature. unsafe.Offsetof requires a struct field (!) as argument - talking about an unusual signature. recover is only meaningful inside a deferred function and possibly stops an ongoing return/panic - another odd context-dependent requirement and strange control-flow behavior. Virtually every single existing built-in has some "code smell", which is the very reason they are built-ins.

Regarding the claim that the try design flies in the face of Go's philosophy or that it is an aesthetic failure: Since the proposal passed muster with the Go team, including several of the original Go designers before we published it, I'm going to make the bold statement that it is ok. As long as Go has a goto statement I think we can't be feeling too smug about Go's aesthetics.

But let me repeat what I've said elsewhere:

Please be assured that we have no intention to rush any new language features without taking the time to understand them well and make sure that they are solving real problems in real code. We will take the time needed to get this right, just as we have done in the past.

lootch commented 5 years ago

It may be petty to say this, but I think "last return argument of the called function must be of type error" stretches your counterexamples somewhat. But I do see your point and we have reached a stage where nothing more appropriate is likely to be proposed in the near future. Personally, I'd hate to be stuck with "try, the intrinsic function", my biggest fear being that it may shut the door to something superior yet to be discovered. But I'm just repeating myself, in common with many others.

Let me add just on more point: it is all too easy to treat criticism as aimed at oneself or one's own side when it is in fact intended for the subject matter, to help improving it. A lot of information has flowed through these "pages" and a lot of it has indicated that the fork in the road is going to be an awkward one, no matter what. I wish you (and us all) the very best of possible outcomes. I'm sure my sentiments reflect those of the majority of the participating community.

Lucio De Re.

On 7/4/19, Robert Griesemer notifications@github.com wrote:

@lootch Thanks for your comment. There's definitively a social engineering aspect to all of this - we're proposing to extend Go in unfamiliar ways, and if it's the language you use day in and day out that is very concerning, perhaps even scary; after all it changes how one is supposed to "speak" in this language. You are absolutely right that there are a lot of emotions running through this discussion - I can't see how a purely technical exchange would collect so much feedback in so little time. As to the decision, it's still early days. I'm not the only decision maker here but I suspect we will stick to the process and let this run its course and decide how to proceed by the end of July.

Regarding the insurmountable issues with try: not to be facetious, but it seems to me that the existence of try is proof that these issues are not insurmountable :-). Yes, try has odd requirements and behavior. The design doc is explicit on this subject. I note that new and make require a first argument which is a type, followed by regular arguments. No Go function has such a signature. unsafe.Offsetof requires a struct field (!) as argument - talking about an unusual signature. recover is only meaningful inside a deferred function and possibly stops an ongoing return/panic - another odd context-dependent requirement and strange control-flow behavior. Virtually every single existing built-in has some "code smell", which is the very reason they are built-ins.

Regarding the claim that the try design flies in the face of Go's philosophy or that it is an aesthetic failure: Since the proposal passed muster with the Go team, including several of the original Go designers before we published it, I'm going to make the bold statement that it is ok. As long as Go has a goto statement I think we can't be feeling too smug about Go's aesthetics.

But let me repeat what I've said elsewhere:

Please be assured that we have no intention to rush any new language features without taking the time to understand them well and make sure that they are solving real problems in real code. We will take the time needed to get this right, just as we have done in the past.

-- You are receiving this because you were mentioned. Reply to this email directly or view it on GitHub: https://github.com/golang/go/issues/32611#issuecomment-508345807

-- Lucio De Re 2 Piet Retief St Kestell (Eastern Free State) 9860 South Africa

Ph.: +27 58 653 1433 Cell: +27 83 251 5824 FAX: +27 58 653 1435

ohir commented 5 years ago

@griesemer

@campoy try is just a macro, fashioned into a built-in function.

Then why not onErr macro instead? One that only checks for err != nil, then if true executes return, goto or panic?

If we could write try in Go, one might write it and use it like any other function we use to factor out shared code (one sort of can, using ugly hacks with panic and defer, for specialized calls, but that's beside the point). try doesn't introduce new syntax or new keywords; and since it lives in the universe scope, it can be shadowed like any other name. As a consequence, it won't affect any existing code.

It will badly affect all future code

[...]

But there is really a difference between a built-in function and a new statement. Built-ins can easily be ignored (because they don't interfere with existing code).

No they can NOT be ignored while reading and comprehending the code. (And I personally am reading my own code ten times more often than I write it. Not to mention other's code I am trying to dissect.)

With few exceptions (len, append), they can be skipped when learning the language, and introduced on demand.

IMO, only the complex, real and imag can be introduced on demand. All the rest must be understood, at least for their purpose and result, while onboarding into a project. How one could be supposed to read Go code without knowing of signature of the make, ehm?

In the spec they show up at the end. Several built-ins are not strictly "needed" and one could write the same code by hand (append, copy, complex come to mind) - these are here purely for convenience. try falls into this category.

No, try does not fall into that category. Neither append, nor copy, nor make have peculiar demands about other parts of the code nor they need augmenting prose in the specs.

A statement cannot be ignored - it is either used or it must be avoided (in the sense that we cannot have identifiers that collide with keywords of statements). It must be learned by everybody for that reason. In the spec it shows up prominently.

And thats good. We do expect all apprentices to learn that failures must be dealt with in clear.

[...]

because it can be used in an expression, something we can't do with on err.

Having try as a function allows for, if not encourages to, hard to notice slips (pointed at #32437 discussion). I can add to this: how a debugger output will look like to set my attention at a fifth wrap of the tryies oneliner matrioshka? And how much effort will it demand from debugger authors?

Yet it comes at much less cost. It doesn't scream "you must use me for error handling" in the language spec. It's just a little built-in for convenience

An the false (write time only) convenience of it will make it hard to eradicate from the Go newcomers minds, so we will soon see a 20 cases switch at the topmost defer then down the body single lines dense of nesting try-parenthesis soup. LISPers will be happy, Gophers will not.

@campoy try is just a macro, fashioned into a built-in function.

onErr as a built-in macro will be yet more trivial to implement without endangering Go's purity:

OnerrStmt = "onErr" ( "continue" | "break" | "return" | "goto" | "panic" )

Onerr macro executes a single terminating statement `return`, `goto`, or
`panic` if a variable named `err` is not `nil`. Otherwise its a noop.

(excuse me Lucio, its yours "onerr" words and my simplified version. We all are in quest for the most gain from the least changes set ;)

(edit: added continue and break allowed statements. I'd like to have also fallthrough but its likely too much demand compiler-changes wise)

griesemer commented 5 years ago

In cases where the if err != nil test is combined with an initializer expression that declares the err variable in the local if scope, say:

if _, err := f(); err != nil {
   return ..., err
}

using on err will expose the err variable in the scope enclosing the if statement:

_, err := f()
on err return ..., err

Here are examples of such code. See lines 378ff.

This may be problematic.

try doesn't have this problem. That is, in cases like these, which are not uncommon, on err is strictly worse than try, and also worse than if.

ohir commented 5 years ago

@griesemer

This may be problematic.

try doesn't have this problem. That is, in cases like these, which are not uncommon, on err is strictly worse than try, and also worse than if.

Yep, the OP version.

The "mine" macro version might not, if implemented as a subparse from the onErr token to the '\n' of its line.

_, err := f()
onErr return ..., err \n
//_1_ |   subparse     |
}

// is expanded to =>
//
if err != nil {
    // subparse
}
// =>
if err != nil {
    return ..., err 
}
networkimprov commented 5 years ago

@griesemer Ok, you've made a case for "minor built-in". But I feel it's disingenuous to call it backward-compatible. It is, but only because it obfuscates existing code that uses a try identifier. IMO it's better to break code than obscure its meaning.

The essential point here is not that I've discovered a magic fix. It's that the Go team should pursue an alternative (or shelve the issue and revisit after generics), given the widespread and intense disapproval for try().

With on err I tried to come up with a general-purpose alternative to if ... { <stmt> }. It could be revised to be more special-purpose, as @ohir suggests.

v, ev := f()

catch <stmt>                // test last var in preceding assignment
catch (ev == io.EOF) <stmt> // test last var and boolean condition
catch (ep, ok := ev.(*os.PathError); ok) <stmt> // allow assignment preceding condition

I'm sure there's a solution to the problem of exposing the error variable to the rest of the function, as well.

thepudds commented 5 years ago

But there is really a difference between a built-in function and a new statement. Built-ins can easily be ignored (because they don't interfere with existing code). With few exceptions (len, append), they can be skipped when learning the language, and introduced on demand. In the spec they show up at the end. Several built-ins are not strictly "needed" and one could write the same code by hand (append, copy, complex come to mind) - these are here purely for convenience. try falls into this category.

A statement cannot be ignored - it is either used or it must be avoided (in the sense that we cannot have identifiers that collide with keywords of statements). It must be learned by everybody for that reason. In the spec it shows up prominently.

@griesemer, that is a valid set of points.

That said, when it comes to learning about a try builtin vs. some statement-based variation, another set of questions is "how prominent would it be" and "how many words would it take to describe" in places like:

Setting aside for the moment the exact form a statement-based variation of try might take, one could certainly imagine that there is a statement-based variation of try that would end up being equally prominent in those places, and it could take roughly the same number of words to usefully introduce the concept and mechanism as a try builtin.

In other words, I suspect a builtin-based try would in practice end up being learned more-or-less just as soon for a new gopher as a statement-based try variation (given error handling is fairly central to how one writes Go), and one could imagine there exists a statement-based syntax that could be learned roughly as easily.

On your points about the spec -- one of the things people praise about Go is how the spec is very readable, and seasoned gophers often recommend new gophers take the time to read the spec... but even there, a "typical" new gopher likely reads several other introductory things prior to venturing into the spec.

In any event, thanks for taking the time to share your thinking as well as to engage on the different concerns and ideas that have surfaced from the broader community since the proposal was published.

thepudds commented 5 years ago

Also, regarding a builtin vs keyword, there are separate concerns regarding different transition costs for community tools, and possibly the need to wait for modules to default to on to allow the Go language version to be controlled if a breaking change is introduced with a new keyword, etc.

I was not trying to rehash all the pros and cons for a keyword vs builtin, and was more just making some guesses around the perceived complexity of the result.

networkimprov commented 5 years ago

I've addressed @griesemer's feedback in the Critiques section of the issue. I added the catch variant described previously, and expanded on it to scope the error variable to the statement:

_ = f()
catch err, <stmt>
catch err, (err != io.EOF) <stmt>

Now I believe no arguments remain for try() aside from: a) its use in expressions, which has been widely criticized, and b) it's a minor built-in that you can ignore.

peterbourgon commented 5 years ago

Now I believe no arguments remain for try() aside from . . . b) it's a minor built-in that you can ignore.

I don't really understand this point. From the perspective of a language user, there is no difference between a keyword and a builtin, except the way in which it is used. In either case, when they are learned is a function of their general applicability; it's never been my experience that e.g. builtins are more likely to be learned later or ignored, as I believe has been implied.

More concretely, whether the error handling feature is a keyword or a builtin, it would certainly appear in the same place in a Go learning syllabus. Right? Could someone clarify?

ngrilly commented 5 years ago

it's never been my experience that e.g. builtins are more likely to be learned later or ignored

@peterbourgon I'm in favor of adopting try, or something close to be defined, but I agree that if try becomes a built-in, it will be used, and gophers will have to learn it sooner than later, as they do with the other built-ins. A built-in has advantages over a keyword (no grammar change, backward compatibility, future extensibility), but it can't be ignored in a public codebase. It can only be ignored in a private codebase.

griesemer commented 5 years ago

@peterbourgon I think it's fair to say that a novice to Go will be able to program without try and do error handling - it's not something that needs to be learned to get going (it's what we do now, after all). But you're right that once they start reading other people's code, they will have to look it up. And yes, if try becomes common practice, it probably will be introduced in a syllabus right after explaining how to use if err != nil.

(We have a few built-ins that we don't strictly need: new can be done by taking the address of a variable, append and copy could be custom-written, complex I've already mentioned, and print and println are for convenience. try falls into a similar category in my mind; it adds convenience where it is applicable. I haven't heard the complaint that the presence of these "unnecessary" built-ins has made Go harder to learn due to the increased complexity.)

ohir commented 5 years ago

@networkimprov

I added the catch variant described previously, and expanded on it to scope the error variable to the statement:

With all due respect, all that additions and refinements morph your initial simple solution into something that will need no less cognitive load as try. IMO.


@griesemer

(We have a few built-ins that we don't strictly need: new can be done by taking the address of a variable, append and copy could be custom-written, complex I've already mentioned, and print and println are for convenience. try falls into a similar category in my mind; it adds convenience where it is applicable.

But yours try, while smart, makes writing convoluted paths convenient, using magic that (except autodereferencing) so far has not been used in Go spec.

And yes, if try becomes common practice, it probably will be introduced in a syllabus right after explaining how to use if err != nil

if is if, boolean expressions are 101, then an apprentice will need to learn and remember that:

[note that this is a description of reasoning, not a manual entry] try, while looks like a function call, is in fact not a try function call, but it is an invocation of a magic ~spell~ macro that will behave like a function call to the function given in try's parenthesis, if and only if that real call will return nil in its last return parameter of type error position. Otherwise, as for the last return parameter of called func not being nil, try magically will morph into a plain naked return ...never forget that the outermost, and outermost only, try incantation cuts off the last return parameter (usually err)! .

Try in its current shape adds too much convenience for too hefty price tag. Readability and simplicity-wise. Make it a statement at least, please, so it will have to occupy its own line. It is the try's ability to nest that concerns. Frankly, I know I personally would be tempted to abuse it, then curse on myself later.

Michael-F-Ellis commented 5 years ago

It is the try's ability to nest that concerns. Frankly, I know I personally would be tempted to abuse it, then curse on myself later.

This! It's precisely the same temptation as the ternary operator proposals that the Go team has repeatedly quashed (rightly, imo.)

balasanjay commented 5 years ago

@ohir Concerns around nesting is not new feedback, and again, a go/analysis check for nested trys or multiple trys on the same line seems entirely straightforward. And I suspect there would be support for getting it into vet as a default check.

@networkimprov I think this proposal needs to be specific about what exactly it's proposing. The proposal seems to have a wide range of possible changes to the language, and it's making it hard to evaluate. The specific keyword doesn't really matter at all, but the exact semantics of what you're proposing does. For instance, any thought like "is there implicit dataflow between an unnamed variable assignment and the following statement?" always has an answer of "maybe", as there are some things in your proposal that imply yes and others that imply no. Same for a foundational question like "does the keyword only apply to values of type error?".