[Pitch N+1] Typed Throws

I’ve been thinking about this also. If we’re going to rethink rethrows I think it should happen in the context of an effect system that allows us to abstract over effects. My hunch is that this would probably end up looking more like Doug’s reinterpretation of rethrows in terms of throws than the existing behavior of rethrows and consistent semantics across effects is probably more important than the subtle difference with current behavior.

This is obviously a separate topic, but one I would love to see receive some attention. If we had a vision of what a general effect system might look like in Swift it could provide helpful context to this decision. Perhaps it would be useful to continue this discussion in the existing effects thread?

2 Likes

Yes, it's a bug: runtime exception when called with a non-throwing function.

Let's hope we won't do that :smile:

While my last post somewhat undermines my point, what I do see here is very similar semantics with ever so slight differences. So while it's true that with determined and purposeful construction one can legally raise an error from func f<E>(_ g: () throws(E)) -> throws (E) without calling g, it's also true that one can legally transform an Error – perhaps versions down the line – to something the caller wasn't expecting when using rethrows.

They're both legal. But that doesn't mean API consumers will be expecting them.

These particular aspects of the individual methods seem less like features and more like emergent properties. If starting from scratch, I don't think there would be much argument. Take the more general approach offered by typed throws. It supports 99.9% of the use cases of rethrows – and offers many more besides.

That's what I'm trying to say when judging one against the other. Looking at them based on their individual merits and zooming out to consider how it fits in with the bigger picture.

So in my opinion, the only argument for not reducing rethrows to syntactic sugar is because there is some emergent property that's actually being relied upon and which cannot be supported by typed throws. If that's the case, fair play, it's the only reasonable course of action, but if not, it seems like an indulgence to keep it (as non syntactic sugar) that will needlessly complicate the language.

In any case, I would be reluctant to include a 'typed rethrows' – just imagine trying to explain why we need that when we have typed throws.

3 Likes

What's important to note here, though, and what differs from your sidestep of rethrows that is possible today, is that it would not be legal to throw an error when E == Never (i.e. when f is called with a non-throwing function passed for g). This is what makes your 'workaround' particularly problematic, since it means that clients can call f without any external error handling, thinking that they are entirely protected from any errors.

2 Likes

A bit lost here: do you mean in the context of what rethrows would 'desugar' to, or?

I would expect clients can call f without any external error handling if E == Never?

How do you ban @tcldr’s example without breaking the documented semantics of rethrows, which @wadetregaskis noted specifically allows rethrows functions to synthesize and throw their own errors?

1 Like

I made another example, which was actually a compiler bug rather than legal syntax. But yes, rethrows certainly has its own legal quirks besides that (in my opinion).

It might be helpful to think about how a good solution would look like to achieve the semantics of rethrows.
Let's consider the strictest version (the one @xwu talks about).

func f(g: () throws -> Void) rethrows

f throws the exactly same error what g throws. Not the other type, not the same type, but the same exact value.
It means the signature of f declares "Data Flow". It describes how values will be passed. It's much more detailed function declaration, than Swift is designed for. (IMO it's too much detailed).
How may more generalized "Data Flow" declarations look like in signatures of functions?
One bad thing about rethrows is that it doesn't explicitly specify the "source" of the error value.
Let's fix this:

func f<E: Error>(g: () throws(a: E) -> Void) throws(a)

So, a is an arbitrary name for a value of error, that will be thrown by g.
throws(a: E) means that the type of error will be E, but also defines a as a named source.
throws(a) means it will throw only errors sourced from whatever the source a.
It's a stricter declaration than throws(E).
Under such limitation the following code would be invalid:

var cache: Any?
func f<E: Error>(g: () throws(a: E) -> Void) throws(a) {
  if let e = cache as? E {
    throw e // error: operand is expected to be sourced by `a`
  }
  do {
    try g()
  } catch {
    cache = error
    throw error
  }
} 

And we can generalize this notion to return values as well.
So we explicitly specify relation between the inner function and the outer function in terms of values.

1 Like

Is it a bug, or is it a necessary consequence of relaxing the semantics of rethrows in Swift 3? That change basically requires letting rethrows functions throw at any point, including by invoking arbitrary throws functions/closures.

You could conceive of restricting that ability to catch blocks, but then you retroactively ban the implementation I linked to above.

1 Like

Thanks for the writeup. To clarify, my suggestion wasn't to eliminate rethrows or change it to this new form in Swift 6, just to not carry over rethrows into the world of typed throws. For untyped throws, it seems that replacing rethrows with a new generic parameter is not only source but ABI breaking, so it would be best to leave rethrows as-is.

5 Likes

This is absolutely right. See the past discussion here: Pitch: Fix rethrows checking and add rethrows(unsafe)

Fixing the soundness hole with rethrows Classic™ is a source break, and requires a new escape hatch mechanism to make certain idioms work:

func f(fn: () throws -> T) rethrows {
  var result: Result<T, Error>? = nil
  someNonThrowingThing {
    do {
      result = .success(try fn())  // call to conditionally-throwing closure here
    } catch {
      result = .failure(error)
  }

  try result.get() // this throws unconditionally from the type checker's POV
}

Several libraries already use the local function workaround to make the above type check, so we'd need a rethrows(unsafe) to express it, but that's gross.

With typed throws, you don't need the escape hatch at all. Parameterizing everything over E expresses this idiom safely:

func f<T, E>(fn: () throws(E) -> T) throws(E) {
  var result: Result<T, E>? = nil
  someNonThrowingThing {
    do {
      result = .success(try fn())  // call to closure that throws E
    } catch {
      result = .failure(error)
  }

  try result.get() // if this throws, it throws E, so it's fine
}

So in my opinion, we should freeze rethrows as-is, and not try to invent a rethrows(E) for typed throws.

7 Likes

I believe that thinking of rethrows as a dynamic property, where the rethrows function throws iff one of the closure arguments throws at runtime, is actually less useful. There's no way to observe the stricter dynamic behavior from within the language, nor does it break any semantic guarantees to relax it to the static rule that says the rethrows function can throw at runtime if the static type of one of its arguments was inferred as throws.

4 Likes

It's a compiler bug; it violates the documented [intended] behaviour:

In addition, the catch clause must handle only errors thrown by one of the rethrowing function’s throwing parameters. For example, the following is invalid because the catch clause would handle the error thrown by alwaysThrows().

func alwaysThrows() throws {
    throw SomeError.error
}
func someFunction(callback: () throws -> Void) rethrows {
    do {
        try callback()
        try alwaysThrows()  // Invalid, alwaysThrows() isn't a throwing parameter
    } catch {
        throw AnotherError.error
    }
}

When support was added for throwing inside a rethrows function, in Swift 3, evidently a necessary check was omitted: that the throw-containing catch clause is only catching errors thrown by the closure argument(s) or that the thrown error is itself always caught (and suppressed) before leaving the function.

There is. Your throwing closure can record whether it was actually called.

More importantly, it provides guarantees for programmers. If a rethrows function throws you know (compiler bugs notwithstanding) that it at least called a throwing closure argument. So in your catch handler (or similar) you can rely on that assumption to e.g. know that your (sole) closure argument executed at least up to its first throw point. That may mean, depending on what you wrote in that closure, that it's guaranteed that some initialisation occurred, that certain things are in certain states, etc. That may well determine what your catch clause has to do to clean up, what program state it may use to try to recover from the error, etc.

It's logically self-inconsistent, though. If the function itself can throw - without even using any of the closure arguments - then it should always be able to throw; it cannot matter whether the closure arguments are throwing or not.

It all depends on if you think of throwing as a property of values or of types. I feel that types are a better model, where you can certainly have something like this, even though it's quite silly:

protocol Initable {
  init()
}

func f<E: Initable & Error>(fn: () throws(E) -> ()) throws(E) {
  try fn() // suppose this actually never throws at runtime
  throw E()
}

And what do you think about the existing functions in stdlib? E.g. Sequence.map

I'm not sure what you're getting at with this example. It has no bearing on rethrows…?

Yes. They can be specialized much like normal generics too, if the opaque type declaration's underlying type is known to the client. For something like embedded Swift, it would still be useful to have opaque return types for the type-level abstraction they provide, even if in the implementation they are always substituted away entirely.

right, but isn’t this specialization still subject to the normal module-boundary constraints?

That documentation change tracked the following compiler behavior change, implemented by @Lily_Ballard who might be able to share more of the thoughts behind it:

EDIT: The docs describing the original limitation came from commit f8a46d52, which @Joe_Groff reviewed, and which resolved an issue pointed out via Twitter.

2 Likes

Interesting. Thanks for this. It seems the original issue on Twitter raised the inability to rethrow an error of the same type (in fact the same error) from a catch clause - I assume in order to perform some kind of unwinding

However, the scope seems to have changed at some point to support throwing any error which altered the semantics significantly.

EDIT: twitter issue does mention an arbitrary error actually although there was a question mark over it during implementation. Specifically from @jrose [Sema] Allow catch blocks to rethrow errors when safe by lilyball · Pull Request #1280 · apple/swift · GitHub And, somewhat ironically, the counter example given in the next comment as to why it should be able to throw arbitrary errors appears to actually throw an Error of the same type.

1 Like

IIRC unless you’re building with -enable-library-evolution, opaque types are erased except if the underlying type is less than public, but I could be wrong.

1 Like