Skip to content
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: Go 2: error handling: try statement with handler #56165

Closed
3 tasks
gregwebs opened this issue Oct 11, 2022 · 55 comments
Closed
3 tasks

proposal: Go 2: error handling: try statement with handler #56165

gregwebs opened this issue Oct 11, 2022 · 55 comments
Labels
error-handling Language & library change proposals that are about error handling. LanguageChange Proposal v2 A language change or incompatible library change
Milestone

Comments

@gregwebs
Copy link

gregwebs commented Oct 11, 2022

Author background

  • Experience: 4 years experience writing production Go code. Expert at writing if err != nil. I have forked and created Go error handling libraries.
  • Other language experience: Many of the common languages that are not JVM/CLR (Rust, Haskell, Python, Ruby, TypeScript, Nim, SQL, bash, terraform, Cue, etc)

Related proposals

  • Has this been proposed before? Variations have been proposed, this is discussed in the proposal.
  • Error Handling: Yes

Proposal

Add a new statement try that allows for succinct error handling.

try err, handler

This is translated to

if err != nil {
    return handler(err)
}

Zero values are generated for any return types, so to see this in the context of a function:

func(args...) (rtype1, rtypes..., rtypeN, error) {
    try err, handler
    ...
}

turns into the following (in-lined) code:

func(args...) (rtype1, rtypes..., rtypeN, error) {
    if err != nil {
            return Zero(rtype1), Zeros(rtypes...)..., Zero(rtypeN), handler(err)
    }
    ...
}

The handler argument is optional

try err

This is translated to

if err != nil {
    return err
}

Unlike in previous proposals, try is a statement, not an expression.
It does not return any values.
When a function returns non-error results, an intermediate error variable must be used.

x, err := f()
try err

If an expression returns just an error, it is possible to use try directly on the expression without an intermediate variable.
For the sake of consistency it may be desireable to not allow this form (possibly enforced by linter rather than compiler).

func f() error { ... }
try f()

Discussion Summary

This section summarizes the discussion that took place on the proposal that you don't have to wade through lots of comments. It has been inserted into the original proposal.

