Annotations for control flow

Note: this is motivated by a conversation in the "deprecating force unwrap" thread where I went a little bit off-topic: Moving toward deprecating force unwrap from Swift?, but I've been thinking about this for a while now.

If you've used Swift for a while, you will almost inevitably fall in love with guard statements. They are a truly great feature of the language and many people learn to prefer them over if statements. But why is that?

The cool thing about guard statements is not so much their inverted semantics and optional binding rules compared to if statements. Their appeal really comes from the fact that they force you to leave the scope somehow. A reader of your code does not need to look into the else branch to know: "if this condition fails, I'll leave the current scope in some way". This is a great way to communicate intent more globally.

Here's an edge case that gives people headaches:

guard error == nil else {
throw error! //force unwrap necessary :( 
}

If you want to avoid the force unwrap, you need to write:

if let error = error {
throw error 
}

But then, you lose this nice added benefit of the guard statement! Now, there's an obvious "solution" for that so you can still communicate scope-leaving-behaviour more globally: just wrap the rest of your function into an else-block. However, if you have a really unpleasant case and you need many variables to be nil and you need to execute some code in between so that covering them with else if let is not an option, you'll quickly end up with a pyramid of elseclauses.

Granted, such scenarios probably mean that you have deeper problems with your code, but the mere possibility of having to write deeply nested elses is troubling and I tend to feel awkward with even one if let ... else.

If you think about the problem further, you notice: this is actually not just a problem of ifs or if lets, but of virtually all control flow statements (except guard): if you read any of for in, switch, while or while let, you actually need to look into the bodies of those blocks to figure out if some, all or none of their branches leave the current scope.

A general solution is therefore desirable.

What I thought of is a set of annotations: one indicating "this control flow statement never leaves the current scope" (likely used in imperative code with while and for in), one indicating "this control flow statement sometimes leaves the current scope" (in mixed situations) and one indicating "this control flow statement always leaves the current scope" (likely used in switch statements or the above if let). Applying an incorrect annotation (except for the "sometimes" annotation) would be a compile-time error.

The question, however, is: what about backward compatibility? Either these annotations are opt-in, in which case you'd get a lot of compiler warnings that get very annoying quickly and you don't see the real problems anymore or there would be no enforcement at all and it would be just a nice gimmick for people who love structured programming. A third solution could be that the annotations are generated fully automatically and shown to the user without being part of the actual source-code, but that would be a bit of a paradigm shift and it would move this an IDE feature rather than a compiler feature.

Probably, the no-enforcement policy would be best as long as you can opt-in any enforcement rules using a linter.

Thoughts? Ideas? Am I missing something?

1 Like

I'm a bit lost about this part. You can use guard let:

guard let error = error else { ... }

Apart from that, the proposed example can be reduced to a simple try statement with the function type signature being annotated with either throws or rethrows. Suppose you have a function bar throwing that error and a function foo in which you're calling bar() and using do...catch to fetch the error. If your goal is to throw bar's error you can simply use try bar():

struct MyError: Error {}
func bar() throws { throw MyError() }

func foo() throws {
  try bar()
}

I suppose their original appeal was the ability to avoid nested scopes. That's why we have switch instead of nested if...elses, or for...in...where instead of a nested if inside a for...in, and now await instead of nested callback based asynchronous functions :slight_smile:

1 Like

The if let error = error tends to come up in older asynchronous APIs that predate Result where you get an optional object of interest and an optional error via a callback, so you can't just throw it. (I guess that APIs like these are responsible for "An unknown error occured" messages to the user where the caller of the continuation forgot to provide either an object of interest or an error). You definitely don't want to do guard let error = error because then, you'd have to put the (usually longer) "good branch" into the else part of the guard.

I am sorry, I misunderstood both your intentions and your example.

I see, I worked with those callback-based signatures frequently in JavaScript prior the introduction of async functions and Promises. However I think that the "correct" approach here would be to update them to the new conventions.
In the Structured Concurrency proposal there's a section about converting callback APIs into async functions using withUnsafeThrowingContinuation.

func oldAsyncFunction<ResultType, ErrorType: Error>(
  _ callback: (ResultType?, ErrorType?) -> Void
) { ... }

func newAsyncFunction<ResultType>() async throws -> ResultType {
  try await withUnsafeThrowingContinuation { continuation in
    oldAsyncFunction { result, error in
      switch (result, error) {
        case let (result?, nil): continuation.resume(returning: result)
        case let (nil, error?): continuation.resume(throwing: error)
        default: fatalError("What?!")
      }
    }
  }
}

