[swift-evolution] [Pitch] Typed throws

Karl Wagner razielim at gmail.com
Tue Feb 21 02:01:21 CST 2017

> On 21 Feb 2017, at 00:34, Karl Wagner <karl.swift at springsup.com> wrote:
>> On 19 Feb 2017, at 21:04, Anton Zhilin <antonyzhilin at gmail.com <mailto:antonyzhilin at gmail.com>> wrote:
>> It’s expected that if you need resilience, then you will throw an “open” enum. Essentially, we pass resilience of typed throws on to those who will hopefully establish resilience of enums.
>> If you prefer separate error types, then declare a base protocol for all your error types and throw a protocol existential. You won’t even need default case in switches, if closed protocols make it into the language.
>> I don’t like any solution that is based on comments. I think that compiler should always ignore comments.
> Open enums can only add cases, not remove them. That means that new versions of a function will similarly only be able to add errors, and won’t be able to communicate that certain errors are no longer thrown. Protocols aren’t a solution, because you will need to write verbose conformances for all of your Error types.
> Let me put this in another (perhaps more palatable) way. Forget comments, let’s say its part of some hidden metadata:
> - The compiler lists every error which can be thrown by a function (it’s easily able to do that)
> - Rather than making this part of the signature, the signature only says “throws an Error” and the actual Error list is written somewhere in the module as documentation/metadata.
> Here’s why it’s so good:
> - It’s completely free (you don’t write anything). The compiler generates everything for you.
> - It’s **completely optional**: it won’t make your structure your Errors in a way that's less usable in a type-system sense for clients who don’t care about exhaustive catching.
> - Exhaustive catching within your own module for free (you can omit a catch-all, the compiler won’t complain)
> It’s good for resiliency, too:
> - Non-resilient functions always reserve the right to throw new Errors in later versions. No system will get exhaustive catching for them anyway.
> - If you stop throwing a specific Error, nothing breaks - it simply vanishes from the documentation/metadata. The compiler can simply warn about the redundant catch.
> - Resilient (@versioned) functions can still offer exhaustive catching if we want to offer that. We might decide to make that opt-in/opt-out, because it would mean they would be limited to removing Errors, and never adding new ones.
> —> As with any ABI promise, we will trap or it will be UB if you break the contract. We couldn’t validate it when compiling, but theoretically a validator could be built which compared two library versions.
> - Karl

So here’s my counter-proposal, fleshed out:

Specially, on resiliency:

## Internally to a module

Compiler can use generated Error-list metadata to:
- provide compile-errors with specific uncaught errors (better diagnostics)
- allow omitting catch-alls
- optimise away Error existential allocations

All of that would automatically apply to all throwing/rethrowing functions, without any additional developer effort.

## Cross-module

Compiler can use generated Error-list metadata to:
- Inform users about errors that might get thrown by this version of the function (purely documentation)
- Allow omitting catch-alls for specific functions which opt-in to that contract.

And that’s it. Notice there is no behavioural change; the Error-list metadata is entirely optional.

### Exhaustive catching cross-module

Resilient functions can _additionally_ promise to never throw new Errors. It should be an additional promise. From an error-list perspective, the function makes an additional promise that later versions of the error-list will never get more inclusive.

We can’t check that resilience at compile-time, though. If you change the function signature, you will get a error in the dynamic linker. Similarly, if somebody just adds a case to their @fixed enum, you won’t know until runtime when it gets thrown and nobody’s there to catch it.

- It would be cool if we failed gracefully in that case; if the caller wasn’t catching exhaustively, the new error should fall in to the existing catch-all.
- Otherwise, if the caller was assuming the library author kept their promise and omitted a catch-all, we should still synthesise one to provide a unique trap location (something like swift_resilience_unexpectedError()).
    - It means we can't optimise away the Error existential cross-module (maybe in -Ounchecked?), but that seems to me like an acceptable cost.

The big problem with this is that it relies on unwritten, generated metadata. For resilient functions promising resilient error-lists, it’s helpful to have the errors you’re promising written down and easily manually inspectable. That’s why I initially suggested having the compiler validate the documentation comments. We could still do that - so if you have a @versioned function and you additionally say that the errors it throws are also @versioned, you have to write a comment listing every Error (and the compiler will check it).

Small example:

enum FailureReason {
    case deviceBusy
    case networkDown
    case notFound

//% [Error-list]: FailureReason.notFound
func openFile(_ path: String) throws { … }

//% @versioned [Error-list]: FailureReason.notFound, FailureReason.deviceBusy
func read(_ path: String, range: Range<Int>) -> Data { … }

//% [Error-list]: <rethrows from arg1>
func retrying(attempts n: Int, _ work: ()throws -> Void) rethrows -> Bool {
  for _ in 0..<attempts {
    do      { try work(); return true }
    catch FailureReason.deviceBusy    { continue }
    catch FailureReason.networkDown { continue }
    catch { throw error }
  return false

//% [Error-list]: <rethrows from arg0, masks: FailureReason.notFound>
func mustBeFound(_ work: ()throws -> Void) rethrows {
    do { try work() }
    catch FailureReason.notFound { fatalError(“This thing must be found") }
    catch { throw error }

mustBeFound { openFile(“test.txt”) } // can be proven not to throw (in same module), because mustBeFound handles all errors

// cross-module

// Function does not have a versioned error-list; catch-all is mandatory.

do { try openFile(“test.txt”) }
catch .notFound { print(“not found!”) }
catch           { print(“other error: \(error}”) }

do    { try mustBeFound { open(“test.txt”) } }
catch { print(“other error: \(error}”) }

// Function has a versioned error-list; catch-all is optional.

do                { try readFile(“test.txt”, range: 0..<64) }
catch .notFound   { print(“file not found!”) }
catch .deviceBusy { /* maybe retry? */       }
// [implicit] catch { _swift_reslience_unexpectedError() }

-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.swift.org/pipermail/swift-evolution/attachments/20170221/66b2ae51/attachment.html>

More information about the swift-evolution mailing list