Pre-pitch: Allowing break/continue in closures

Trailing closures look a lot like control statement (for/while) bodies, especially for things like SwiftUI's ForEach where the name strongly implies that it's like a for loop. The catch is that you can't use break or continue in them. So... what if you could?

The current situation creates a kind of inconsistency in the language. How often have you had to actively remind yourself that you can't break out of a ForEach or something similar?

So I'm hoping to start a discussion on what it would take to enable that (and, of course, whether we should).

One possibility: an attribute (say, @control) on the callback parameter. It transforms the closure signature in a way that is transparent to the caller providing the closure, but inside the for-like function there is either an additional inout parameter to indicate how the closure exited (return/break/continue), or the return type is converted to a tuple of the original return type and the exit type (example below).

We would also want return to function as in a normal loop: not only exit the loop, but return from the calling function as well. This would require the loop function to be generic over the return type of the caller.

A minimal example, using the tuple return type approach:

// Transparently added to the return type of the callback block
// T is the return type of the calling function
enum ControlBlockReturnType<T> {
  case return(T), continue, break
}

// Return type of a function that has a @control parameter
// T is the return type of the calling function
// U is the return type of the control (loop) function
enum ControlFunctionReturnType<T, U> {
  case blockReturned(T), value(U)
}

// block actually returns (Void, ControlBlockReturnType<T>)
// myForLoop actually returns ControlFunctionReturnType<T, Void>
func myForLoop<T>(@control _ block: () -> Void) {
  for _ in someRange {
    switch block().1 {
      case .break: break
      case .continue: continue
      case .return(let value): return .blockReturned(value)
    }
  }
  return .value(()) // maybe this can be implicit
}

myForLoop {
  // return, break, and continue can all be used in here
}

// the above call is actually transformed by the compiler into:
if case let .blockReturned(result) = myForLoop(···) {
  return result
}
// ..otherwise the .value result is used as the effective result of myForLoop()

As I wrote this, I realized making return work is the trickiest part because it requires all that behind-the-scenes stuff to make it work nicely at the call site.

I've tried to think of other viable approaches, but couldn't think of anything that seemed better.

Thoughts?

1 Like

Well, the “simple” way to make break work, would be to have the language treat “break at the top-level of a void-returning function” as a synonym for return.

Ruby is a model to look at here: it draws a distinction between “blocks” and “lambdas,” with the former maintaining its connection to the local control flow of its containing function, and binding break, next (Ruby’s continue), and return all to the nearest enclosing loop/function. This lets Ruby use methods as its primary looping mechanism ([1, 2, 3].each { … }, 10.upto(20) { … }, etc.). It’s the grandkid of Smalltalk’s non-local returns.

Ruby’s blocks have a weird, special, last-param-only syntax that’s quite different from Swift, but the effect of that is to create restrictions on client that quite closely resembles Swift’s non-escaping closures. There might be something to build on there.

(Ruby also has “procs,” which are essentially non-escaping closures whose non-escape is only enforced at runtime. Those don’t seem like such a good fit for Swift.)

4 Likes

Refining the details from my example above:

  • @control can be applied to a closure parameter. Probably only one should be allowed per function. I'm tempted to say it must be the last parameter so it can be a trailing closure, but this might not be strictly necessary.
  • The declared return type of the @control parameter must be Void. I wrote my example assuming it could be something else, but since we want to facilitate return to exit the calling function as well, I don't think it makes sense for the block to also return a value unless we come up with a good way to differentiate between the two ways to "return".
  • A function that has a @control parameter (a "control function") must be generic, with the first type parameter being the return type of the calling function. This allows it to handle when the block uses return.
  • Inside a control function, the block return type is transformed to ControlBlockReturnType<T> where T is the return type of the calling function. From the outside, the block return type is represented as Void for the sake of making the API look clean. (I'm dumping the tuple return type from my example because of that issue with the block having its own return type)
  • A control function's return type is transformed to ControlFunctionReturnType<T, U> where T is the calling function's return type, and U is the control function's declared return type.
    • The calling function is written using the control function's declared return type, and the compiler transforms a call to a control function so that if it returns .blockReturned, the calling function returns the associated value. If it returns .value, then the associated value is supplied to the calling code as the return value.
    • Inside the control function, it can return values of its declared return type or use ControlFunctionReturnType values explicitly, similar to how optional return types can be given as the wrapped type or as explicit Optional values. A lack of return statement implicitly returns .value(()) (if the declared return type is Void, of course).

Some things I'm not sure about yet:

  • Should storing a @control block in a variable be allowed? Are there complications that might make this undesirable?
  • As noted above, is there a good way to distinguish between the @control block returning its own value, such as for reduce(), and wanting to return out of the calling function?
  • What about breaking to a label? This is useful for breaking out of a switch and an enclosing loop all at once. The language grammar currently only allows statement labels before actual control statements, not function calls. And where would the label go if the control function has a return value? I think that gets too complicated, so the best answer is probably to not allow labels for control functions, or only for Void-returning (or maybe event @discardableResult) ones.
1 Like

Honestly never. We can filter, map and format the data before using in View's body. It is interesting to see cases when it is not possible and the only solution is to add break / continue to ForEach.

1 Like

Just imagine that after each call of the closure passed you should consider the possibility that your flow will be interrupted.
For escaping closures it does not make sense at all, because all of the context that was not captured is gone at the moment the closure can be called.

Yes, @control closures would definitely be non-escaping.

If so, in SwiftUI it will not be applicable, because pretty all closures are escaping, including ForEach closure.
Thus, the only real place where it could be used is higher-order functions, contradicting the whole concept of that functions.

Ah, good point. There is a valid case, such as in SwiftUI, for saving the control block and executing it later. In that case then, you of course can't use return in the block to exit the calling function.

So, in the form of my examples above, if a @control parameter is declared as @escaping:

  • The function is not generic over the caller's return type.
  • T is Void (or maybe even Never) for ControlBlockReturnType<T> and ControlFunctionReturnType<T>, and the .return and .blockReturned cases are not used.
  • Unless we come up with a good way for @control blocks to have their own return type, return statements are disallowed. I believe break and continue would always be viable alternatives in this context.