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: spec: error handling via iterator-inspired handler functions #69734

Open
1 of 4 tasks
DeedleFake opened this issue Oct 1, 2024 · 8 comments
Open
1 of 4 tasks
Labels
error-handling Language & library change proposals that are about error handling. LanguageChange Suggested changes to the Go language LanguageChangeReview Discussed by language change review committee Proposal
Milestone

Comments

@DeedleFake
Copy link

DeedleFake commented Oct 1, 2024

Go Programming Experience

Experienced

Other Languages Experience

Elixir, JavaScript, Ruby, Kotlin, Dart, Python, C

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?

This idea involves several ideas, including handler functions and guard clauses, but it goes about it quite differently.

Does this affect error handling?

Many previous error handling proposals had issues with return values other than errors and the semantics around how handler functions were called and scoped. This attempts to work around those problems by instead using a similar higher-order function approach to what range-over-func uses.

Is this about generics?

No.

Proposal

With the range-over-func, Go, for the first time, has functionality in which calling a function can directly cause another function to return. The compiler generates a function from the body of the loop, but that function has special semantics around returns which will cause the function containing the loop to return when a return is executed inside of the loop body.

Despite this, the functionality is not particularly useful for error handling, as it is not only unweildy, but it actually often pollutes the code more than the standard if err != nil does:

