[Pitch N+1] Typed Throws

Not really: you could store a E somewhere else and throw that stored E instead. For instance:

var storedError: (any Error)?

func f<E: Error>(body: () throws(E) -> ()) throws(E) {
    if let e = storedError as? E {
       throw e // and never call body()
    }
    try body()
}

And it's a silly example written like that, but as a cache of some Result<Any, Error> that you cast back to the original return type and error type it makes a lot of sense.

Well, actually you're right, you can cast some value from the outer scope to the expected type, you can build a value of specified type in runtime, and so on. But this point doesn't justify (re)invention of rethrows.
rethrows also do not guarantee the function(f) will throw the value of error that was provided by the closure(body). It just doesn't do anything apart from convincing the callsite to assume that the failure branch is unreachable. Surely, it has semantics people (kinda) understand, but this is not enforced by the compiler. I saw people throw different types of error in rethrows functions, because they wanted to enrich the error with some context in which the error happened (let's say add Index at which map's transformation failed, doesn't really matter). It would be interesting if rethrows actually induce restrictions on the data flow and control flow in the function's body. For example, the same function func f(body: () throws -> Void) rethrows with the following restrictions:

  • body is mandatory to be invoked at least once.
  • throw is forbidden except when the argument is proven to be the error from body.

But that not the world we live in. This is too complicated and too unnecessary.
And I think it's nearly impossible to express the behaviour down to concrete instances, like "A function that rethrows the error (the instance, not the type) of the passed closure", in function signature.
We found this balance between strictness and expressivity and exposing too many implementation details in interface: signatures are aware of types, but unaware of actual values and data flows.

Does anyone know what the actual intent of rethrows was at its genesis? It'd be interesting to know if that intent is what we see today in its implementation or if it followed a different path.

What rethrows actually means today

It's apparent that there's multiple, conflicting interpretations of its purpose today. However, all but one are demonstrably incorrect insofar as how the compiler actually behaves today. Specifically, we have:

  1. Rethrows means the function doesn't throw itself, it only passes up errors thrown from its closure arguments.

    This is incorrect - you can catch the errors and throw different errors.

  2. Rethrows means the function will throw if & when one of its closure arguments does, but in no other case.

    This is incorrect - you can catch the errors and return without throwing.

  3. Rethrows means the function may only throw errors of the same type as its closure parameter(s) throw.

    This is incorrect - there are no restrictions on the types of any of the errors. You also may not throw at an arbitrary time, even if it's of the same error type as one of the closure arguments'.

  4. Rethrows means the function may throw only if & only when one of its closure arguments does.

    This is rethrows currently. This is also not just a matter of runtime behaviour but also compile-time typing - if the closures don't throw at all, the function doesn't throw at all (i.e. no try is needed to call it, no error-handling code is emitted, etc).

It's important to note that rethrows is perhaps not the ideal name for this functionality. But naming is hard, and while only-throws-if-and-when-a-closure-argument-throws is clearer, it's a little wordy. :slightly_smiling_face:

Typed throws partially obsolete rethrows

Not that rethrows behaves that way today anyway, but one doesn't need rethrows for that purpose when typed throws are available, because that type constraint can be expressed using normal generics grammar (in the same manner as for tying parameter types to non-error return types), e.g.:

func foo<E: Error>(_ arg: () throws(E) -> ()) throws(E) { … }

This also handles one aspect of what rethrows actually does today which is to allow a single function definition to produce both throwing and non-throwing variants (via the use of Never as the error type).

So for those purposes rethrows was never applicable and is no longer necessary, respectively.

The unique purpose of rethrows

However, typed throws do not have any way to express the other aspect of what rethrows means, which is the restriction on when an exception may be thrown at runtime.

So your question is really: is that specific functionality important?

I'm not certain what the answer is yet, and there may also be different answers depending on whether you mean important to the compiler or to humans.

