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: cleaner error handling happy path via limited pattern matching #65266

Open
1 of 4 tasks
DeedleFake opened this issue Jan 24, 2024 · 4 comments
Open
1 of 4 tasks
Labels
error-handling Language & library change proposals that are about error handling. LanguageChange Proposal v2 A language change or incompatible library change
Milestone

Comments

@DeedleFake
Copy link

Go Programming Experience

Experienced

Other Languages Experience

JavaScript, Elixir, Kotlin, Dart, Ruby

Related Idea

  • Has this idea, or one like it, been proposed before?
  • Does this affect error handling?
  • Is this about generics?
  • Is this change backward compatible? Breaking the Go 1 compatibility guarantee is a large cost and requires a large benefit

Has this idea, or one like it, been proposed before?

I don't think so, but considering how many error handling proposals there have been over the years it is possible.

Does this affect error handling?

Yes. It differs in that it doesn't attempt to handle errors in a magical way, but instead introduces a new syntax that can be used for several different types of common data handling.

Is this about generics?

No.

Proposal

Prior Art

In Elixir, pattern matching is often used as a way to deal with errors. Functions that can return error values usually return a tuple of the form {:error, error_string}, and will usually return {:ok, result} when they don't fail. This results in code that looks like

{:ok, result} = thing_that_can_fail()

That code checks to make sure that the first element is :ok and assigns the second element to a new variable, result. If the pattern does not match, it will crash. When you want to handle the error, however, you generally use something like a case expression, which allows checking multiple possible patterns:

case thing_that_can_fail() do
  {:ok, result} -> use_result(result)
  {:error, err} -> handle_error(err)
end

This is fine until you need to do a bunch of things in a row and several of them can fail:

case thing_that_can_fail() do
  {:ok, result} -> case other_thing_that_can_fail() do
                              {:ok, result2} -> use_results(result, result2)
                              {:error, err} -> handle_error(err)
                            end
  {:error, err} -> handle_error(err)
end

To help deal with this, Elixir has a with expression which allows handling multiple pattern matches in a row. The following code is functionally identical to the previous snippet:

with {:ok, result} <- thing_that_can_fail(),
         {:ok, result2} <- other_thing_that_can_fail() do
           use_results(result, result2)
else
  {:error, err} -> handle_error(err)
end

Proposal

My proposal is to adopt a variant of the second syntax above, with, and make it a bit more Go-like, and use it to allow deduplication of repeated, nearly identical error handling. This would work by adding a new keyword, though what exactly I'm not sure. To illustrate, I'll use with to match the Elixir code, but it doesn't really need to be. Normally a new keyword would be a problem in terms of backwards compatibility, but it should be possible to tell from context if the keyword should be treated as such here. It would only be legal to use the keyword if it was immediately followed by a { and was the first part of a statement. In all other cases, the keyword would be treated as an identifier. I don't know if this is too complicated in and of itself, but if so there may be alternative syntaxes for this that wouldn't cause such issues. I haven't thought of them, though.

Inside of a with block and only inside of a with block, very limited pattern matching would be possible. It would use a new assignment operator which I shall assume to be ~= for illustration purposes. Pattern matching would only work on direct results of functions, not on the internals of values, and would be simple == checks.

Here's an example:

with {
  result, nil ~= thingThatCanFail() // This line fails the pattern match if the second return value is not nil.
  val, true ~= result.(T) // Works for all comparable types, not just errors, and even works with type assertions.
  return useValue(val) // Other lines of code are possible, too.
} else {
  case _, error(err):
    return nil, fmt.Errorf("failed to do thing: %w", err)
}

The else block would be a list of cases looking similar to a switch or a select. Each case would be a comma-separated list of identifiers, with each being the same as a pattern match above. If any pattern match in the with block fails, the same values are run against the cases of the else block in top-to-bottom order. If one succeeds, it is run instead and then the whole block exits.

Pattern matches would be very limited in terms of what they could do. Along with being able to check against literals and non-shadowed predefined values, such as true, nil, etc., they could each be wrapped in what looks like a type conversion. If they are, they are constrained to that specific type. This is demonstrated in the error handling case above. If no cases in the else block match, the entire block panics. Or does nothing. I'm not sure which makes more sense.

Comparison Example

As another example, here's a program that opens two files and copies the contents of one into the other. Here it is without with:

src, err := os.Open("input.txt")
if err != nil {
  return err
}
defer src.Close()

dst, err := os.Create("output.txt")
if err != nil {
  return err
}
defer dst.Close()

_, err := io.Copy(dst, src)
if err != nil {
  return err
}

And with with:

with {
  src, nil ~= os.Open("input.txt")
  defer src.Close()

  dst, nil ~= os.Create("output.txt")
  defer dst.Close()

  _, nil ~= io.Copy(dst, src)
} else {
  case _, error(err):
    return err
}

Primary Concerns

