[swift-evolution] [Pitch] Typed throws
John McCall
rjmccall at apple.com
Mon Feb 20 15:44:08 CST 2017
> On Feb 20, 2017, at 3:46 PM, Karl Wagner <razielim at gmail.com> wrote:
>> On 20 Feb 2017, at 18:57, John McCall <rjmccall at apple.com <mailto:rjmccall at apple.com>> wrote:
>>
>>> On Feb 19, 2017, at 3:04 PM, Anton Zhilin via swift-evolution <swift-evolution at swift.org <mailto:swift-evolution at swift.org>> 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.
>>>
>> I agree. And in general, this sort of thing is exactly my core concern about adding typed throws to the language: I am completely certain that many programmers will add typed throws annotations because they're programmers and thus, well, probably a little obsessive/compulsive, and they're trying to precisely document the behavior of their function without necessarily thinking about the usefulness of that information for their clients and (if they're writing a library; and really you should almost always be writing code as if you're writing a library) whether they're actually willing to commit to that behavior in their interface. For those programmers, typed throws is just going to box them in and force them into anti-patterns in the long run.
>>
>> In the vast majority of use-cases, clients are not going to exhaustively handle all errors — they will always have some generic fall-back. That is not pessimism, it's actually the natural result of the complicated world we live in, where code can fail for a huge host of reasons and most callers won't have meaningful special-case behavior for all of them. (On most operating systems, opening a file or a network connection can fail because you ran out of file descriptors. You're seriously telling me that you're going to add a special case to your error logic for that?) Go look at the actual error types that people use in most typed-throws situations and try to tell me I'm wrong — they probably have like twenty alternatives, and solidly a quarter or more of them will just be embedding some other arbitrarily-complex or stringly-typed error value.
>>
>> The real use case for typed throws is when you have something like a parser library that really does only fail in a fixed number of semantically distinct ways, and you both (1) actually care about enforcing that in the implementation and making sure that other errors are handled internally and (2) you really do expect clients to exhaustively switch over the error at some point. That's important. But I continue to think that if adding better support for that use case misleads other programmers into thinking they should use typed throws, we will have made the language worse overall.
>>
>> John.
>>
>>
>>
>>> 2017-02-18 18:27 GMT+03:00 Karl Wagner <razielim at gmail.com <mailto:razielim at gmail.com>>:
>>>
>>>
>>>
>>>
>>> So, I’m not sure about what was decided last time, but my issues with this are:
>>>
>>> - The thrown error type will become part of the ABI of the function. If you change the type of Error that is thrown, callers may not catch it. At the same time, if we make enums resilient by default and only allow specifying a single entire type, you will basically need one Error enum per function and it will need to be @fixed if you actually want to remove the catch-all block. Otherwise:
>>>
>>> // Let’s say this isn’t @fixed...
>>> enum CanFailError {
>>> errorOne
>>> errorTwo
>>> }
>>>
>>> func canFail() throws(CanFailError) { /* … */ }
>>>
>>> do { try canFail() }
>>> catch CanFailError {
>>> switch error {
>>> case .errorOne: /* handle error one */
>>> case .errorTwo: /* handle error two */
>>> default: /* handle possible new errors in later versions of the library */
>>> }
>>> }
>>>
>>> do { try canFail() }
>>> catch .errorOne { /* handle error one */ }
>>> catch .errorTwo { /* handle error two */ }
>>> catch { /* handle possible new errors in later versions of the library */ }
>>>
>>> - I usually have _semantic_ namespaces for Errors, rather than single types per implementation pattern. If we are adding strong annotations about which errors can be thrown, I’d quite like to incorporate that pattern. For example:
>>>
>>> extension File {
>>> @fixed enum OpeningError {
>>> case .invalidPath
>>> case .accessDenied // e.g. asking for write permissions for read-only file
>>> }
>>> @fixed enum ReadError {
>>> case .invalidOffset // past EOF
>>> case .deviceError // probably worth aborting the entire operation the read is part of
>>> }
>>>
>>> // - throws:
>>> // - .OpeningError if the file can’t be opened
>>> // - .ReadError if the read operation fails
>>> func read(from offset: Int, into buffer: UnsafeBufferPointer<UInt8>) throws(OpeningError, ReadError) { /* … */ }
>>> }
>>>
>>> - I wonder if we could try something more ambitious. Since the list of thrown errors is resilience-breaking for the function, it is only beneficial for versioned and @inlineable functions. They should not be able to add new errors (they can remove them though, since errors are intended to be switched over). I wonder if we couldn’t introduce a small pattern grammar for our structured comments (isolated from the rest of the language) - it would be optional, but if you do list your errors, the compiler would validate that you do it exhaustively. Some patterns I would like are:
>>>
>>> // - throws: - MyError.{errorOne, errorThree, errorFive}: Something bad || considered exhaustive
>>> @inlineable public func canFail() throws {}
>>>
>>> // - throws: - OpeningError: Computer says nooooo... || considered exhaustive if OpeningError is versioned or @fixed
>>> // - * || other errors, requires “catch-all” by external callers
>>> @inlineable public func canFail2() throws {}
>>>
>>> If we want to get really clever, we can have the compiler automatically generate those error-lists for internal functions, so you would automatically get exhaustive error-handling within your own module.
>>>
>>> - Karl
>>>
>>>
>>> _______________________________________________
>>> swift-evolution mailing list
>>> swift-evolution at swift.org <mailto:swift-evolution at swift.org>
>>> https://lists.swift.org/mailman/listinfo/swift-evolution <https://lists.swift.org/mailman/listinfo/swift-evolution>
>
> I agree, and that’s where I was going with it: I think that typed-throws should basically be something on the level of a stronger comment rather than something so definitive as the function’s ABI. That’s how it will be much of the time in practice, anyway.
>
> I don’t believe having a single error type is really ideal for anything. We’ve basically whittled down the feature until it gets in the way. If every function is throwing its own enum or hidden under complex hierarchies of protocols, it becomes difficult to write helper routines which respond to common errors in certain ways (e.g. trying an operation if if failed because the network was down).
>
>
> // Using one-enum per function
>
> enum FunctionOneError: Error {
> case networkWasDown(shouldTryAgain: Bool)
> case otherReason
> }
> func functionOne() throws(FunctionOneError)
>
> enum FunctionTwoError: Error {
> case networkWasDown(shouldTryAgain: Bool)
> case aDifferentReason
> }
> func functionTwo() throws(FunctionTwoError)
>
> // How to use this information at a high level?
>
> func retryIfNetworkDown(let attempts: Int = 3, work: ()throws->Void) rethrows -> Bool { // <- Can’t specify which errors we take, or which we rethrow
> for n in 0..<attempts {
> do { try work() }
> catch FunctionOneError.networkWasDown(let tryAgain) {
> if tryAgain, n<attempts { continue }
> else { return false }
> }
> catch FunctionTwoError.networkWasDown(let tryAgain) { // Needs to handle per-function errors :(
> if tryAgain, n<attempts { continue }
> else { return false }
> }
> catch { throw error }
> }
> }
>
> So I’ve heard people say you should create a protocol then, but that’s not really a convenient solution either...
>
> protocol NetworkError {
> func wasNetworkDownAndShouldTryAgain() -> (Bool, Bool)
> }
>
> extension FunctionOneError: NetworkError {
> func wasNetworkDownAndShouldTryAgain() -> (Bool, Bool) {
> guard case .networkWasDown(let tryAgain) = self else { return (false, false) }
> return (true, tryAgain)
> }
> }
>
> // This needs to be done twice, too...
>
> extension FunctionTwoError: NetworkError {
> func wasNetworkDownAndShouldTryAgain() -> (Bool, Bool) {
> guard case .networkWasDown(let tryAgain) = self else { return (false, false) }
> return (true, tryAgain)
> }
> }
>
>
> So I think it all descends in to lots of syntax for very marginal amounts of value. Typed-throws is never likely to be the wondrous 100% cross-library reliability guarantee that people dream of. I view it more like good documentation.
I completely agree that what most programmers are looking for is just a blessed way to document that a function is likely to throw specific kinds of error, and that certain of them might be worth considering in the caller.
John.
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.swift.org/pipermail/swift-evolution/attachments/20170220/359bf310/attachment.html>
More information about the swift-evolution
mailing list