Description
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
andreturn
let you end iteration early.This has two problems:
- The implementation of the iterator cannot use
for
loops. As each call toNext()
happens on a different stack, and implementors must manually track and update progress. - 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, anddefer
statements to clean up as necessary. (This was proposed in the context of extending for range loops to custom types at #47707). WithreturnFrom()
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; andbreak
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. - The implementation of the iterator cannot use
-
Please describe as precisely as possible the change to the language.
A new builtin would be added
returnFrom(label, ...retArgs)
. The first argumentlabel
identifies a funtion call, that is either the call to the function namedlabel
that lexically encloses the function literal containingreturnFrom
, or a call to a function namedlabel
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 theerror
interface.When
returnFrom
is called, it creates a new instance ofruntime.EarlyReturn
that identifies which function call to return from, and with which arguments; and then callspanic
with that instance.When
panic
is called with an instance ofruntime.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 executedreturn ...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 ofruntime.EarlyReturn
is returned. If thedeferred
function calls does notpanic
then execution is resumed as it is when recovering from a panic; and thereturnFrom
behavior is abandoned. Similarly if a deferred function panics with a different value, then thereturnFrom
is abandoned and the panic happens as normal. If the instance ofruntime.EarlyReturn
is later passed topanic
, then it works as thoughreturnFrom
had been called.If
returnFrom
is called in a deferred function while the current goroutine is panicking (either with an un-recovered panic, or anotherreturnFrom
) it does not do an early return, but instead continues panicking as though you'd runpanic(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 namedlabel
, or in a function literal in the argument list of a call to a function namedlabel
.When
returnFrom(label, …)
is called, it creates an instance ofruntime.EarlyReturn
that identifies which function call to return from and with what values, and then callspanic()
with the instance ofruntime.EarlyReturn
.When
panic()
is called with an instance ofruntime.EarlyReturn
it runs all deferred functions on the current stack that were added after the function call, and then acts as thoughreturn …args
was run infn
immediately returning to the caller offn
. If any of those deferred functions callsrecover()
then the panic is aborted, and execution resumes as it normally would. IfreturnFrom
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
orreturn
to exit early. When using nested functions usereturnFrom
instead.If you are implementing a function that accepts a callback, then you must be aware that it could
panic()
orreturnFrom()
. As such, any cleanup that must happen after you run the callback should be scheduled using adefer
statement. -
Is this change backward compatible?
Yes
-
Orthogonality: how does this change interact or overlap with existing features?
This feature iteracts heavily with
panic()
andrecover()
, and introduces a new case for functions that defer a callrecover()
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 ifcollection
implements eithertype 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 withpanic()
andrecover()
.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 usereturnFrom(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 byreturnFrom
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 toreturnFrom
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)
}