This should be applied as a general rule: if you're working with an old-styled function foo that for example provides optional errors instead of throwing or returning a Result, you shouldn't "fix" foo every time you use it at the call site. You should instead define a "correct" throwing version of foo once (using if let error = error { throw error }) and use that all the times.

Regarding your proposed opt-in annotations, how would they apply to the code sample written above? newAsyncFunction has only one return statement, withUnsafeThrowingContinuation has only one statement (thus it exits its scope after running that single statement), the oldAsyncFunction callback has only one statement and every switch case has only one statement. Would the annotation be applied everywhere?

I actually tend to do the following in current Swift:

guard let data = data else {
   onFailure(error ?? UnknownError())
}

Not sure about your snippet, because when you're generic over the result, you actually leave the decision on the type to the caller which is probably not intended here. However, the switch statement would definitely fall into the category of "always leaving scope", as in each case, the executable statement is the last statement that is evaluated in this function.

Probably the optional error example is not ideal because it's relatively easy to fix. Maybe it gets clearer with more examples.

Say, you have code like this:

func doSomething(arg: String?) {
   
   var result = "" 

   switch arg {
      case .none:
         return
     case .some(let str):
         result = "hello, " + str
   }
print(result)
}

Can you infer - without looking at the individual cases - if the switch statement might leave the scope? The print call and the var result tell you that you use switch at least on one branch in order to initialize the result, so you may be tempted to think that this is the case for all branches. If the switch statement becomes sufficiently long, you may be surprised and even annoyed to learn that there are some branches that just return and don't eventually go to the print statement.

Or look at this:

func foo(arg: Int) -> Int {

   var result = arg

   while true {
       if condition1(result) {
         return 42
       }
      if condition2(result) {
         break
      }
      iterate(&arg)
   }

   return result == 42 ? 1337 : result

}

Here, too, you have to actually look inside the while statement to get a global understanding of control flow. Worse: if you guessed that the control flow is "do something to result until some condition is met, then go postprocess the result", you'd think "this function can never return 42". But you'd be wrong!

I'd just think, it would make sense to have an attribute - checked by the compiler - that tells you about the global control flow. Like:

@alwaysLeave switch foo {
//each case must return, throw or trap
}

@neverLeave while let next = stack.pop() {
//return and throw are forbidden here
}

@neverLeaveNeverBreak while true {
//return, throw and break are forbidden
//in this case, the compiler can infer and warn of an infinite loop
//as the condition always evaluates to true and you can't break
}

@sometimesLeave for idx in range {
//default attribute
//everything can happen here, you need to read the details
//to understand the control flow
}

Without thinking too deeply about this topic, my impression is that what you really want are control flow expressions.

e.g.

return switch foo {
  case .a: 1
  case .b: 2
}

Or at least it should cover the @alwaysLeave case.

1 Like

Been frustrated by this for a long time and your post sparked a syntax idea I don't think I've seen...(correct me if I'm wrong)

What about guard let error != error ?

func doAThing(possibleError: Error?) throws {

    guard let error != possibleError else {
        throw error // error is not nil here
    }
    // error is nil here
    // do the thing
}

I'm not sure it's a good idea to re-use the inequality operator, but aside from that it seems viable after 5 minutes of thinking about :)

1 Like

Why the :(? This is the correct and intended usage of the ! operator: there is no need to invent anything to avoid it.

4 Likes

I thought in this direction as well, but that may mislead in the sense that there are no throws or traps in there. In imperative scenarios, the @neverLeave may also be a gentle hint, and that doesn't map to any keyword we have. That's why I think annotations may be a better solution.

Force unwrap is something I'm always suspicious of in production code and makes me look at it more closely. Whether or not that's the intention I don't know, but that's my experience in using the language. So I'm in favor of making it unnecessary as much as possible :slight_smile:

2 Likes

I think the "intended" usage is in situations with very custom code paths where it is really tricky to prove to the compiler that the value isn't nil (while relatively simple to prove to humans), not more or less frequent situations which can easily be expressed without a force-unwrap.

This is also one of the scenario where the compiler can't proof that the value is not nil as it's not looking around, but can be easily proven by human.

1 Like

I haven't got a general solution, but in this specific case, a "side effect" of map can be used:

try error.map { throw $0 }

Or a throw() method can be added:

extension Optional where Wrapped: Error {

  /// Unwraps and throws the wrapped error, if the instance isn't `nil`.
  ///
  ///     let error: Error?
  ///     /* ... */
  ///     try error.throw()
  ///
  /// - Throws: The wrapped error.
  public func `throw`() throws {
    try self.map { throw $0 }
  }
}

Or instead of using map, a forValue or ifPresent API can be added:

2 Likes
Terms of Service

Privacy Policy

Cookie Policy