However, I suspect there's a solid case for the latter audience, at least. Rethrows seems like an important hint that a function has no internal error cases, only whatever the closures bring in. Maybe the compiler doesn't care, but that's helpful to a human in interpreting and understanding what the function does and how it behaves.

If it's ultimately decided that such functionality is not still important, then indeed rethrows could be removed (hopefully with suitable migrators and FixIts etc to convert existing code over).

If that functionality is still important, then rethrows still has its place but doesn't have to accept a type argument, since in principle the rethrows annotation has only that specific meaning and is orthogonal to throws - you can optionally add a separate throws(X) annotation if you wish to impose any type constraints. The two could be combined for convenience - rethrows(X) - but arguably that conflates their purposes and may cause confusion…?

(it might also be worth considering a new, better name for rethrows, although nothing immediately comes to mind)

7 Likes

I’m genuinely interested in hearing a use case for this vs. semantics of typed throws.

Yet another angle of view to the problem. Let's try to apply this semantics to return value. We introduce a keyword rereturns, that should be read as "only returns a value from a closure if and when the closure argument returns".

func f(_ body: () -> some Any) rereturns {
  body()
}

So it substitutes the result type of f: if some Any happens to be Never - f never returns, otherwise it returns the result of body().
Do we need it? Does this tiny bit of semantics worth a keyword?
Obviously it's not, right?
withUnsafeBytes, withExtendedLifetime, withoutActuallyEscaping, withUnsafeTemporaryAllocation, ... all of them have this semantics, and non of them experience problems from the lack of the rereturns keyword.
Humans are ok with the current signatures of them.
Maybe the compiler can do something useful with the rereturns keyword?
Yes, it can actually. rereturns implies that body will be invoked before f returns, so the compiler can safely reason about the control flow in and around a call to f.
But Swift doesn't do this kind of things (it actually does, but only for optimization). There was an attempt but it didn't make it.
So the compiler can't do anything useful from this semantics. Maybe this will change in the future, but at least for now it can't.
All in all, there's no point to introduce the rereturns keyword, even if it provides "semantics".

2 Likes

Thanks for this analysis @wadetregaskis

This is the essential point to this discussion IMO. Were it not for this guarantee, getting rid of rethrows is reasonable. However, this guarantee is extremely useful for users. It would be disappointing to see it removed.

3 Likes

But why is it useful to document that in the language, instead of in the function’s documentation? The strongest implication of that property is already covered by the throws(E) where E == Never case. Swift doesn’t encode other aspects of idempotency in the type signature.

1 Like

I agree that it seems to be central to the discussion, but I'm still yet to hear any use cases that demonstrate how it's useful. I could even get behind the idea that calling a function marked rethrows is guaranteed to only throw Errors that originate from the supplied throwing closure.

But that is provably not the case. This compiles without issue:

struct MyError: Error {}

func f(_ g: () throws -> Void) rethrows {
  do {
    try g()
  }
  catch {
    throw MyError()
  }
}

So even today, the types of Errors that can be thrown from a rethrows function offer no guarantees.

So what exactly are we getting rid of here?

It’s a guarantee about when errors will not be thrown. Callers often know the implementation of the arguments they provide, and therefore the dynamic conditions under which an error may or may not be thrown. It can be very helpful to know that these are the exact same conditions under which the invoked rethrowing function may throw (even if the error it throws could be transformed). The fact that error documentation is usually abysmal makes this static guarantee even more important IMO.

2 Likes

OK, that's interesting to hear. I think I have a better understanding of the motivation now – thanks.

But doesn't typed throws offer a stronger guarantee?

If you have a function f:

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

How would you get an E without g throwing?

1 Like

There's an interesting parallel between rethrow used for error wrapping and Result.mapError used for the same thing. From mapError's documentation:

mapError