The below are points that in theory are easy to resolve by altering the existing proposal:

  • The name try does not capture the operation well, check and returnif are given as possible alternatives.
  • The use of a comma separator is not liked by some because it looks like a multi-value return. with has been given as a possible alternative.
  • Adding a ThenErr error handler function composition is dis-liked by many (it's not clear to me why). An alternative is to not add it.
  • defer try does not match how defer currently takes an expression. The alternative is to not add this.

Below are points that were raised about the benefits and costs of this proposal:

  • There is some interest in generalizing these kind of proposals to work for other zero values in addition to just errors. But this has not been fully thought through. This could make the feature more powerful, and thus more worth it, or it may just be confusing and unworkable.
  • It may be too much work for tooling to adapt to this new statement. Thus this may be a difficult breaking change for some tools to deal with

And of course are value judgements about whether the benefits outweigh the costs. For some Go programmers, using anything except return for returning creates multiple ways to do the same thing, which is unacceptable. Some try to address this by changing the keyword to something like returnif.

Background

Existing proposals to improve Go errors taught us that our solution must provide 2 things:

  • the insertion of a return statement for errors
  • compose error handler functions together before the error is returned

Existing solutions handle the first point well but most have done poorly on the second. With a slight variation on existing error handling proposals, we can provide the second.

Motivation: Go's biggest problem

Recently the Go Developer Survey 2022 Q2 results were released.
Now that Generics have been released, the biggest challenge for Go developers is listed as "Error handling / working with stack traces".

Error handling: missing or poorly implemented in many proposals

This proposals allows the Go programmer to write the exact same code, but more tersely:

f, err := os.Open(filename)
try err

In such a simple case, with no error handler, this transformation may not be very valueable. However, even in relatively simple case, consider if the zero values are verbose:

x, err := f()
if err != nil {
    return MyLargeStructName{} otherpackage.StructName{}, err
}

In the above example, programmers are tempted to return the structs as pointers just so they can return nil rather than obfuscate their code with zero values. After this proposal, they can just write:

x, err := f()
try err

Additionally, there is the case of "handling the error". Often we want to annotate the error with additional information, at least an additional string. Adding this code that modifies the error before it is returned is what I will refer to as adding an "error handler".

The original draft proposal solution used stacked error handlers, but this has difficulties around composition due to the automatic stacking and code readability since the error handler is invoked implicitly. A second proposal was put forth not long after which implemented try as an expression and without any support for (stacked) error handlers. This proposal had extensive discussion that the author attempted to summarize. In my view this proposal was poor because it did not create any affordances for error handling and instead suggested using defer blocks. Defer blocks are a powerful and orthogonal tool that can solve the problem, but for many normal error handling use cases they are clumsy and introduce incidental complexity.

A solution to the error problem should encourage the Go programmer to add error handling code as needed.

Extending existing solutions with function-based error handling

Composing error handlers can be solved by adding a 2nd parameter to try. The second parameter is an errorhandler of type func(error) error or more precisely with generics: type ErrorHandler[E error, F error] func(E) F.

Now we can cleanly write the following code given from the original problem statement:

func CopyFile(src, dst string) error {
    handler := func(err error) error {
            return fmt.Errorf("copy %s %s: %w", src, dst, err)
    }

    r, err := os.Open(src)
    try err, handler
    defer r.Close()

    w, err := os.Create(dst)
    try err, handler.ThenErr(func(err error) error {
            os.Remove(dst) // only if Create fails
            return fmt.Errorf("dir %s: %w", dst, err)
    })
    defer w.Close()

    err = io.Copy(w, r)
    try err, handler
    err = w.Close()
    try err, handler
    return nil
}

ThenErr would be a standard library function for chaining error handlers.
The new example dramatically reduces verbosity. Once the reader understands that try performs an early return of the error, it increases readability and reliability. The increased readability and reliability comes from defining the error handler code in one place to avoid de-duping it in your mind at each usage site.

The error handler parameter is optional. If no error handler is given, the error is returned unaltered, or alternative mental model is that a default error handler is used which is the identity function type ErrorId[E error] func(err E) E { return err }

The CopyFile example is probably a best case for using defer for error handling. This technique can be used with try, but it requires named return variables and a pointer.

// This helper can be used with defer
func handle(err *error, handler func(err error) error) {
    if err == nil {
        return nil
    }
    *err = handler(err)
}

func CopyFile(src, dst string) (err error) {
    defer handle(&err, func(err error) error {
        return fmt.Errorf("copy %s %s: %w", src, dst, err)
    })

    r, err := os.Open(src)
    try err
    defer r.Close()

    w, err := os.Create(dst)
    try err, func(err error) error {
            os.Remove(dst) // only if Create fails
            return fmt.Errorf("dir %s: %w", dst, err)
    }
    defer w.Close()

    err = io.Copy(w, r)
    try err
    err = w.Close()
    try err
    return nil
}

Conclusion

This proposal allows for:

  • the insertion of a return statement for errors
  • composition of error handler functions together before the error is returned

Please keep discussions on this Github issue focused on this proposal rather than hashing out alternative ideas. Almost all the alternatives have been hashed out already.

Provisional

This proposal should be considered for provisional acceptance. The following will need to be well-specified (some are mentioned below in the appendix):

  • Decide how to interact with defer
  • The best name for try - this should be discussed separately after this proposal is provisionally accepted
  • Firm decision as to whether to use lazy error handlers

Appendix: alternative names

I would be happy with try being renamed to anything else. Besides other single words like check, return if has been proposed.
I use try in this proposal because it is the shortest word that has been proposed so far.
This proposal would leave it to the Go maintainers to decide the best name for the word.

Appendix: lazy error handlers

It is tempting to make error handlers lazy.
This way we don't need to bother with making curried handlers.

x, err := f()
try err, fmt.Errorf("f fail: %w", err)

I am sure this will appeal to many as seeming to be Go-like. It would work to do it this way.
This proposal has a preference for the function handler over a lazy handler to reduce defects.
The lazy form requires using an intermediate variable 3 times. It is possible in Go to produce a defect by using the wrong error variable name.

A go program generally only needs 3 supporting standard error handling functions in a curried form.

  • Wrap an error so that it can be unwrapped (%w)
  • Wrap an error so that it cannot be unwrapped (%v)
  • Add a cleanup handler

However, we should consider supporting both a lazy handler and a function handler.

Appendix: special usage with defer

We could explore making the defer and try combination special in that it would accept an error handler function and apply it to the returned error value (if not nil) without requiring a named return value

func CopyFile(src, dst string) error {
    defer try func(err error) error {
        return fmt.Errorf("copy %s %s: %w", src, dst, err)
    }

Appendix: Citations

Appendix: prior art

There are 2 boilerplate reductions from this proposal:

  • avoiding if err != nil { and using 1 line
  • avoiding generating zero values for the return statement

I believe the latter is well addressed by this proposal that automatically generates zero values from return ..., err. It is unfortunate that no action has been taken on that existing proposal. If this proposal were accepted, I think that in any place where one might use return ..., err one could just use try. If return ..., err were already possible I think try might not add enough value.

This proposal is still open and is equivalent to this proposal without the handler argument. It is suggested to add error handling via handler functions that already have an if err == nil { return nil } guard. But then using handlers requires reading code and looking at the calling function call to understand how it works and to ensure that it works properly.

There have been proposals for dispatching on or after an error value assignment. These are quite similar to this proposal but suffer from being tied to assignment.

This proposal is different, but notes that it adds a with keyword for the handler. We could do that for this proposal, but it seems preferable to only reserve one keyword and use a comma.

I made a similar proposal in which try was an expression rather than a statement.

Appendix: generic enumerations

Now that Go has Generics, we might hope for this to get extended to enumerations and have a Result type like Rust has. I believe that when that day comes we can adapt try to work on that Result type as well.

Appendix: implementation

The try library implements this proposal as a library function. However, it has several shortcomings as a library function that can only be resolved by building features into the Go language itself.

Appendix: code coverage

There are concerns about code coverage. It may be a significant burden for line-oriented code coverage tools to figure out how to tell users if the error paths are getting exercised. I would hate for a helpful tool to hold back back language progress: it is worth it for the community to undertake the effort to have code coverage tools that can determine whether the error path of try is getting exercised.

Appendix: examples

import (
    "fmt"
)

// This helper should be defined in the fmt package
func Handlew(format string, args ...any) func(error) error {
	return func(err error) error {
		args = append(args, err)
		return fmt.Errorf(format+": %w", args...)
	}
}

// This helper should be defined in the fmt package
func Handlef(format string, args ...any) func(error) error {
	return func(err error) error {
		args = append(args, err)
		return fmt.Errorf(format+": %v", args...)
	}
}

func valAndError() (int, error) {
    return 1, fmt.Errorf("make error")
}

func newGo() (int, error) {
    x, err := valAndError()
    try err

    // Common formatting functions will already be provided
    i := 2
    x, err = valAndError()
    try err, Handlew("custom Error %d", i)

    // Using a custom error type
    // For convenience the error type can expose a method to set the error
    x, err = valAndError()
    try err, TheErrorAsHandler(i)
}

type TheError struct{
    num int
    err error
}

func (t TheError) Error() String {
    return fmt.Sprintf("theError %d %v", t.num, t.err)
}

func TheErrorAsHandler(num int) func(err) TheError {
    return func(err error) TheError {
        return theError{ num: i, err: err }
    }
}

Appendix: real world code base examples

I did some automated language transforms to use try on the golang codebase. This is easily automated now with Semgrep rules and a little shell script so I could apply this to any code base. Unfortunately it is only examples of using try without an error handler. Try with an error handler is much more difficult to automate.

Costs

  • Would this change make Go easier or harder to learn, and why?

Harder to learn the language spec because users must learn a new keyword. However, when verbose error handling code is removed, beginners will be able to read and evaluate Go code more quickly and learn Go faster.

  • **What is the cost of this proposal? **

The costs are discussed in detail elsewhere

  • understanding a new keyword
  • requiring go fix for upgrading
  • code coverage tool upgrading
  • How many tools (such as vet, gopls, gofmt, goimports, etc.) would be affected?

All, but those that re-use Go libraries may just need to upgrade their library usage?
I think these tools would then have less source code to analyze and thus run more quickly.
Linters that check that all errors are handled currently use a lot of CPU.
These could be simplified or even removed entirely.

  • What is the compile time cost?

Without handlers I would think it could be reduced because the compiler can avoid evaluating code that previously would have been hand-written. It will reduce the error linting time more significantly (see above) for those of us that run linters right after compilation since checking for proper error handling will be easier.

  • What is the run time cost?

I think this should be equivalent. The success and error path should be the same. However, having better error handling abilities will encourage Go programs to better annotate their errors. But the error path should not be a performance concern.

  • Can you describe a possible implementation?

I started a branch that gives some idea of some of the changes required, but keep in mind that it is incomplete and already making implementation mistakes.

  • Do you have a prototype? (This is not required.)

This can be roughly implemented as a library, done here.
However, it is limited and that can only be solved with compiler modifications.
Internally it uses panic/recover.

  • This is slow when there is an error (but fortunately does not affect the success path)
  • It requires a defer at the top of the function and using a pointer to a named return variable for the error
@gopherbot gopherbot added this to the Proposal milestone Oct 11, 2022
@ianlancetaylor ianlancetaylor added LanguageChange v2 A language change or incompatible library change error-handling Language & library change proposals that are about error handling. labels Oct 12, 2022
gregwebs added a commit to gregwebs/go that referenced this issue Oct 12, 2022
as per golang#56165
This was automated with a Semgrep rule and sed.
It only demonstrates the form of try without an error handler.
Example of the form with an error handler will need to be done manually.
@apparentlymart
Copy link

Thanks for writing up this refined proposal, @gregwebs!

Although I have personally become accustomed to the "long form" of error handling in today's Go and don't feel a strong need to reduce it, I do also recognize that many other Go programmers -- including those new to Go -- find the current approach rather jarring/uncomfortable. Of all of the proposals for first-class error-flow-control constructs so far, this one is the one I feel most comfortable with because I can easily follow what it's a shorthand for, without having to develop any complex new mental models once I've learned about the new keyword.

Some initial reactions below. Some of these are just clarifications to make sure I understood correctly, while others are actual feedback.

  • Although framed as being for errors, on the surface it seems like this can be defined in a way that doesn't special-case the error type:

    The expression which appears after try must be assignable to the last return value of the function. If activated, the try statement assigns that expression to the last return value and updates any other return values to be the zero value of their type.

    One hitch with this, though: if it's broader than error then the implied if predicate is trickier to define, because not all types can be meaningfully compared to nil. Perhaps it could be defined as accepting anything which can be compared to nil, although that seems to get into non-orthogonal territory. Perhaps it is better after all to require error, if only because there's a well-understood convention for whether an error value represents an error or not.

  • The defer try variant feels a bit tricky. Defer is currently defined as being followed by an expression, but we're intentionally making try a statement instead of an expression and so defer try is essentially a new statement in its own right here, which I see you already touched on in the proposal.

    Making defer try work seems like would invite questions about why defer if doesn't work, if try is a shorthand for an if statement. However, I don't personally feel that strongly about it and am only mentioning it for completeness; I can't imagine any real situation where defer if would be clearer than a normal if statement inside a defer function.

  • I retain my previous worry that the form of try with only one expression is so convenient (in comparison to the longer version which requires defining a separate error-transforming function) that folks will be tempted to use it in situations where it isn't ideal.

    However, I can't really argue that this isn't already somewhat true for if err != nil { return err }. The fact that the expression in the return statement is an inline "lazy expression" (to copy the terminology from your proposal) does make it marginally more convenient, but as you pointed out it would be possible for try to treat its second argument that way if that turns out to be a significant concern.


I think the above is the extent of my initial substantive reaction. Everything else is details that I would not expect to affect the acceptance of the proposal. The following is therefore intended as presumptuous feedback for a subsequent effort to develop this from an accepted proposal into a final design, rather than for deciding whether to accept the proposal:

  • try followed by a noun rather than a verb just reads weirdly to me. I find check better because it is a word that seems more naturally followed by a noun. I also like the idea of something involving return if possible, since that would really cement the idea that this is a shorthand for returning early.

    (try DoSomething() for a function which only returns an error doesn't have this problem; it's the form where we're checking an error variable assigned in an earlier statement that I'm concerned about.)

  • Using a comma between the error expression and the error transformer expression feels a bit too similar to the syntax for multiple return values or multiple assignment. I worry that a naive reader would presume that the second argument is somehow another value to be returned, rather than a function to be called. I'd recommend either another keyword or some different punctuation, to hopefully make it more obvious that the error transformer is something unique to this statement.

Thanks again!

@gregwebs
Copy link
Author

gregwebs commented Oct 12, 2022

@apparentlymart thank you so much, I value your feedback and the time you have taken to properly understand the proposals. And I really appreciate how you have separated it out into 2 sections.

I think I will alter the proposal to put the content on defer into an appendix section. It's definitely the part where I am thinking out loud about something I don't have a great conclusion on yet.

this can be defined in a way that doesn't special-case the error type:

In that case is is possible to do this transformation in the future then? But when I look to the future I hope for a language that has sum types and is using a result type and an option type. And I think this would only need to work for those two types. So attempting to generalize now to zero types may cause problems in the future.


As for the details not affecting acceptance:

I find check better

I actually prefer check as well, but have stuck with try for now because it is fewer characters.

Using a comma

What do you think about other possible options to replace a comma:

  • a space
  • a semi-colon (like for)
  • keeping the comma, but enclosing the 2 in parentheses (like a function call)

@gregwebs
Copy link
Author

I did some automated language transforms to use try on the golang codebase. This is easily automated now with Semgrep rules and a little shell script. Unfortunately it is only examples of using try without an error handler. Try with an error handler is much more difficult to automate.

@gregwebs
Copy link
Author

I retain my previous worry that the form of try with only one expression is so convenient (in comparison to the longer version which requires defining a separate error-transforming function) that folks will be tempted to use it in situations where it isn't ideal.

There is no need for end users to define an error handling function. As stated, there are probably about 3 error handling helpers that satisfy normal usage. And it is trivial to define them if you need to. I defined them in my package, but I will reproduce them here.

func Fmtw(format string, args ...any) func(error) error {
	return func(err error) error {
		args = append(args, err)
		return fmt.Errorf(format+": %w", args...)
	}
}

func Fmt(format string, args ...any) func(error) error {
	return func(err error) error {
		args = append(args, err)
		return fmt.Errorf(format+": %v", args...)
	}
}

func Cleanup(handler func()) func(error) error {
	return func(err error) error {
		handler()
		return err
	}
}

So rather than

    try err, func(err error) error {
        return fmt.Errorf("valAndError %w", err)
    }

One would write

    try err, Fmtw("valAndError")

I will see if I can better incorporate these into the proposal to make this clear.

gregwebs added a commit to gregwebs/try that referenced this issue Oct 12, 2022
try.Try and try.Check can be used.
This just requires an intermediate error variable.
In many real world cases this is a good way to make code readable.
This aligns the library to community feedback and this proposal:
golang/go#56165
@apparentlymart
Copy link

Thanks for the clarification on the error handler functions, @gregwebs . On my first read I understood that as something provided by user/library code rather than standard library, but providing some ready to use does indeed improve the convenience of the wrapping case.

@percybolmer
Copy link

percybolmer commented Oct 12, 2022

While I do like the idea behind this proposal as it would reduce code boilerplate, I have to ask if there has been any reflections on the usage combined with the current
errors package.

I have a hard time seeing this integrate well with errors.Is and errors.As which I find frequently used to handle certain types of errors.

In those cases I suppose that logic would instead be inside on the the Handlers, and thus making the code much harder to read and understand.

The usage would be smth like

   try err, func(err error) error {
        If errors.Is(ett,ErrMissingData){
            return nil // don't want to err on SQL no row matches etc
       }
        return fmt.Errorf("valAndError %w", err)
    }

This increases complexity, especially for junior devs I think.

The current err handeling would be

If !errors.Is(ett,ErrMissingData){
            return fmt.Errorf("valAndError %w", err)
}
        

@gregwebs
Copy link
Author

gregwebs commented Oct 12, 2022

@percybolmer filtering errors is something I have thought about but didn't mention in the proposal, maybe it deserves an appendix section. In my own experience, filtering comes about most often in the use case you are presenting: a lookup in which no data is returned. In my view, many of these APIs could be designed to return (*data, error) where (nil, nil) indicates the lookup failed to find data. Or if an array is returned, just the empty array is enough to signify no data. I usually handle the issue in my code bases by writing a wrapper that does this once. After this is taken care of, I don't have that much error filtering to do.

But let's discuss how to handle this if the try tool were available.

Today you would write:

if err != nil && !errors.Is(err, ErrMissingData) {
        return fmt.Errorf("valAndError %w", err)
}

With try, in this case, you must filter the error first.
Let's see what we can do with a small function helper.

// This function would exist on the errors package
func FilterIs(err, target error) error {
    if errors.Is(err, target) {
        return err
    }
    return nil
}

try errors.FilterIs(err, ErrMissingData), fmt.Handlew("valAndError")

But I think I might prefer the original or something quite similar

if !errors.Is(err, ErrMissingData) {
    try err, fmt.Handlew("valAndError")
}

Although to reiterate my original point, I feel that this example is contrived: we should probably do something different for ErrMissingData or adjust how our APIs work (with a wrapper as necessary).

@gregwebs
Copy link
Author

gregwebs commented Oct 13, 2022

There are 2 boilerplate reductions from this proposal:

  • avoiding if err != nil { and using 1 line
  • avoiding generating zero values for the return statement

I believe the latter is well addressed by this proposal that automatically generates zero values from return ..., err. It is unfortunate that no action has been taken on that existing proposal. If this try proposal were accepted, I think that in any place where one might use return ..., err one could just use try. If return ..., err were already possible I think try might not add enough value.

@percybolmer
Copy link

percybolmer commented Oct 13, 2022

I do see what you mean, and I am fond of your solution, but also afraid of it.

Zero values can already be reduced by using named returns, so I don't see a need for that tbh. However named returns do not seem to be very popular in code bases. So maybe an alternative as suggested could prove handy.
But I see your standpoint, Far to often we see pointer returns to avoid handeling the zero value, because nil check is just easy.

Also, there is short hand if statements which makes them one liners.

Having helper methods as suggested increases complexity though, it might seem trivial, but many developer even find errors.Is and errors.As confusing. Sure they might be juniors, but the current beauty of Go is that it is easy for even juniors, and this would be a step that increases the complexity for a small boilerplate reduction, or even an increase in having handlers assigned anonymously or as accessible functions.

@gregwebs
Copy link
Author

Zero values can already be reduced by using named returns

This was pointed out in the return ..., err issue to be a poor solution. A named return means: go to where the function is defined, look at that name, then look down through the rest of the function to see if the name has ever been assigned to before the return statement. Whereas return ..., err just means: return zero values with the error. Generally this is the problem of zero values and one should avoid assigning a zero value to a variable when possible and only assign to it when a value is available.

Having helper methods as suggested increases complexity

I appreciate bringing this perspective. I would say though that errors.Is and error wrapping is a new concept that involves recursion of interfaces rather than declared OO inheritance, I think one does have to study it a little, and the %w/%v distinction must be watched out for as well.
For the helpers I am suggesting, I think fmt.Handlew("string") is quite the same as fmt.Errorf("string: %w"). It does though introduce currying. I think this would be an argument for using lazy handlers rather than curried. I think though that rather than try with lazy handlers just return ..., err would be enough.

@bdzwillo
Copy link

If this is an essence of the past error proposals, you could make it easier for the occasional reader of go code, if you borrow the "return if" naming from proposals like #53017. The try statement from this proposal could simply be renamed to returnif:

  i, err := strconv.Atoi("42")
  returnif err, handler

This would better indicate that this is a return statement, and still allow to grep for "return" through the source code.

If you would addionally allow to infer the '!= nil' comparision for values of type error in if statements, the operation could be made even more obvious. Then the returnif err statement could be a shorthand for returnif err != nil, err. And this would just be syntatic sugar for a checked return statement with zero value insertion:

  if err {
      return ..., err
  }

@dmajkic

This comment was marked as off-topic.

@Skarlso
Copy link
Contributor

Skarlso commented Oct 14, 2022

There is no try only do. Go errors are explicit, verbose and on the nose. If you hide it behind magic, you will have a bad time. If I read the code, I can clearly identify an error and what will happen if said error does occur. I don't want to hide it! I want to see, read and be reminded of its existence. Otherwise, I might miss an important error.

Also, I'm adding different error messages for each error with fmt.Errorf. If I understand you correctly, I would have to have a different handler for each of those? That would obviously be a problem and just increase the complexity and number of lines further. :)

@treeder
Copy link

treeder commented Oct 14, 2022

@Skarlso I agree and I think that's the idea behind the "lazy" appendix, which I think would be essential to make this useful.

try err, fmt.Errorf("f fail: %w", err)

@apparentlymart
Copy link

@dmajkic that seems like a materially different proposal than what this issue is representing -- the only similarity is the keyword try -- so I would suggest writing it up as a separate proposal issue if you're intending to enter it into consideration.

@steeling
Copy link

I strongly believe we should not add the ThenErr error chaining in this proposal. Better error handling has been discussed a lot, so I'd call this a big change to the language. I'd recommend we keep the change small for its first iteration.

Some other things that stick out to me:

  • I agree with other commenters that check or returnif is a better keyword. "Try" implies that you are trying something, but an error might occur. With this proposal, the "trying" has already occurred, and we're simply checking if an error has occurred.
  • Agree with other commenters that a comma looks too similar to multi-assign statements.
  • It might be worth mentioning how handler might interact with a defer that interacts with named returns error params, although if the handler is just syntactic sugar for if err != nil {...}, then defer's should continue to work as normal

@shawc71
Copy link

shawc71 commented Oct 14, 2022

There is no try only do. Go errors are explicit, verbose and on the nose. If you hide it behind magic, you will have a bad time. If I read the code, I can clearly identify an error and what will happen if said error does occur. I don't want to hide it! I want to see, read and be reminded of its existence. Otherwise, I might miss an important error.

I disagree that this proposal makes anything more "magical" or "hides" anything. This proposal provides syntactic sugar to handle an extremely common usecase and the final result is clearer more succinct code. I am personally okay with the current if err != nil way of error handling but this is indeed seen as somewhat of speedbump by many go developers.

It must be noted that this proposal maintains the current spirit of Go's error handling- the errors remain explicit and one is free to drop into if err != nil blocks if that makes more sense in the current context while providing syntactic sugar for common usecases. Rust for example also provides a construct that is similar in spirit to what this tries to accomplish and it does not result in code being any less explicit or more magical.

@Scratch-net
Copy link

Scratch-net commented Oct 14, 2022

Will we be able to do try f() where f = func() error ?
Or even better try a,b,err := f(), handler

@SealOfTime
Copy link

This proposal is rather inconsistent in the definition of try's grammar.
At the start of the proposal try's arguments are introduced as "error and handler of type func(error) error", but later in "Appendix: lazy error handlers" try is used with arguments of "error and function call".

Additionally, in my opinion, this proposal is much more costly, that the author suggest. It's not only the keyword to learn, but also a difference of when to use if err != nil idiom and when to use this syntax sugar. I believe this proposal may either introduce inconsistency in error handling, since one times the if err != nil would be used and other times the try statement with a handler, it won't be possible for go vet to automatically figure out, which fits the best, or totally revolutionize the current approach at error handling. Moreover, it definitely introduces inconsistencies in ways of exiting a function, which would require a massive rework of all tools, that rely on analyzing that.

@jmonroynieto
Copy link

jmonroynieto commented Oct 14, 2022

> Will we be able to do try f() where f = func() error ? Or even better try a,b,err := f(), handler

I think this perfectly exemplifies the concern raised by @steeling about the try keyword being misleading.

On the topic of the keyword, other options presented also produce misled interpretations. For example: in a lot of ways, check looks like a special reduced use of a ternary statement. As a consequence, extensions of expressions like defer if start to become apparently natural in the language as mentioned by @apparentlymart, and open unnecessary discussion space for syntax expansion covering increasingly arcane edge cases.

I don't disagree at all with the idea of making it more readable. I apologize for bringing in an alternative to the discussion; I think some contrast might accentuate my point about how better syntax can alleviate the natural and unavoidable consequence of introducing syntax concepts that seem extensible.

handle err with fmt.Errorf("f fail: %w", err)

It looks a bit too English-like for what the rest of the language usually does. Still, it delivers simplicity and ease of reading, and it is specific enough that fewer attempts to expand the syntax would be prompted. The language sounds specific to error handling, which confines the scope of this language change.

The internals of the implementation on this proposal would work perfectly fine with this, and additionally, you would circumvent the issue of using a comma.

@gregwebs
Copy link
Author

I just want to clear up some misconceptions

I would have to have a different handler for each of those?

No. If you normally use fmt.Errorf, you just need the same function in curried form written once somewhere in a lib or your code. If we use the lazy approach then you would just use fmt.Errorf as usual.

Will we be able to do try f() where f = func() error ?

Yes, this is mentioned explicitly in the proposal

Or even better try a,b,err := f(), handler

No, this proposal leaves assignment to assignment and error handling to error handling

@lunemec
Copy link

lunemec commented Oct 17, 2022

Thanks for taking the time to make a proposal @gregwebs , while this would make it easier for newcomers, it is not to my taste. For some reason I don't see this as "go" code. It seems to me that the solution you propose would only remove 1 line if err != nil while adding more lines for the handler(err) func in the end.

I like the idea of improving error handling, but to me, having try err, h(err) sprinkled in my code instead of if err != nil isn't great. I'd stick with the old way if I had the option.

Sorry I'm not more helpful, but I can't even nail down why I don't like it, maybe it reminds me too much of ? hidden error returns, or flashbacks of Python's exceptions... 😄

I would much rather see something akin to Rust's Result<T, E> and Option<T> handling of errors and optionals, backed by the type system. I think those would be much more valuable, even though they would require more changes to the language.

@allochi
Copy link

allochi commented Oct 17, 2022

I didn't read all the posts, but like others I though and read a lot about error handling in Go, I don't have strong opinion, more thinking loud.

Is it really about adding a new keyword because of a missing functionality or is it about code format/readability?

if we can add a case to the go formatter to allow one line if with couple of statements like this

if err != nil { handle(err); return }

if err != nil { handle(err); return something, err }

if err != nil { log.Fatalln(err) }

borrowing from @gregwebs post above

func CopyFile(src, dst string) error {
    handler := func(err error) error {
            return fmt.Errorf("copy %s %s: %w", src, dst, err)
    }

    r, err := os.Open(src)
    if err != nil { handler(err); return }
    defer r.Close()

    w, err := os.Create(dst)
    if err != nil { return specialHandler(err) }
    defer w.Close()

    err = io.Copy(w, r)
    if err != nil { handler(err); return }
    err = w.Close()
    if err != nil { handler(err); return }
    return nil
}

func specialHandler(dst string, err error) error {
    os.Remove(dst) // only if Create fails
    return fmt.Errorf("dir %s: %w", dst, err)
}

Anyway, if error handling is more than one line beside return it should be in multiple lines, this way it explicitly describe the steps of error handling.

@andrius4669
Copy link
Contributor

what abt

func x() (int, int, error) { ... }
a, b := try x(), fmt.Errorf("x err: %w")

have this been tried to be probosed before (i haven't been keeping up)?

@apparentlymart
Copy link

@andrius4669: try as an expression was the subject of #55026.

This new proposal is in response to the decision that expressions may not return early and that anything which looks like an expression must be valid in any situations where expressions are valid, rather than special case for particular situations. You can read more about that in the other issue.

@kiqi007
Copy link

kiqi007 commented Oct 18, 2022

  1. Much like #define in c language, Or, maybe it's better to add a define?
  2. Just turned 3 lines of code ** if err!=nil{ // do somethine } ** into one or two lines,but with more implicit processing
func checkNilErr(err error, doIfErr func()) bool{
     if err != nil {
         doIfErr()
         return false
     }
     return true
}

func process() {
   errH := func() {
       // do somethine
   }


   // ...
   err := doSomethins()
   if !checkNilErr(err, errH) {return expr}    // So, try just shortens this line? but more inflexible
   
  // ...
}

@russoj88
Copy link

@gregwebs thanks for writing this proposal, even though I disagree with it.

@steerling thanks for pointing that out. Just this week I wrote a bug where I messed up the equality and wrote if err == nil and it got through code review. Granted, I am working to setup linters that will catch that, but ideally we wouldn't need to rely on linters. The point is that rather than being highly readable, it ends up being a bunch of boilerplate that is glazed over. However, the !=nil is not as bad as having to insert zero values. Developers are tempted to return pointers to structs just so they can have something that is easy to write and read return nil, nil, err rather than return LongStructName{}, package.OtherStructName{}, err where the zero values are reducing readability.

I think this bug is a bad argument for if err != nil being a negative thing. This bug is a logic bug that should have been caught with a test. Errors being values and treated as regular code makes this simple.

@gregwebs
Copy link
Author

I think this bug is a bad argument for if err != nil being a negative thing. This bug is a logic bug that should have been caught with a test. Errors being values and treated as regular code makes this simple.

Great testing doesn't make code more readable even if it reduces the defect count. Similar to linting which is a better solution in this case.

@allochi I think one line formatting combined with a syntax for a zero value return would be a satisfactory solution.

if err != nil { return ..., handle(err) }

I really appreciate everyone's interest in this proposal. Please keep in mind though that every solution that has been proposed in the comments has already been proposed in comments or proposals elsewhere. Some of these ideas might be what the community needs, but they would have to be fully fleshed out in a proposal. This proposal itself only varies by an already open proposal by attempting to add a handler syntax.
So the most helpful ideas to discuss here are direct tweaks of this proposal, for example what syntax to use instead of a comma, rather than proposing an entirely different idea.

In general it seems like the useful discussion is starting to wind down on this proposal. I think the Golang proposal process suffers from not summarizing useful information. We would benefit from putting together a summary of the benefits of this proposal and the critiques of it. I think I can do this in a mostly detached way, but it could benefit from a different party playing that role.

@treeder
Copy link

treeder commented Oct 20, 2022

This is a great discussion all, clearly something a lot of people are interested in. I've been following along here and it seems we all just want less clutter for the most common use cases of error handling. So what if we took a little bit out of JavaScript's playbook and even made it more terse:

if err { return fmt.Errorf("abc: %w", err) }
  • The if err would obviously evaluate to if err != nil
  • If the block is a single line, it would format like above
  • It would return zero values for all other return values

@DeedleFake
Copy link

DeedleFake commented Oct 20, 2022

It would return zero values for all other return values

What does the following return?

func Example() (string, error, error) {
  err := doSomething()
  if err { return err }
}

@treeder
Copy link

treeder commented Oct 20, 2022

Equivalent to:

return "", nil, err

@apparentlymart
Copy link

apparentlymart commented Oct 20, 2022

A few people now (across this and some earlier proposals) have proposed alternatives which seem to involve combining the following two independent building-blocks:

I think it's instructive that of all of these at the time of writing only the second set remains open, and in particular the first set related to using error (or any other subset of nilable types) as a predicate seems to have been explicitly declined multiple times and is therefore not on the table unless there is new information to consider.

I think the shortest possible alternative using the above ideas, without using any mechanisms that have already been declined in previous proposals, would be something like:

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

or indeed, the one-liner form of that:

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

...but that's already essentially what #21182 proposes -- the above uses nothing except a form of that proposal -- and so that other issue is probably the better place to discuss the pros and cons of that approach, rather than here.

@esdnm
Copy link

esdnm commented Oct 23, 2022

what if we let a function that returns an error value register an handler and have a way to tell it to either handle any error with the error handler it registered or explicitly gets handled by the caller

@godcong
Copy link

godcong commented Oct 24, 2022

I think the error handling should be pre-defined in the same function with your error try.
Like this:

catch (v any, err error) bool {
	if err != nil {
		return true
	}
	return false
}

Or like this:

catch (err error) bool {
	if err != nil {
                //do something
		return true
	}
	return false
}

Or some function your defined

catch ErrorDo

Or not defined, it will call the default

catch (err error) bool {
	if err != nil {
		return true
	}
	return false
}

the return value must bool to tell go return error or not.

Then below the same function, you can do like this:

	try err := doSomething()
	//or
	try err

Or

	try r, err := os.Open(src)
	//or
	try r,err

The number of try parameters must be the same as the number of catch parameters. If only error is checked, the preceding parameters can be defined as any
catch must defined in the same function,If not means that the default processing is

catch (err error) bool {
	if err != nil {
		return true
	}
	return false
}

This is to be checked separately if there are multiple parameters

	try err := doSomething()
	//or
	try err

Further, if the handling of anonymous functions can be handled well. This is also possible.
The catch should not be nested with other tries, except by default.

catch (v any, err error) bool {
	try err
}

All catches can actually be considered as anonymous functions.
When a catch with multiple parameters is defined, the default catch is replaced

This will greatly streamline the code, and in many cases one line will suffice.
It will not look so messy

        //old handling
	err := doSomething1()
	if err != nil{
		return err
	}
	err := doSomething2()
	if err != nil{
		return err
	}
	err := doSomething3()
	if err != nil{
		return err
	}
	err := doSomething4()
	if err != nil{
		return err
	}
	err := doSomething5()
	if err != nil {
		return err
	}

	//new handling
	try err := doSomething1()
	try err := doSomething2()
	try err := doSomething3()
	try err := doSomething4()
	try err := doSomething5()

@lzambarda
Copy link

Interesting concept but I feel it is just adding another layer of interpretation and possible confusion to what is in reality a very straight-forward part of Go. All to save 1-2 lines of code, feels more about laziness than readability.
In my opinion this is more in the scope of a stricter linter rather than of the programming language itself.

@forsaken628
Copy link

I have a idea, error just a value,no magic.

func Bar1() (int, error)

func Far() error {
	err! error {
	    if err != nil {
	        return err
	    }
	}

	b1, err! := Bar1()
	_, err! = Bar1()

	/* after translated
	var _err0 error
	b1, _err0 := Bar1()
	{
		var err error = _err0
		if err != nil {
			return err
		}
	}
	var _err1 error
	_, _err1 = Bar1()
	{
		var err error = _err0
		if err != nil {
			return err
		}
	}
	*/

	_=b1

	return nil
}
        // not just for error, also for bool
	ok! bool {
	    if !ok {
	        return errors.New("not found")
	    }
	}

	var m map[int]int
	a, ok! := m[5]
	s += a
	a, ok! = m[3]
	s += a
        // may no return
        var ls []int
	item! int {
		if i % 2 == 0 {
			ls = append(ls, item)
		}
	}
	item! = 1
	item! = 2
	item! = 3
	item! = 4
	item! = 5

	// ls == []int{2,4}

https://github.com/forsaken628/error-handling (WIP)

@gregwebs
Copy link
Author

It doesn't seem there is any new discussion on this proposal (instead just different proposals). The different proposals being posted here have all been proposed before.

Let me try to summarize the feedback on this proposal. If someone wants to chime in with major points that I missed, that would be appreciated. Otherwise I would be happy to close the discussion now.

The below are points that in theory are easy to resolve by altering the existing proposal:

  • The name try does not capture the operation well, check and returnif are given as possible alternatives.
  • The use of a comma separator is not liked by some because it looks like a multi-value return. with has been given as a possible alternative.
  • Adding a ThenErr error handler function composition is dis-liked by many (it's not clear to me why). An alternative is to not add it.
  • defer try does not match how defer currently takes an expression. The alternative is to not add this.

Below are points that were raised about the benefits and costs of this proposal:

  • There is some interest in generalizing these kind of proposals to work for other zero values in addition to just errors. But this has not been fully thought through. This could make the feature more powerful, and thus more worth it, or it may just be confusing and unworkable.
  • It may be very difficult or at least a lot of work for tooling to adapt to this new statement. Thus this may be a difficult breaking change for some tools

And of course, there are a lot of value judgements about whether adding this new feature is worth the cost or otherwise the right thing to do for Golang. In the above I tried to write down any factual statements that came out of these, but sometimes they are hard to tease out.

@gregwebs
Copy link
Author

@ianlancetaylor as a side note I think the model of using an issue as a proposal is broken. Proposing via pull request (as is commonly done in community RFC processes) would be a big improvement because it would allow for and encourage a clear discussion thread on a particular point of a proposal.

@steeling
Copy link

@lunemec

I would much rather see something akin to Rust's Result<T, E> and Option<T> handling of errors and optionals, backed by the type system. I think those would be much more valuable, even though they would require more changes to the language.

I wonder if the proposal here, and what you describe are mutually exclusive. I agree having Option<T> has nicer properties than the current proposal, but I think the two can be used in tandem.

type Optional[T] struct {
   ...
}

func (o Optional[T]) Unwrap() error {...}

optional := myFunc()
check optional.Unwrap()

This proposal would bring us closer to being able to do that, as opposed to an Unwrap, with if err != nil {...}. As stated from prior proposals, the control flow should be a statement level operation, not an expression, so having a special cased Unwrap would not suffice (given current agreed upon constraints)

@ianlancetaylor
Copy link
Contributor

@gregwebs I agree that GitHub issues are far from perfect. But the discussion aspect of a proposal is essential, and at least on GitHub discussions on a pull request are no better than discussions on issues.

(Note that we have recently started using the relatively new GitHub discussions for some topics. They are also imperfect but are slightly better than GitHub issues.)

@gregwebs
Copy link
Author

gregwebs commented Oct 24, 2022 via email

@ianlancetaylor
Copy link
Contributor

I see no advantage to that feature over what GitHub discussions provides. (That said, this is not the place for this discussion thread. golang-nuts would be better. Thanks.)

@godcong
Copy link

godcong commented Oct 25, 2022

GitHub issues are clear and unambiguous.
Projects, issues, and discussion history. This can be easily understood by first-time visitors.

@rsc
Copy link
Contributor

rsc commented Oct 26, 2022

I believe we put all the language changes for error handling on hold. We should probably do the same for this one.

@pieroarkana
Copy link

pieroarkana commented Oct 26, 2022

Thanks for the proposal.

I am fully against it:

  • how many keystrokes does it save? It actually adds more in many cases
  • readability improvement: it worsens readability by introducing magic, also all IDEs today fold errors
  • writeability improvement: it worsens writeability by introducing magic (see for example try/catch in Java, where behavior is difficult to predict in elaborate try/catch/finally cases)
  • second order consequences: it opens the possibility of finally, universally recognized as a mistake in Java, and even worse, Optional

But, above all, it steepens significantly the learning curve:

Learning case one: erros are values, errors are there when a non nil value is detected

Learning case two: a special keyword, try, is introduced, that behaves in the following way: case 1... case 2... case 3, and code is autogenerated...

Basically it boils down to targeting people coming from Java/Javascript, where it's easier, in the beginning, to read code that has "try", so we obfuscate a clean language to make the learning curve shorter by one day.

@gregwebs
Copy link
Author

I will mark this proposal as closed since there has been nothing new added to the discussion in quite some time. I would support locking discussion on it, but as a reminder, there is an Unsubscribe button on this page.

I will put my summary in the proposal itself.

@gregwebs gregwebs closed this as not planned Won't fix, can't repro, duplicate, stale Oct 27, 2022
@golang golang deleted a comment from Mk1206mk Oct 28, 2022
@sapmli
Copy link

sapmli commented Jan 2, 2023

Thanks for the proposal.
My 2 cents: If try err is translated to return err, why write try err, handler to have it translated to return handler(err)?
We could also write try handler(err), which is just cleaner and more understandable, and it could be lazily evaluated like a defer statement, so that the handler is not gonna be called if there is no error.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
error-handling Language & library change proposals that are about error handling. LanguageChange Proposal v2 A language change or incompatible library change
Projects
None yet
Development

No branches or pull requests