[swift-evolution] [Discussion] Analysis of the design of typed throws
Matthew Johnson
matthew at anandabits.com
Fri Feb 24 12:34:22 CST 2017
> On Feb 24, 2017, at 12:06 PM, David Hart <david at hartbit.com> wrote:
>
> Sending to mailing list:
>
> On 23 Feb 2017, at 01:37, Matthew Johnson via swift-evolution <swift-evolution at swift.org <mailto:swift-evolution at swift.org>> wrote:
>
>> # Analysis of the design of typed throws
>>
>> ## Problem
>>
>> There is a problem with how the proposal specifies `rethrows` for functions that take more than one throwing function. The proposal says that the rethrown type must be a common supertype of the type thrown by all of the functions it accepts. This makes some intuitive sense because this is a necessary bound if the rethrowing function lets errors propegate automatically - the rethrown type must be a supertype of all of the automatically propegated errors.
>>
>> This is not how `rethrows` actually works though. `rethrows` currently allows throwing any error type you want, but only in a catch block that covers a call to an argument that actually does throw and *does not* cover a call to a throwing function that is not an argument. The generalization of this to typed throws is that you can rethrow any type you want to, but only in a catch block that meets this rule.
>
> What? This makes no sense to me. Can you elaborate? I don't see the relationship about rethrows and catch.
This was a little bit surprising to me as well but I wouldn’t want it any other way. Here is some valid Swift 3 sample code demonstrating how this works:
enum E: Error { case e }
enum F: Error { case f }
func ithrow() throws { throw E.e }
func nothrow() {}
func rethrower(f: () throws -> Void, g: () throws -> Void) rethrows {
do {
try f()
// I am not allowed to call `ithrow` here because it is not an argument
// and a throwing catch clause is reachable if it throws.
// This is because in a given invocation `f` might not throw but `ithrow` does.
// Allowing the catch clause to throw an error in that circumstance violates the
// invariant of `rethrows`.
//
// try ithrow()
} catch _ as E {
// I am allowed to catch an error if one is dynamically thrown by an argument.
// At this point I am allowed to throw *any* error I wish.
// The error I rethrow is not restricted in any way at all.
// That *does not*
throw F.f
}
do {
// Here I am allowed to call `ithrow` because the error is handled.
// There is no chance that `rethrower` throws evne if `ithrow` does.
try ithrow()
// We handle any error thrown by `g` internally and don't propegate it.
// If `f` is a non-throwing function `rethrower` should be considered non-throwing
// regardless of whether `g` can throw or not because if `g` throws the error is handled.
// Unfortunately `rethrows` is not able to handle this use case.
// We need to treat all functions with an uninhabitable errror type as non-throwing
// if we want to cover this use case.
try g()
} catch _ {
print("The error was handled internally")
}
}
>
>> ## Example typed rethrow that should be valid and isn't with this proposal
>>
>> This is a good thing, because for many error types `E` and `F` the only common supertype is `Error`. In a non-generic function it would be possible to create a marker protocol and conform both types and specify that as a common supertype. But in generic code this is not possible. The only common supertype we know about is `Error`. The ability to catch the generic errors and wrap them in a sum type is crucial.
>>
>> I'm going to try to use a somewhat realistic example of a generic function that takes two throwing functions that needs to be valid (and is valid under a direct generalization of the current rules applied by `rethrows`).
>>
>> enum TransformAndAccumulateError<E, F> {
>> case transformError(E)
>> case accumulateError(F)
>> }
>>
>> func transformAndAccumulate<E, F, T, U, V>(
>> _ values: [T],
>> _ seed: V,
>> _ transform: T -> throws(E) U,
>> _ accumulate: throws (V, U) -> V
>> ) rethrows(TransformAndAccumulateError<E, F>) -> V {
>> var accumulator = seed
>> try {
>> for value in values {
>> accumulator = try accumulate(accumulator, transform(value))
>> }
>> } catch let e as E {
>> throw .transformError(e)
>> } catch let f as F {
>> throw .accumulateError(f)
>> }
>> return accumulator
>> }
>>
>> It doesn't matter to the caller that your error type is not a supertype of `E` and `F`. All that matters is that the caller knows that you don't throw an error if the arguments don't throw (not only if the arguments *could* throw, but that one of the arguments actually *did* throw). This is what rethrows specifies. The type that is thrown is unimportant and allowed to be anything the rethrowing function (`transformAndAccumulate` in this case) wishes.
>>
>>
>> ## Eliminating rethrows
>>
>> We have discussed eliminating `rethrows` in favor of saying that non-throwing functions have an implicit error type of `Never`. As you can see by the rules above, if the arguments provided have an error type of `Never` the catch blocks are unreachable so we know that the function does not throw. Unfortunately a definition of nonthrowing functions as functions with an error type of `Never` turns out to be too narrow.
>>
>> If you look at the previous example you will see that the only way to propegate error type information in a generic function that rethrows errors from two arguments with unconstrained error types is to catch the errors and wrap them with an enum. Now imagine both arguments happen to be non-throwing (i.e. they throw `Never`). When we wrap the two possible thrown values `Never` we get a type of `TransformAndAccumulateError<Never, Never>`. This type is uninhabitable, but is quite obviously not `Never`.
>>
>> In this proposal we need to specify what qualifies as a non-throwing function. I think we should specifty this in the way that allows us to eliminate `rethrows` from the language. In order to eliminate `rethrows` we need to say that any function throwing an error type that is uninhabitable is non-throwing. I suggest making this change in the proposal.
>>
>> If we specify that any function that throws an uninhabitable type is a non-throwing function then we don't need rethrows. Functions declared without `throws` still get the implicit error type of `Never` but other uninhabitable error types are also considered non-throwing. This provides the same guarantee as `rethrows` does today: if a function simply propegates the errors of its arguments (implicitly or by manual wrapping) and all arguments have `Never` as their error type the function is able to preserve the uninhabitable nature of the wrapped errors and is therefore known to not throw.
>>
>> ### Why this solution is better
>>
>> There is one use case that this solution can handle properly that `rethrows` cannot. This is because `rethrows` cannot see the implementation so it must assume that if any of the arguments throw the function itself can throw. This is a consequence of not being able to see the implementation and not knowing whether the errors thrown from one of the functions might be handled internally. It could be worked around with an additional argument annotation `@handled` or something similar, but that is getting clunky and adding special case features to the language. It is much better to remove the special feature of `rethrows` and adopt a solution that can handle edge cases like this.
>>
>> Here's an example that `rethrows` can't handle:
>>
>> func takesTwo<E, F>(_ e: () throws(E) -> Void, _ f: () throws(F) -> Void) throws(E) -> Void {
>> try e()
>> do {
>> try f()
>> } catch _ {
>> print("I'm swallowing f's error")
>> }
>> }
>>
>> // Should not require a `try` but does in the `rethrows` system.
>> takesTwo({}, { throw MyError() })
>>
>> When this function is called and `e` does not throw, rethrows will still consider `takesTwo` a throwing function because one of its arguments throws. By considering all functions that throw an uninhabited type to be non-throwing, if `e` is non-throwing (has an uninhabited error type) then `takesTwo` is also non-throwing even if `f` throws on every invocation. The error is handled internally and should not cause `takesTwo` to be a throwing function when called with these arguments.
>>
>> ## Error propegation
>>
>> I used a generic function in the above example but the demonstration of the behavior of `rethrows` and how it requires manual error propegation when there is more than one unbounded error type involved if you want to preserve type information is all relevant in a non-generic context. You can replace the generic error types in the above example with hard coded error types such as `enum TransformError: Error` and `enum AccumulateError: Error` in the above example and you will still have to write the exact same manual code to propegate the error. This is the case any time the only common supertype is `Error`.
>>
>> Before we go further, it's worth considering why propegating the type information is important. The primary reason is that rethrowing functions do not introduce *new* error dependencies into calling code. The errors that are thrown are not thrown by dependencies of the rethrowing function that we would rather keep hidden from callers. In fact, the errors are not really thrown by the rethrowing function at all, they are only propegated. They originate in a function that is specified by the caller and upon which the caller therefore already depends.
>>
>> In fact, unless the rethrowing function has unusual semantics the caller is likely to expect to be able catch any errors thrown by the arguments it provides in a typed fashion. In order to allow this, a rethrowing function that takes more than one throwing argument must preserve error type information by injecting it into a sum type. The only way to do this is to catch it and wrap it as can be seen in the example above.
>>
>> ### Factoring out some of the propegation boilerplate
>>
>> There is a pattern we can follow to move the boilerplate out of our (re)throwing functions and share it between them were relevant. This keeps the control flow in (re)throwing functions more managable while allowing us to convert errors during propegation. This pattern involves adding an overload of a global name for each conversion we require:
>>
>> func propegate<E, F, T>(@autoclosure f: () throws(E) -> T)
>> rethrows(TransformAndAccumulateError<E, F>) -> T {
>> do {
>> try f()
>> } catch let e {
>> throw .transformError(e)
>> }
>> }
>> func propegate<E, F, T>(@autoclosure f: () throws(F) -> T)
>> rethrows(TransformAndAccumulateError<E, F>) -> T {
>> do {
>> try f()
>> } catch let e {
>> throw .accumulateError(e)
>> }
>> }
>>
>> Each of these overloads selects a different case based on the type of the error that `f` throws. The way this works is by using return type inference which can see the error type the caller has specified. The types used in these examples are intentionally domain specific, but `TransformAndAccumulateError` could be replaced with generic types like `Either` for cases when a rethrowing function is simply propegating errors provided by its arguments.
>>
>> ### Abstraction of the pattern is not possible
>>
>> It is clear that there is a pattern here but unforuntately we are not able to abstract it in Swift as it exists today.
>>
>> func propegate<E, F, T>(@autoclosure f: () throws(E) -> T) rethrows(F) -> T
>> where F: ??? initializable with E ??? {
>> do {
>> try f()
>> } catch let e {
>> throw // turn e into f somehow: F(e) ???
>> }
>> }
>>
>> ### The pattern is still cumbersome
>>
>> Even if we could abstract it, this mechanism of explicit propegation is still a bit cumbersome. It clutters our code without adding any clarity.
>>
>> for value in values {
>> let transformed = try propegate(try transform(value))
>> accumulator = try propegate(try accumulate(accumulator, transformed))
>> }
>>
>> Instead of a single statement and `try` we have to use one statement per error propegation along with 4 `try` and 2 `propegate`.
>>
>> For contrast, consider how much more concise the original version was:
>>
>> for value in values {
>> accumulator = try accumulate(accumulator, transform(value))
>> }
>>
>> Decide for yourself which is easier to read.
>>
>> ### Language support
>>
>> This appears to be a problem in search of a language solution. We need a way to transform one error type into another error type when they do not have a common supertype without cluttering our code and writing boilerplate propegation functions. Ideally all we would need to do is declare the appropriate converting initializers and everything would fall into place.
>>
>> One major motivating reason for making error conversion more ergonomic is that we want to discourage users from simply propegating an error type thrown by a dependency. We want to encourage careful consideration of the type that is exposed whether that be `Error` or something more specific. If conversion is cumbersome many people who want to use typed errors will resort to just exposing the error type of the dependency.
>>
>> The problem of converting one type to another unrelated type (i.e. without a supertype relationship) is a general one. It would be nice if the syntactic solution was general such that it could be taken advantage of in other contexts should we ever have other uses for implicit non-supertype conversions.
>>
>> The most immediate solution that comes to mind is to have a special initializer attribute `@implicit init(_ other: Other)`. A type would provide one implicit initializer for each implicit conversion it supports. We also allow enum cases to be declared `@implicit`. This makes the propegation in the previous example as simple as adding the `@implicit ` attribute to the cases of our enum:
>>
>> enum TransformAndAccumulateError<E, F> {
>> @implicit case transformError(E)
>> @implicit case accumulateError(F)
>> }
>>
>> It is important to note that these implicit conversions *would not* be in effect throughout the program. They would only be used in very specific semantic contexts, the first of which would be error propegation.
>>
>> An error propegation mechanism like this is additive to the original proposal so it could be introduced later. However, if we believe that simply passing on the error type of a dependency is often an anti-pattern and it should be discouraged, it is a good idea to strongly consider introducing this feature along with the intial proposal.
>>
>>
>> ## Appendix: Unions
>>
>> If we had union types in Swift we could specify `rethrows(E | F)`, which in the case of two `Never` types is `Never | Never` which is simply `Never`. We get rethrows (and implicit propegation by subtyping) for free. Union types have been explicitly rejected for Swift with special emphasis placed on both generic code *and* error propegation.
>>
>> In the specific case of rethrowing implicit propegation to this common supertype and coalescing of a union of `Never` is very useful. It would allow easy propegation, preservation of type information, and coalescing of many `Never`s into a single `Never` enabling the simple defintion of nonthrowing function as those specified to throw `Never` without *needing* to consider functions throwing other uninhabitable types as non-throwing (although that might still be a good idea).
>>
>> Useful as they may be in this case where we are only propegating errors that the caller already depends on, the ease with which this enables preservation of type information encourages propegating excess type information about the errors of dependencies of a function that its callers *do not* already depend on. This increases coupling in a way that should be considered very carefully. Chris Lattner stated in the thread regarding this proposal that one of the reasons he opposes unions is because they make it too easy too introduce this kind of coupling carelessly.
>>
>> _______________________________________________
>> 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>
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.swift.org/pipermail/swift-evolution/attachments/20170224/a9d5ddee/attachment.html>
More information about the swift-evolution
mailing list