Returns a new result, mapping any failure value using the given transformation.
[...]
Use this method when you need to transform the value of a Result instance when it represents a failure. The following example transforms the error value of a result by wrapping it in a custom Error type:

 struct DatedError: Error {
 	var error: Error
 	var date: Date

 	init(_ error: Error) {
 		self.error = error
 		self.date = Date()
 	}
 }

 let result: Result<Int, Error> = // ...
 // result == .failure(<error value>)
 let resultWithDatedError = result.mapError { DatedError($0) }
 // result == .failure(DatedError(error: <error value>, date: <date>))

The difference is that rethrows does a better job here.

If mapError was given a Result<Int, Never>, it'd transform it to a Result<Int, DatedError>. But it's impossible to map Never to anything since it's impossible to call the mapping closure. In the transform, we get rid of the interesting fact that there can't be an error.

With rethrow, the catch statement plays the role of the closure in mapError. Similar to the closure, it can't be called if the original closure doesn't throw. But here the compiler recognizes this and makes the function non-throwing, or throws(Never). It's as if we had a mapError operation capable of returning a Result<Int, Never> if the error type was Never in the first place.


See my post above for an example.

1 Like

That's some serious hoop jumping: if the module you're calling into somehow has access to initialize your Error type and is opportunistically casting to it – you've probably got bigger issues. And of course rethrows is subject to similar abuse through creating and/or transforming arbitrary Errors in its catch clause and what not, so I'm not sure there's a convincing argument here.

1 Like

In my perspective, the throws(E) construct serves to preserve the error type, while rethrows specifically pertains to throwing the same error instance.

Both of these mechanisms have distinct applications, with rethrows carrying a slightly stricter semantic implication.

Because if it's in the actual language the compiler can enforce it. As it does today. So the same benefits as static typing in general (although not of as much benefit as, e.g. knowing that a variable is a number and not a string).

From a user perspective, machine-validated is usually better than unvalidated. Documentation is great except when it's outdated or flat-out wrong (SingleValueDecodingContainer et al), which is all too often. I love documentation because it can be game-changing, when done right, but I also hate it because most of the time it's done wrong (or not at all).

But validation isn't magically free - from a compiler-author perspective, for example, it may be much less attractive in some cases. I gather rethrows might be one of them, given @Douglas_Gregor bringing up this existential question about rethrows to begin with. It sounds like it's hard to actually implement rethrows and typed throws together.

Indeed.

And it's a little like throwing vs non-throwing to begin with. If you have a function func foo(…) {} - no throws annotation - you know you can call it and it's "transparent", in an error-handling sense. It's not something you need to worry about in terms of failure origin points within your program. Whereas func bar(…) throws {} has to be approached more carefully - not just syntactically with the required try keywords etc, but logically as you design your program and reason about its failure modes. You have to ask questions like "what should my program do if this specific thing fails - what does it mean if this things fails?", "how do I present this to the user?", etc.

So a function that rethrows [only] is also "transparent" in this sense. You do still have to use try etc, but the function is merely on the path of errors, not an origin of errors. So it doesn't add any additional burden to your design-level thinking w.r.t. failure modes.

It depends on the definition of E. For the most generic case - E: Error - indeed I don't see a realistic way for f to make a new one. But one certainly can constrain E to a protocol that facilitates it. e.g.:

/// Enhanced error protocol for all Acme Corp libraries,
/// that provides important fundamental error functionality.
protocol StandardError: Error {
    …

