Skip to content

proposal: Go 2: returnFrom() #54361

Closed as not planned
Closed as not planned
@ConradIrwin

Description

@ConradIrwin

Author background

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

    Experienced

  • What other languages do you have experience with?

    Significant amounts of Ruby, Javascript/Typescript and Python. Small amounts of many others.

Related proposals

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

    Yes, #35093 is the clearest other version of this (and a few other linked issues are also similar)

    • If so, how does this proposal differ?

      It limits the change to lexically visible places, and shows how this might interact with other Go features in more detail.

  • Does this affect error handling?

    Somewhat

    • If so, how does this differ from previous error handling proposals?

      It is similar to #35093. The primary focus is a new control flow mechanism. While it is not an error-handling proposal per-se, it would help with error handling in currently hard-to-support contexts.

  • Is this about generics?

    Not as such, but generic code would benefit from this change particularly.

    • If so, how does this relate to the accepted design and other generics proposals?

Proposal

  • What is the proposed change?

    Go should add a new keyword/builtin returnFrom(fn, ...retArgs) that lets you return early from a surrounding function or function call.

  • Who does this proposal help, and why?

    The primary case it would be helpful is for iterating over custom collections.

    Iteration in go is today handled using a for loop that looks something like this:

    i := collection.Iter()
    defer i.Close()
    for i.Next() {
      if doSomething(i.Current()) { break }
    }

    It is currently necessary in go to use a for loop to iterate if you know in advance that you may not want to iterate over the entire collection; because break and return let you end iteration early.

    This has two problems:

    1. The implementation of the iterator cannot use for loops. As each call to Next() happens on a different stack, and implementors must manually track and update progress.
    2. The caller has to do any necessary cleanup (in the example here, calling i.Close()).

    Both of these problems could be solved by allowing the implementor of the iteration to own the control flow. That would mean that the lifetime of the iterator is tied to a function, and so implementors could use for loops, and defer statements to clean up as necessary. (This was proposed in the context of extending for range loops to custom types at #47707). With returnFrom() this proposal is easier to implement, and doesn't suffer the "forget to check the return value" problem.

    Two examples of this API:

      func example() error {
          // used as an equivalent to `break` to move function control out of the loop
          collection.Range(func(u *Unicorn) {
              if doSomething(u) {
                  returnFrom(collection.Range) // control flow jumps to fmt.Print call
              }
          })
          fmt.Print("done")
          // used as an equivalent to `return` to end the enclosing function early
          collection.Range(func(u *Unicorn) {
              if err := doSomethingElse(u); err != nil {
                  returnFrom(example, err) // example() returns the value err
              }
          })
          return nil
      }

    With this support in the language, the implementor of collection.Range() can defer the cleanup
    required itself; and if the collection used something like a slice under the hood, the implementation of the iterator would be very simple (something like this):

      func (c *Collection) Range(fn func(u *Unicorn)) {
          s := c.getResults()
          defer s.Close()
          for _, u := range s.slice {
              fn(u)
          }
      }

    This would also pave the way for generic helper functions like slices.Map that are common in other
    languages (particularly those that use exceptions for error related control flow), but which are hard to implement in go because there is currently no idiomatic way to end iteration early if something goes wrong.

      func lookupHosts(hosts []string) ([]string, error) {
          return slices.Map(hosts, func(s string) string {
              ret, err := lookupHost(s)
              if err != nil {
                  returnFrom(lookupHosts, nil, err)
              }
              return ret
          }), nil
      }
    
      // package slices
      func Map[T, U any](s []T, fn func(T) U) []U{
        r := make([]U, len(s))
        for i := range s {
          r[i] = fn(s[i])
        }
        return r
      }

    That said, there are a few other cases it would also help with. You could use it to clarify returning from the parent function in a deferred function.

      func before() (i int) {
          defer func() { i = 10 }()
          return 5
      }
    
      func after() int {
          defer func() { returnFrom(after, 10) }()
          return 5
      }

    There are also a few cases where a nested function is used outside of the context of iteration. One example is errgroup (though until #53757 is fixed, this will not work).

    // returns a match, or if no matches, the first error encountered
    func find(needle string) (int, error) {
      g := errgroup.Group{}
    
      for _, c := range collections {
          bound := c
          g.Go(func () error {
              result, err := bound.Seek(needle)
              if err != nil {
                  return err
              }
              returnFrom(example, result, nil)
          })
      }
    
      return 0, g.Wait()
    }

    In terms of prior art, a similar keyword return-from exists in lisp. The problem is solved in Ruby by differentiating between methods and blocks (return returns from the enclosing method even when called in a block; and break returns control to the next statement in the enclosing method when called in a block). The distinction between methods and blocks seems unnecessarily subtle for a language like go. In Python this problem was solved by adding generators to the language, but that seems very heavy-handed for a language like go.

  • Please describe as precisely as possible the change to the language.

    A new builtin would be added returnFrom(label, ...retArgs). The first argument label
    identifies a funtion call, that is either the call to the function named label that lexically encloses the function literal containing returnFrom, or a call to a function named label that contains the function literal in its argument list.

    A new type would be added to the runtime package, type EarlyReturn { } which contains unexported fields; and which implements the error interface.

    When returnFrom is called, it creates a new instance of runtime.EarlyReturn that identifies which function call to return from, and with which arguments; and then calls panic with that instance.

    When panic is called with an instance of runtime.EarlyReturn it unwinds the stack, calling deferred functions as normal until it reaches the identified function call. At that point, stack unwinding is aborted and execution is resumed as if the called function had executed return ...retArgs.

    If the identified function call is not found on the stack, then the stack is unwound and the program terminates as it would with an unhandled panic. This would happen if the closure in which returnFrom is called was returned from the function it was defined in, or run on a different goroutine. The error message would be: panic: call to returnFrom(<label>) outside of <label>.

    If recover is called while unwinding in this way, then the instance of runtime.EarlyReturn is returned. If the deferred function calls does not panic then execution is resumed as it is when recovering from a panic; and the returnFrom behavior is abandoned. Similarly if a deferred function panics with a different value, then the returnFrom is abandoned and the panic happens as normal. If the instance of runtime.EarlyReturn is later passed to panic, then it works as though returnFrom had been called.

    If returnFrom is called in a deferred function while the current goroutine is panicking (either with an un-recovered panic, or another returnFrom) it does not do an early return, but instead continues panicking as though you'd run panic(recover()).

  • What would change in the language spec?

    A new section under Builtins

    Return from

    The built in function returnFrom(label, ...args) takes the name of a function as the first argument and the remaining arguments correspond to the return values of that function. returnFrom can be called in two places: within a nested function literal defined inside a function named label, or in a function literal in the argument list of a call to a function named label.

    When returnFrom(label, …) is called, it creates an instance of runtime.EarlyReturn that identifies which function call to return from and with what values, and then calls panic() with the instance of runtime.EarlyReturn.

    When panic() is called with an instance of runtime.EarlyReturn it runs all deferred functions on the current stack that were added after the function call, and then acts as though return …args was run in fn immediately returning to the caller of fn. If any of those deferred functions calls recover() then the panic is aborted, and execution resumes as it normally would. If returnFrom is called in a deferred function while the current goroutine is panicking, it instead resumes stack unwinding with the current panic.

  • Please also describe the change informally, as in a class teaching Go.

    When using loops you can use break or return to exit early. When using nested functions use returnFrom instead.

    If you are implementing a function that accepts a callback, then you must be aware that it could panic() or returnFrom(). As such, any cleanup that must happen after you run the callback should be scheduled using a defer statement.

  • Is this change backward compatible?

    Yes

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

    This feature iteracts heavily with panic() and recover(), and introduces a new case for functions that defer a call recover() across a user-provided callback to consider. In most cases this will not be an issue as code should not swallow unexpected panics; though it could exacerbate a problem if this is not the case (e.g. encoding/json before 2017).

    This feature also interacts heavily with iteration. Although not in scope for this proposal, an obvious extension would be to allow for x := range collection loops if collection implements either

     type Ranger[T any] interface { Range(f func(T)) }
     type Ranger2[T, U any] interface { Range(f func(T, U) }
    

    The compiler would convert break/return statements within the loop to calls to returnFrom() as appropriate.

    Given that, I considered proposing that the syntax was break fn, ...retArgs; but I think that is more confusing in the case you're trying to return from the current function, and it is less obvious that this feature interacts with panic() and recover().

    This feature also interacts with defer as it introduces a new way for deferred functions to set the return arguments of the enclosing function. Instead of using named arguments, code can now just use returnFrom(enclosingFn, ...retArgs).

    This feature also interacts with function literals. Currently in go it is not possible to name function literals; and this proposal does not suggest changing that. This means that returnFrom will not be able to return from an enclosing function literal directly. I don't think this is likely to be a problem in practice, as if you want to name a function you can move it's definition to the top level.

    func badExample() {
      func notSupported() { ... }
    }
  • Is the goal of this change a performance improvement?

    No.

    • If so, what quantifiable improvement should we expect?
    • How would we measure it?

Costs

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

    Harder, it adds surface area to the language. Although the behaviour is easy to describe in the common case, it is still more than not having it.

  • What is the cost of this proposal? (Every language change has a cost).

    The primary cost is an additional powerful keyword that programmers are unlikely to have encountered before. That said, it worked for go statements :).

  • How many tools (such as vet, gopls, gofmt, goimports, etc.) would be affected?

    All tools would need to learn about the new builtin. As the syntax resembles a function call (albeit one that handles the first argument specially), and the semantics resemble a panic() (control flow is aborted) I think the changes would be minor. Generics may complicate things, as this would be a place you're allowed to "pass" a generic function with no type parameter.

    Go vet could be updated to add handling for obviously broken calls to returnFrom as discussed below.

  • What is the compile time cost?

    The compiler must identify which calls can be returned from early, and ensure that there's a mechanism to do that. This would need two parts: one to identify which stack frame to unwind to, likely by inserting a unique value onto the stack in a known place in the frame; and secondly a mechanism to resume execution at that point; possibly with a code trampoline.

    As any modifications are scoped within a single function, this change would not slow down linking, or require changes to functions that do not lexically enclose returnFrom() statements.

  • What is the run time cost?

    I would imagine that it has similar performance to a panic/defer, as the stack must be unwound explicitly instead of using normal returns.

  • Can you describe a possible implementation?

    To identify which call returnFrom targets, stack-frames that may be targetted by returnFrom will need a unique identifier. I had initially hoped to reuse the frame pointer, but that may not be sufficient as a given frame pointer may be re-used for later calls after the current call has returned. The combination of a frame pointer and a per-goroutine counter value stored at a known offset in the frame should be sufficient.

    To actually implement returnFrom it may be necessary to add a small trampoline, either at the end of the function if the enclosed function is identified; or after the function call if a function call is identified, that copies the values passed to returnFrom into the right place. That said, if go's scheduler is able to resume a goroutine at any point, it may be possible to set up the state of the stack and registers and then just jump back into the existing code.

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

Yes, I have implemented a version of this by syntax rewriting at https://github.com/ConradIrwin/return-from

Examples

To help understand how this could work:

// OK, returning from named enclosing function
// f() == 10
func f() int {
  func () { returnFrom(f, 10) }()
  return 5
}

// OK, returning from enclosing function call
func f(s []int) {
  slices.Range(s, func (i int) { returnFrom(slices.Range) })
}

// OK, returning from (doubly) nested enclosing function call
func f(s []int) {
  slices.Range(s, func (i int) {
    inTransaction(func () { returnFrom(slices.Range) })
  })
}

// OK, returning from a enclosing instance method
func (s *T) f () {
    func () { returnFrom(s.f) }()
}

// OK, showing why panic/recover can be helpful
// f() == 10
func f() int {
  c := make(chan interface{})
  go func () {
    defer func () { c <- recover() }()
    returnFrom(f, 10)
  }()
  panic(<-c)
}

// OK, showing how it can be used with defer
// f() == 10
func f() int {
  defer func () { returnFrom(f, 10) }()
  return 5
}

// OK, showing how it could be used with function pointers
// f() == 10
func f() int {
    g := func() { returnFrom(f, 10) }
    g()
    return 5
}

// compile time error, f does not enclose returnFrom(f)
func f() { }
func g() { returnFrom(f) }

// compile time error, nested is a function pointer (not a named function)
func f() {
  nested := func () { func () { returnFrom(nested) }() }
}

// unhandled panic, could be added to go vet
func f() func() {
  return func() { returnFrom(f) }
}
f()()

// unhandled panic, could be added to go vet
func f() {
  go func () { returnFrom(f) }()
}
f()

// unhandled panic, the call to f(true) is over before f(false) calls the callback
func f(a bool) func() {
  if a {
    return func() { returnFrom(f, func() { }) }
  }
  f(true)()
  return func() { }
}
f(false)

// OK, but may be confusing, the first argument to returnFrom
// identifies the call to the function nested; not the function
// pointed to by the variable called nested.
func f() {
   nested := func(f func()) { f() }
   nested2 := func (f func()) { }
   nested(func () { nested = nested2; returnFrom(nested) })
}
f()

// OK, example of showing why returnFrom() ignores subsequent different calls.
func wrapper(fn func()) int {
    defer func() { returnFrom(wrapper, 5) }()
    fn()
}
func f() {
  x := wrapper(func () {
      returnFrom(wrapper, 10)
  })
  fmt.Println(x == 10)
}

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions