[Pitch N+1] Typed Throws

It could be partially supported under specific conditions. If we take this idea and combine it with @Joe_Groff 's suggestion about Uninhabited protocol, we could express it like:

struct TranslationError<T: Error>: Error { ... }
extension TranslationError: Uninhabited where T: Uninhabited {}

func translateRethrown<E>(f: () throws(E) -> Void) throws(TranslationError<E>) {

The issue here is that when TranslationError doesn't depend on E, like in your example, we would have to make it depend. That's not ideal, but in many cases TranslationError carries the underlying error anyway.
But if one really wants to have TranslationError independent of E, it should be possible to provide an overload which accepts non-throwing f.

It would be interesting to explore allowing conditional expression in type expressions:

typealias TranslateRethrownError<E> = if E == Never then Never else TranslationError
func translateRethrown<E>(f: () throws(E) -> Void) throws(TranslateRethrownError<E>) {

Why would that break? f throws any Error so this function can 'rethrow' any Error, and GenericError is a type of Error, so…?

But in any case, I'm not sure about the premise to this, that rethrowing functions basically only pass through the errors of their closure parameters. Especially in async code where you have to accomodate CancellationError, wrapping the closure(s)'s error into a superset type is sometimes necessary.

It might be fine to by default type the parent function as throwing the exact same type as the lowest common denominator of its closure parameter(s) - I do like that the precise type carries through rather than always devolving to any Error - but it might be wise to have any easy way to override that, as @michelf suggests.

Under the proposed solution(if I got it right) it wouldn't be any Error. Firstly rethrows will be substituted with throws(E) like:

func translateRethrown(f: () throws -> Void) rethrows
turns into
func translateRethrown<E: Error>(f: () throws(E) -> Void) throws(E)

And then the body is type checked, and it turns out GenericError isn't E.

So throws in the closure type declaration is not equivalent to throws(any Error)? That is unintuitive and surprising.

I get that we want to be able to pass a closure argument which throws something more specific than just any Error, but that should work fine because throws(SomeSpecificError) is a compatible subset of throws(any Error).

Silently pulling the type of the actual argument into parameter declaration, when there's nothing there to indicate that (like a generic parameter E or at least a some Error), seems like a bit of a trap, and unprecedented…?

1 Like

But I guess if you want to avoid existentials you have to ensure the function is generic over the error types, not using any Error whether implicitly or otherwise.

So I get why this is a bit thorny, at least from an "Embedded Swift" perspective.

My instinct, at least, is that we should favour "common" Swift which prefers convenience and intuition over efficiency (to a degree). So by that principle it's better to err on the side of simplicity and convention and use the any Error existential by default - even if not technically necessary - with the ability to explicitly genericise over the error if you want. i.e.:

func translateRethrown(f: () throws -> Void) rethrows { … }

…is syntactic sugar for:

func translateRethrown(f: () throws(any Error) -> Void) rethrows(any Error) { … }

…but if you want you can explicitly make it generic, rather than existential, via:

func translateRethrown<E: Error>(f: () throws(E) -> Void) rethrows(E) { … }

I realise we then have a problem if you have multiple closure parameters that you want to be independent w.r.t. their error type, and we lack sum types (or equivalent) for handling that easily, but you can write code that will handle that (example below) and I think it's better to err on the side of simple, clear, and conservative w.r.t. assumptions.

enum Either<A, B> {
    case a(A)
    case b(B)
}

func translateRethrown<E1: Error, E2: Error>(f: () throws(E1) -> Void,
                                             g: () throws(E2) -> Void)
    rethrows(Either<E1, E2>) { … }

…or more loosely:

func translateRethrown(f: () throws(some Error) -> Void,
                       g: () throws(some Error) -> Void)
    rethrows(some Error) { … }
1 Like

The rethrows(any Error) formulation can't "remain available" because it doesn't exist in the language. It's in the proposal that one can specify rethrows(T). It doesn't have to be.

Yes, that's right. Why would anyone do this? Is it worth the complexity of all of the special rethrows logic specified in the current proposal document?

Let's take the history and source compatibility bits out of the discussion for a moment. Assume we have typed throws in the language but not rethrows: how would we justify introducing a rethrows that specifies that it throws an error type that is different than the closure arguments it is given? I can't see how we would, and even the name "re"-throws implies that you're not generating your own errors, just passing errors through.

There's an opportunity here to simplify part of the language (down to "just sugar") while also making it more general, and we should consider taking it.

Doug

11 Likes

I remain apprehensive about introducing typed throws to the language. I'll explain another time, but for now there is one thing I'd like to ask for some elaboration on: Embedded Swift.

I'm not sure I understand why this is required for Embedded Swift. Sure, existentials can have runtime overhead, and sure, that can be unacceptable on embedded platforms -- but so can unspecialised generics, yet Embedded Swift will support generics.

We're solving the latter by requiring the optimiser to completely specialise generics; we're not introducing anything new to the language model to support it, it's just a mandatory optimisation. Libraries which vend generic functions and data structures do not need to modify (that aspect of) their code to support Embedded Swift.

Why can't we do something similar for error existentials? Why can't we have the compiler "specialise" the specific occurrence of the error existential and turn it in to a synthesised enum? This would have the important benefit that the language model does not need to change in order to support embedded systems.

I just don't think it's tenable to hold both of these positions simultaneously:

  • Most new and existing code should continue to use untyped throws.
  • Embedded Swift cannot call functions which use untyped throws

Because that would mean Embedded Swift is a dialect which will not support any existing throwing Swift code, and will continue to not support anything which follows our recommendation to prefer untyped throws.

There are other things to consider with typed throws, for sure - I'm just trying to better understand this particular motivating problem.

9 Likes

So, why do you want to support typed rethrows? Why can't we just say rethrows is always any Error or Never. If you want to use "typed rethrows" - use throws(...). Discourage people from using rethrows. Rewrite the code in stdlib to use typed throws. And add a special attribute, that will instruct the compiler to emit a type-erased trampoline for ABI compatibility.

3 Likes

I for one really, really appreciate the thoughtfulness of the design in that version. I think it got all the defaults right while supporting migration of existing APIs in a maximally compatible way. If infeasible to implement then certainly worth exploring alternatives, but I’m wary of prematurely deciding that certain use cases supported now are niche enough to be whittled away by simplification as another wrinkle to an already complicated proposal.

1 Like

is this even possible without requiring all the code to live in one module?

I would refine this a bit, but it generally describes my apprehension as well. This will be seen as an attractive—and possibly necessary—feature. Any disclaimers that it shouldn’t be used will be massively underpowered.

If typed throws is really the right solution for certain circumstances, then I think it’s far more consistent to eliminate untyped throws altogether. I understand that if you squint, this pitch kind of does that by treating “untyped” throws as throws(errorUnion(E1, E2, …)), which will often but not always decay to throws(any Error). But that just seems like an inability to let go of Swift’s current error philosophy. If typed throws is really that great, why shouldn’t the developer specify the type they want to throw? Then they’ll have to think about the ABI consequences of their choice.

1 Like

I definitely see the value of the conceptual simplicity we get from treating rethrows as 'just' generalizing over the error type of closure arguments, and I agree that were we starting from a different place with typed throws already in place we'd probably find it difficult to justify the current design of rethrows.

However, given that we do support the current design, I think it's important to ask whether, even in a major language version bump, we consider this hack:

sufficient as a migration path. For users who are already making use of the transform-and-rethrow pattern, do we think that legitimate use cases are either so rare or so ill-advised that we're comfortable treating this as the 'blessed' way to achieve a 'clean' migration? It's worth noting that this transformation is itself source breaking, since inserting a defaulted argument will potentially break any use sites referencing the function in an unapplied manner. So, for someone who has written a transform-and-rethrow function, updating their library to Swift 6, what are their options? They can either:

  1. Drop the 'transform' behavior and just rethrow the underlying error directly.
  2. Adopt the hack and potentially break clients.
  3. Drop the 'rethrows' behavior and concede that the function must be considered always-throwing and almost certainly break clients.

The rework of rethrows takes a pretty strong position that (1) is the right path forward, but it's only an option for users who don't consider the thrown error to be a strong part of their declared contract. For users who have made strong promises in that regard, I don't see any good options. Would we really be willing to say that users who have built such APIs on this feature are just out of luck with respect to maintaining compatibility for their clients in Swift 6 mode? Or is there a door 4 I'm not seeing?

3 Likes

IIUC – it doesn't remove any currently supported use cases. As things stand today rethrows, would compile down to either a) throws (Never) or b) throws (any Error). In code:

func f(_ body: () throws -> Void) rethrows
// becomes
func f<E>(_ body: () throws(E) -> Void) throws(E)
// where E == Never || E == any Error

Essentially, rethrows always erases the Error type. Which is exactly the behaviour we have today – as there's no typed throws.

So in that regard, as @dmt says, I wonder if rethrows needs any further functionality at all. If someone wants to narrow the scope of their Error type, they should simply adopt the more expressive typed throws.

func f<E>(_ body: () throws(E) -> Void) throws(E)
// no constraint on E
1 Like

I don't know why it wouldn't be possible. At the implementation level it seems similar enough to an opaque return type.

If we can handle opaque return types, why couldn't we handle an opaque thrown error type?

But yeah, that's what I'd like clarification on. Is there some reason this is fundamentally impossible, such that the only way for Embedded Swift to support errors at all is for us to introduce typed throws? That's the impression I get from the proposal's motivation text:

In constrained environments such as those supported by Embedded Swift, existential types may not be permitted due to these overheads, making the existing untyped throws mechanism unusable in those environments.

Moreover, rethrows is always compiled down to throws(any Error). rethrows is an API thing, not ABI.

func test(_ a: [Int]) -> [Int] {
  a.map { $0 }
}

will be (attention to try_apply and bb2)

sil hidden @$s6output4testySaySiGACF : $@convention(thin) (@guaranteed Array<Int>) -> @owned Array<Int> {
bb0(%0 : $Array<Int>):
  debug_value %0 : $Array<Int>, let, name "a", argno 1, loc "/app/example.swift":1:13, scope 2 // id: %1
  %2 = alloc_stack $Array<Int>, loc "/app/example.swift":2:3, scope 2 // users: %7, %3, %9
  store %0 to %2 : $*Array<Int>, loc "/app/example.swift":2:3, scope 2 // id: %3
  %4 = function_ref @$s6output4testySaySiGACFS2iXEfU_ : $@convention(thin) @substituted <τ_0_0, τ_0_1> (@in_guaranteed τ_0_0) -> (@out τ_0_1, @error any Error) for <Int, Int>, loc * "/app/example.swift":2:9, scope 2 // user: %5
  %5 = thin_to_thick_function %4 : $@convention(thin) @substituted <τ_0_0, τ_0_1> (@in_guaranteed τ_0_0) -> (@out τ_0_1, @error any Error) for <Int, Int> to $@noescape @callee_guaranteed @substituted <τ_0_0, τ_0_1> (@in_guaranteed τ_0_0) -> (@out τ_0_1, @error any Error) for <Int, Int>, loc "/app/example.swift":2:9, scope 2 // user: %7
  %6 = function_ref @$sSlsE3mapySayqd__Gqd__7ElementQzKXEKlF : $@convention(method) <τ_0_0 where τ_0_0 : Collection><τ_1_0> (@guaranteed @noescape @callee_guaranteed @substituted <τ_0_0, τ_0_1> (@in_guaranteed τ_0_0) -> (@out τ_0_1, @error any Error) for <τ_0_0.Element, τ_1_0>, @in_guaranteed τ_0_0) -> (@owned Array<τ_1_0>, @error any Error), loc "/app/example.swift":2:5, scope 2 // user: %7
  try_apply %6<[Int], Int>(%5, %2) : $@convention(method) <τ_0_0 where τ_0_0 : Collection><τ_1_0> (@guaranteed @noescape @callee_guaranteed @substituted <τ_0_0, τ_0_1> (@in_guaranteed τ_0_0) -> (@out τ_0_1, @error any Error) for <τ_0_0.Element, τ_1_0>, @in_guaranteed τ_0_0) -> (@owned Array<τ_1_0>, @error any Error), normal bb1, error bb2, loc "/app/example.swift":2:5, scope 2 // id: %7

bb1(%8 : $Array<Int>):                            // Preds: bb0
  dealloc_stack %2 : $*Array<Int>, loc "/app/example.swift":2:14, scope 2 // id: %9
  return %8 : $Array<Int>, loc "/app/example.swift":3:1, scope 2 // id: %10

bb2(%11 : $any Error):                            // Preds: bb0
  unreachable , loc "/app/example.swift":2:5, scope 2 // id: %12
} // end sil function '$s6output4testySaySiGACF'

but don’t opaque return types use runtime generics, same as normal generics?

These two are not equivalent semantically (however it compiles being another matter), and this goes to the heart of what rethrows means in current Swift: rethrows means that f throws if and only if body throws—currently, there is no guarantee as to what f throws if body throws, but it throws something. By contrast, throws(E) means that f only throws E, but it might throw E de novo when body doesn't throw E, or body might throw E and f might not rethrow it.

// This example rethrows, but does not throw the same error as `body`:
func f(_ body: () throws -> Void) rethrows {
  do {
    try body()
  } catch {
    throw ErrorThatWrapsAnotherError(error)
  }
}

// This example throws the same error as `body`, but does not rethrow:
func f<
  E: ErrorProtocolThatRequiresInitWithNoParameters
>(_ body: () throws(E) -> Void) throws(E) {
  // We never execute `body`; throw unconditionally instead.
  throw E()
}
3 Likes

I just meant that I wasn't sure if you were getting rid of it entirely in the new version of the proposal compared to the old version of the proposal.

Because it lets you to wrap or replace the original error. Perhaps to add more context, or perhaps to avoid leaking errors of a certain subsystem externally. I don't know how common it is to do this, but it didn't take me much searching to find an example of this in the context of an article explaining how rethrows works.

1 Like

Well, it's "the same type of error".
But anyway, this is not a correct comparison. You required E to conform ErrorProtocolThatRequiresInitWithNoParameters which implies:

  1. body never throws Never (or any other Uninhabited type)
  2. You provided a factory for errors to f (E.init)

This is not what func f<E: Error>(_ body: () throws(E) -> Void) throws(E) means. I mean, just compare them:

  1. Can body be throwing Never? - Yes.
  2. Can f produce an instance of E by itself? - No.

The lack of knowledge "How to make an instance of E" is the key here. The only possible source of it is the failure of the call to body.

I hear what you're saying: a rethrows function throws if and only if the invocation of a supplied closure parameter throws. Whereas a typed throws function could throw outside of the invocation of a supplied closure.

However, I can't think of any situation which would rely on these semantics. How useful, really, is it to say 'this function can only throw if and only if its supplied closure throws when it's invoked'. In practice, programmers are using rethrows to express 'this function can throw when the supplied closure can throw'.

I'd be interested to see examples to the contrary, but really, if there's no evidence of people relying on the 'if and only if' semantics, it seems like jumping through hoops to hold onto them.

2 Likes