    static func missing(argument: String,
                        function: String = #function,
                        file: String = #file,
                        line: Int = #line) -> HandyError

    …
}

func f<E: StandardError>(_ g: () throws(E) -> Void) throws(E) {
    throw E.missing(argument: "g")
}

Now, how common or useful that is, I dunno. My own use of errors tends to be pretty straight-forward, largely just plain error enums. But I write Swift solo, not as part of a big company. And big companies have amazing abilities to complicate everything, for better or worse. :slightly_smiling_face:

It's nevertheless a good answer to your initial question: "How would you get an E without g throwing?".

By the way, you can even do that with an internal error type from another module. All you need is to collect and store an error of the same type thrown at another time and throw it.

Fair. :slight_smile:

Sure. But again, I'm not sure that this kind of abuse could be considered any worse than the abuse that can be performed from within a rethrows function (i.e. from within the catch clause.)

I'm just saying that the closer you look at this, the harder it is to find any tangible difference between the functionality of the two methods and their respective limitations. Even if the original intention of rethrows was different, in practice it's been ticking over with more or less the same semantics that will be offered by typed throws.

Simplifying the language seems like a huge win over maintaining parallel implementations for achieving essentially the same functionality.

I hear what you're saying here, but a function that is generic over its thrown Error should be communicating something similar to the programmer.

Yeah, I think this is an important point. There's two aspects to what we get from rethrows today: the guarantee in the non-throwing case, and the guarantee in the throwing case. Without typed throws in the mix, the non-throwing case boils down to "if no input function is of throws type, then this higher-order function will not throw." The proposed reframing of rethrows maintains this entirely—we know that the higher-order function will not throw if not provided with a throwing input function.

In particular, I think it's worth noting that this formulation:

entirely prevents calling f with E == Never (unless someone conformed Never to StandardError, which is plainly invalid).

As others have noted, you can contrive implementations of f<E>(g: () throws(E) -> Void) throws(E) which manage to have f throw even when g doesn't, dynamically, throw itself. But all examples are IMO exceptionally weird and not something that an implementor would do accidentally. You definitely have to go out of your way to construct an E which has not been thrown by an input function.

Moreover, all of the examples I've seen demonstrating this dynamic concern rely on having an utterable identity for the error type E, but as-proposed, rethrows would offer no such affordance. Without being able to spell the hidden error type parameter that gets introduced, do any of the same objections apply? At that point, it really does seem to me that the only way to recover and throw a value of the correct static type would be if it was thrown by the input function itself.

Lastly, I think we should also analyze the impact on clients in these unusual cases. Since we've narrowed our focus to the throwing case, the client of a 'new rethrows' function must have provided a throwing input function, e.g.:

func g() throws -> Int { ... }

func f(_ h: () throws -> Int) rethrows { ... }

do {
  try f(g) // g may throw, so must catch errors
} catch {
  ...
}

What harm may befall the client if f throws in a condition where g does not, in fact, dynamically throw? We would have had to add the static error handling structure anyway, since g is a throwing function. If we had a guarantee that g wouldn't throw, then f would inherit that guarantee! The only circumstance that comes to mind where we could actually be harmed by this is if we somehow knew, dynamically, that g would not throw and tried to rely on that with a poorly behaved implementation of f:

// Must be 'throws' to, say, satisfy a protocol requirement
let g: () throws -> Int = {
  return 0
}

// should be safe, 'f' is 'rethrows'
try! f(g) // 💥

But is this risk realistic? I can't even think of a real-world situation where this particular set of facts would arise.

1 Like

I don’t think such a function is weird at all. If E == any Error, all distributed actor methods fit this pattern. There’s nothing weird about that.

In other words, I think the only useful aspect of rethrows is that it lets you elide try when passing a non-throwing closure, and typed throws completely subsumes this use case.

Perhaps I'm misunderstanding what you're getting at here, but if f is a distributed actor method then it will appear externally as async throws regardless of the identity of E. The point I'm trying to make is that it requires some (IMO substantial) contriving to write generic code which satisfies all of the following:

  • Will not throw when E == Never
  • When E != Never, will only throw error values of type E
  • Is capable of throwing values of type E even when the underlying input function does not, in fact, throw

And I'm not sure that the fact rethrows currently prevents such a construction realistically provides any significant value to clients. IOW, I think we're agreeing on this point, though you're perhaps stating it a bit more strongly than I am:

1 Like

Yes, we’re in violent agreement. :slight_smile:

3 Likes