func handle(err error) iter.Seq[error] {
  return func(yield func(err) bool) {
    if err != nil {
      yield(fmt.Errorf("parse int: %w", err)
    }
  }
}

func Example() (int, error) {
  v1, err := strconv.ParseInt(str1, 10, 0)
  for err := range handle(err) {
    return 0, err
  }

  v2, err := strconv.ParseInt(str2, 10, 0)
  for err := range handle(err) {
    return 0, err
  }

  return int(v1 + v2), nil
}

Proposal

I propose adding a concept of guard functions which are invoked via an operator (?, perhaps, but I'm not so stuck on this. I also thought of ||.) after a function call. These functions would take as arguments the return types of the function as well as a special function created automatically by the compiler. This extra function argument would take as arguments the returns types of the function in which the guard clause was used and, when called, would cause the function to return with those arguments. The guard function would return the same types as those it was called with, minus the extra function.

That explanation is a bit obtuse, so here's an example:

func handle[R, V any](ret func(R, error), v V, err error) V {
  if err != nil {
    var r R
    ret(r, err)
  }

  return v
}


func Example() (int, error) {
  v1 := strconv.ParseInt(str1, 10, 0) ? handle
  v2 := strconv.ParseInt(str2, 10, 0) ? handle
  return int(v1 + v2), nil
}

In the example, the ret argument to handle() is a special function created by the compiler. When handle() calls it, it causes Example() to return with the values that it was given. The arguments after the first to handle() are the return values of strconv.ParseInt(). The entire f() ? handle expression returns the return value of handle() as long as ret is never called.

I believe that this approach solves a number of problems with previous proposals. For one thing, it fits cleanly with generics to allow for crafting custom handler functions that work across a range of types. It also isn't error-specific: Handler functions could be written with any custom conditions on calling ret that they want to.

The syntax is not something that I'm stuck on, as mentioned previously. I think it should be operator-based just to avoid potential problems with new keywords, but the primary idea here is the special ret function, not the syntax. An alternative syntax could even be to put the guard function before the function being called, i.e. v := handle?strconv.ParseInt(str, 10, 0) or something.

Language Spec Changes

The section on function calls would be changed to mention guard clauses and guard functions, and these would then also be given their own section explaining their semantics.

Informal Change

Guard functions are regular functions designed to be called via a guard clause which are passed, along with other things, a special function that can be used to cause the calling function to return.

Is this change backward compatible?

Yes.

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

I think it fits well with generics and the existing error handling mechanisms.

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

Harder. People got surprisingly confused over how the new iterator functions work, although they seem to have gotten used to them pretty quickly, but I expect that the reaction to this would be pretty similar due simply to its higher-order function based design.

Cost Description

Slightly more complexity in the language's syntax.

Changes to Go ToolChain

Anything that parses Go code would be affected.

Performance Costs

Likely a very small compile-time penalty, but probably essentially no run-time penalty with proper optimization.

Prototype

No response

@DeedleFake DeedleFake added LanguageChange Suggested changes to the Go language LanguageChangeReview Discussed by language change review committee Proposal labels Oct 1, 2024
@gopherbot gopherbot added this to the Proposal milestone Oct 1, 2024
@ianlancetaylor ianlancetaylor added the error-handling Language & library change proposals that are about error handling. label Oct 1, 2024
@ianlancetaylor
Copy link
Contributor

The "ret" argument to the handle function is potentially quite powerful. What happens if the handle function passes "ret" to some other function, and that function calls "ret"? What happens if you send "ret" over a channel to some other goroutine, and that goroutine calls it? What happens if you store "ret" in a global variable, and then something else calls it later on? In general we need pin down rules for how "ret" can be used, while keeping in mind that Go is meant to be an orthogonal language.

Also, what happens for something like a top-level var? What is the "ret" argument passed down?

var Global = os.Open("file") ? handle

If we aren't able to inline the handle function, then "ret" has to be implemented as some sort of panic. We have to recover at the point of the ?, and look for a special type holding the return values. If we see that type, we stop the panic and return. If we don't see that type, we have to silently continue the panic, since we don't want to disturb the stack trace.

@DeedleFake
Copy link
Author

The "ret" argument to the handle function is potentially quite powerful. What happens if the handle function passes "ret" to some other function, and that function calls "ret"? What happens if you send "ret" over a channel to some other goroutine, and that goroutine calls it? What happens if you store "ret" in a global variable, and then something else calls it later on? In general we need pin down rules for how "ret" can be used, while keeping in mind that Go is meant to be an orthogonal language.

I think similar rules to yield make sense. Passing it to other functions is fine, but calling it after the guard function returns causes a panic, as does calling it concurrently.

Also, what happens for something like a top-level var? What is the "ret" argument passed down?

var Global = os.Open("file") ? handle

I'm not quite sure, but my initial thought is to just not allow it outside of contexts where return is allowed.

@seankhliao
Copy link
Member

Does this require a different handler function per combination of function / enclosing function return args?

I don't see a clear way to annotate errors with this, there doesn't look like a way to pass extra args to the handler.

I feel like it's only really suited to replacing if err != nil { return err }, anything more complex cannot be reused / would spread related logic out over a parent + handler functions specific to the parent.

@DeedleFake
Copy link
Author

Does this require a different handler function per combination of function / enclosing function return args?

Unfortunately, quite likely it would for a lot of cases, although some could be covered by a generic function as shown in the example.

I don't see a clear way to annotate errors with this, there doesn't look like a way to pass extra args to the handler.

No, this is quite straightforward, actually, and again works the same way that iterators do:

func handle(annotation string) func(ret func(error), v int, err error) int {
  return func(ret func(error), v int, err error) int {
    if err != nil {
      ret(fmt.Errorf("%v: %w", annotation, err))
    }
    return v
  }
}

func Example() error {
  v := doSomething() ? handle("something failed")
  // ...
}

The problem with this, though, is that if you try to make it generic than the inference will fail as reverse inference from the usage doesn't work in Go.

I feel like it's only really suited to replacing if err != nil { return err }, anything more complex cannot be reused / would spread related logic out over a parent + handler functions specific to the parent.

Perhaps, although the existing error handling would very much still work just fine, so anything complicated where this didn't work could just not be done that way. Alternatively, the handler functions can be anonymous, so, to take the example from Errors are Values:

var err error
write := func(buf []byte) {
    if err != nil {
        return
    }
    _, err = w.Write(buf)
}
write(p0[a:b])
write(p1[c:d])
write(p2[e:f])
// and so on
if err != nil {
    return err
}

could become this instead, which would allow for an early return:

handle := func(ret func(error), n int, err error) {
  if err != nil {
    ret(err)
  }
}
w.Write(p0[a:b]) ? handle
w.Write(p1[c:d]) ? handle
w.Write(p2[e:f]) ? handle

That also removes the need for a top-level type with a method and all the other complications that go along with that as suggested next in the blog post.

@antong
Copy link
Contributor

antong commented Oct 10, 2024

In the main example in the description, should the handle function return return v? Now it does return v, err.

@DeedleFake
Copy link
Author

In the main example in the description, should the handle function return return v? Now it does return v, err.

Yes, yes it should. I made a few tweaks as I was writing it and seem to have missed one. Thanks.

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 Suggested changes to the Go language LanguageChangeReview Discussed by language change review committee Proposal
Projects
None yet
Development

No branches or pull requests

7 participants
@DeedleFake @antong @ianlancetaylor @gopherbot @seankhliao @gabyhelp and others