I have three primary issues with my own proposal. I think both of them are solvable, but I'm not quite sure at the moment how to do so.

One is the specific definition for the rules surrounding variable creation. In the above examples, I just kind of assumed that src and dst were being created by the ~= operator, but maybe that assumption doesn't make sense. Should it just work exactly like := in terms of variable creation, shadowing, etc? Or should it never create new variables? What are the rules surrounding what is a value on the left-hand side and what is not? Should it only be predeclared values, or should it be possible to match against values stored in variables themselves? Elixir uses the pin operator to declare that a variable should not be declared during a match, i.e. {^v, r} = something() meaning that r is a new variable but ^v should just match against the value already stored in v. Maybe something like that makes sense? I'm not sure, and I could probably be persuaded either way.

Similarly, I'm worried about ways to differentiate between similar cases that should be handled separately. For example, what if I wanted to add custom error messages to the above example? If all I wanted to add it to was io.Copy(), it would be easy enough to just not use pattern matching for that call and handle it the old-fashioned way there. Another option would be to add a third value to indicate, something like

with {
  _, err := io.Copy(dst, src)
  nil, err ~= err, fmt.Errorf("copy failed: %w", err)
} else {
  case _, error(err):
    return err
}

And finally, how should overlapping types be handled in else cases? For example, if I used the above code in the example before, the _, error(err) case would have two different possible types for _, *os.File and int64. Is that a problem? Maybe it could work similarly to #65031. That could get kind of messy, though.

Conclusion

As you can probably tell from the concerns section, I'm not entirely sold on my own proposal, but I think it's a possibility for a completely different approach to cleaning up error handling code. It doesn't solve every problem people have with error handling, but I think that it leverages multiple returns as part of handling errors more than most proposals, many of which are trying to come up with ways to make functions with multiple returns including an error act as though they are not. That being said, even if this proposal is rejected, maybe it'll inspire a better one.

Language Spec Changes

Two main changes: Add a with block and add the ~= operator and associated rules.

Informal Change

No response

Is this change backward compatible?

I think so, but it should be possible to create an alternative that is if it is not. The parts that are potentially no backwards compatible are basically just implementation details.

Orthogonality: How does this change interact or overlap with existing features?

It enables separation of repeated error handling from the happy path of the code.

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

Slightly harder, but I don't think that it's overly complicated. It adds a new type of control flow, but I think that the main complication would probably come from the rules surrounding how the pattern matching works.

Cost Description

Some extra complexity. Possible runtime cost, but very minimal if it exists at all. It's mostly just syntax sugar. Most possible cost could come from some potentially unnecessary type switches, but it might be possible to optimize those away at compile-time in most cases.

Changes to Go ToolChain

Everything that parses Go code would be affected.

Performance Costs

Likely minimal in both cases.

Prototype

No response

@DeedleFake DeedleFake added LanguageChange Proposal v2 A language change or incompatible library change labels Jan 24, 2024
@gopherbot gopherbot added this to the Proposal milestone Jan 24, 2024
@seankhliao seankhliao added the error-handling Language & library change proposals that are about error handling. label Jan 24, 2024
@mainjzb
Copy link

mainjzb commented Jan 25, 2024

like try/catch. Based on my observation, people are disgusted with try/catch in the community

@DeedleFake
Copy link
Author

It has some similarities unfortunately, yes. I'm one of those "disgusted" with try/catch myself. I don't think this has some of the main problems that try/catch does, though, particularly that the only real way to handle multiple errors with try/catch is to wrap the entire thing around every single line, which winds up right back where if err != nil was, but worse. I had an alternate that was per-line without the extra indentation, but I thought it was so close to if err != nil that it didn't really warrant mentioning. It also was lacking a number of key parts without which it completely doesn't work. It looked something like

// Very backwards incompatible, too.
use result, nil := something() else {
  return ???
}

My primary goal with the proposal was to try to come up with an alternate way of handling errors that isn't specific to error. I'm not sure I really succeeded, though, unfortunately. As I said, I'm not thrilled with how it came out, either, but I do think that pattern matching as a way to more succinctly check multiple returns has some merit as a mechanism to investigate.

@kevin-matthew
Copy link

The proposed syntax to this is very interesting. It is much cleaner, more useful, more predictable, and more controlled then the dreadful try/catch in my opinion. I wish every language which utilizes the try/catch uses a syntax like this instead. However, I think this additional syntax to golang goes against the simplicity we hold so dear here. With this proposal, and all the multi-error-handling proposals like it, we end up having 2 syntax choices doing the same thing ("should I use if-not-null chains or with block?")... with if-not-null chains being much more readable for someone who even knows nothing about go, whereas this is more complex (albeit, much cleaner than try/catch).

We like the pain of handling errors around here.

@thediveo
Copy link

As with might be too generic to associate it with error handling, why not go for something self-descriptive from what English has on offer?

fallible {
  fool, err := bar(42)
} fallen {
  case ...
}

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

6 participants