Exceptions without polymorphic effects #1261
Replies: 4 comments 20 replies
-
Function arguments aren't the only place where throw effects can (in principle) become polymorphic. For example: protocol X {
foo() throws
bar() throws
}
struct A: X {
foo() /* throws */ { ... }
bar() /* throws */ { ... }
}
f<T: X>(b: X) rethrows(if T:X throws) { ... } // mythical syntax
f(A()) // nonthrowing I'm not saying we should cover this kind of case, but if we are not going to, we should understand why we're not doing it! |
Beta Was this translation helpful? Give feedback.
-
I must admit that although I have also had a long-held preference for a world where functions are non-throwing by default, I'm beginning to question whether that makes sense for Hylo. There are two reasons:
What's left are those functions used in error recovery, which must be non-throwing. |
Beta Was this translation helpful? Give feedback.
-
We should talk about how/if this technique generalizes to If the idiom is “write safe components using unsafe constructs,” it seems to me,
doesn't need to be
(this is what happens with So what kind of effect threading is really needed here? |
Beta Was this translation helpful? Give feedback.
-
Isn't it true that any function with parameters has to assume it received an argument containing a throw capability (unless it can see all the details of the type and there is no type-erasure, e.g. an existential, involved)? |
Beta Was this translation helpful? Give feedback.
-
I'm opening this discussion to talk about the design of exceptions we'd like for Hylo.
There are two things I'd like to achieve ideally, none of which is properly addressed by Swift:
Note that I don't consider avoiding function coloring a goal. That is because if we opt for a world where functions are non-throwing by default (and I currently have a strong bias toward such a world), then we need a way to stop the propagation of exceptions and satisfy the contract of a non-throwing function anyway. So we may as well require a
try-catch
statement to call a throwing function.If we take Swift as a starting point, none of my goals are properly addressed. That's because Swift wants a
try
before every throwing outermost expression and because the ability to throw is an effect that has to appear in the API of every maximally generic higher-order function. The first issue is easy to lift, though: just allow any throwing function to be called without ceremony from another throwing function. The second is more tricky but arguably even more important. We'll haveunsafe
as another effect and I would be very sad if all maximally generic higher-functions had to be written<E>([E](T) throws unsafe -> U) rethrows reunsafe -> V
.One thing I'd like to try is to encode the capability to throw as an object rather than an effect, which is an idea coming from research around Scala. Here is how the basics would look in Hylo:
An instance of
Throws
represents the capability to throw an exception. Here, bothrisky_business
andeven_riskier_business
are throwing because they take such a capability as parameter. We can call the latter from the former without ceremony because we already know thatrisky_business
is throwing. However, we need atry-catch
statement inmain
, which is not throwing.The question marks after the names of some parameters indicate that they are implicit, saving us from having to pass
throws
explicitly to every function. Implicit parameters are necessary to alleviate the syntax burden of capabilities are object (and that's why they've been implemented). Their syntax is still subject to bikeshedding, but that is orthogonal to this discussion.To guarantee that we cannot call
throw
outside of atry-catch
statement, we must make sure that instances ofThrows
can only be construced in a try-block and can never escape it. Fortunately our type system can do that. We only need to makeThrows
a non-copyable type with no constructor and have the try-catch statement "project" it the try-block. Another way to think about this setup is to imagine that thetry-catch
statement is implemented by the following function:Since
throws
is alet
parameter that isn't copyable, it cannot escape a call toaction
.The advantage of passing
throws
as a parameter instead of using an effect is that we can no longer need polymorphic control effects on higher order functions. For example, considerCollection.map
:We don't have to make any change to this function to let it accept throwing and non-throwing function (or even unsafe functions!) That is because the ability to throw can be captured in the environment of the lambda passed to transform. For example:
In the call
things.map((x) => risky_business(x))
, the lambda is implicitly capturingthrows
, which is an implicit value available in the try-block. The type of the lambda is[remote let Throws](Int) -> Int
, meaning that it is throwing, and we can happily specializeCollection.map
for[Self: Array<Int>, E: remote let Throws, T: Int]
.[Note: Using eta-expansion we could even write
things.map(risky_business)
.]The next challenge is to compile this thing! Here I think there are two main ways we can approach the problem. The first is to say that every function is in fact allowed to throw under the covers. Then we could implement transfer of control back to the caller the same way a C++ implementation does. I'm sure other people know waaaaay better than me about how that works, so I'll let them jump in if necessary.
The second approach would be to translate a throwing function into one that returns either a result or an error. In other words, we would compile
fun f(_ x: T, throws?: Throws) -> U
as though it has been writtenfun f(_ x: T) -> Union<U, any Error>
, whereany Error
is an existential containing th thrown error.The problem with this strategy is that in general a higher-order function has to assume that it received a throwing function, and therefore it must be compiled as though it was throwing. That means a lot of functions would have
Union<U, any Error>
in their ABI, just in case they are called with a throwing function.The proliferation of these
Union
results is a little sad but note that it would only be necessary for higher-order functions. In a world where functions are non-throwing by default, we can know for a fact that(Int) -> Int
never throws. Further, allocatingUnion<U, any Error>
instead of justU
is probably not that bad, but maybe I'm too naive on this point.Finally, in places where we can monomorphize everything, we would be be able to conclude that a higher-order function is in fact not throwing. That is because once we've determined that
Throws
doesn't occur in the parameters or environment of a lambda, then that lambda can't possibly throw. More generally, we can determine whether a lambda can throw if its type doesn't have any existentials or generic parameters.Beta Was this translation helpful? Give feedback.
All reactions