On Dec 18, 2015, at 9:25 AM, Dennis Lysenko <dennis.s.lysenko@gmail.com> > wrote:
Genuinely, David, thank you for taking up the mantle of this problem. To
me, the lack of type annotations makes the error handling painful and I
wish the team just hadn't released it until the design was smoothed out
fully. Those dangling catch-all blocks when I've caught all cases make it
uselessly verbose and moreover do not fit with the language at all.
As for the multiple vs. single type annotations, I do think that you need
a concrete example of why this will be different from java to quell the
concerns of all the people that skim the proposal, don't think about it,
and exclaim "but everyone hates it in Java!" so I agree on the singular
type annotations.
I definitely agree that we need to address the Java problem. That said, I
don’t think avoiding knee-jerk objection from people who don’t read and
consider a proposal carefully is a good way to approach design. There may
be good reasons to choose singular type annotations but this is not one of
them.
On Fri, Dec 18, 2015, 8:53 AM Matthew Johnson via swift-evolution < > swift-evolution@swift.org> wrote:
David,
Thank you for taking the time to continue working on a proposal for typed
throws. I agree that this feature is very desirable and appreciate the
work you’re doing to bring forward a proposal. I think it’s a great start
but also has some room for improvement.
First, I think it could be strengthened by incorporating some learning
from Rust. My impression is that the Rust community is very happy with
typed error handling. Adding some detail about their experience would
provide a counter-example to those who are concerned about the experience
in Java and C++.
I agree that error types are an important part of an API contract. One
of the big hurdles to doing this well is the need to catch errors when all
that needs to be done is to wrap and rethrow them. Ideally should not need
to do this just to perform a simple type translation to map the underlying
error into the type we wish to expose as part of a stable API contract.
You might want to take a look at the From mechanism Rust uses to facilitate
this. IMO a proposal for typed error handling should address this issue in
some way (even if the author determines this mechanism is not necessary or
a good design cannot be identified).
I would also like to see much more detail on why you think allowing a
function to throw multiple error types is problematic. My impression is
that you have concerns from a usability point of view. I am on the fence
here to some degree, but definitely leaning in the direction that allowing
a function to throw multiple error types is better.
The primary reason I lean this way is that it enables more re-use of
standard error types. Custom error types for an API often make sense, but
not always. I am concerned about the need to create them just because our
API contract might reasonably include two or three of the standard error
types. Adding new types when they are not necessary introduces complexity
and cognitive overhead. It also complicates catching of errors if the new
custom type is a two or three case enum that just embeds the underlying
error.
These problems will lead many people to just revert to an untyped throws
clause. Objections to typed errors along these lines are common and
legitimate. They will arise during review. It is best if you address them
in the proposal now in order to focus a review on your solutions. My
personal opinion is that allowing multiple error types and including a
mechanism to perform automatic wrapping when appropriate would go a long
way towards solving them.
Implementation challenges related to multi-typed errors have been
discussed on the list quite a bit already. They would obviously need to be
addressed if we go in that direction. I don’t want to downplay those. But
I do think we need to try to identify the most usable solution for typed
errors that we can first and then focus on implementation details. If the
design needs to be modified to accommodate implementation at least we will
have a better idea of what we are giving up.
I am willing to be convinced that a single error type is better than
multiple error types but the current proposal does not provide a compelling
argument in that direction. It just says “Java checked exceptions”. I
know these have been pretty much universally considered a serious design
mistake. My impression is that there are quite a few reasons for that. I
don’t have any direct experience with Java and am not familiar with the
details. If you could elaborate on specifically why you believe allowing
multiple error types was a significant contributor to the problem in a
manner that indicates that they will be a problem in any language that
includes them I would appreciate that. Links would be sufficient if they
are focused on answering this particular question.
I’m looking forward to your feedback on these thoughts.
Thanks,
Matthew
On Dec 18, 2015, at 1:29 AM, David Owens II via swift-evolution < >> swift-evolution@swift.org> wrote:
This a significantly updated proposal for typed annotations on the
`throws` construct. The previous was closed due to not be complete; I
believe I’ve addressed all of those concerns.
https://github.com/owensd/swift-evolution/blob/master/proposals/allow-type-annotations-on-throw.md
—
Allow Type Annotation on Throws
- Proposal: SE-NNNN
- Author(s): David Owens II
- Status: *Pending Approval for Review*
- Review manager: TBD
Introduction
The error handling system within Swift today creates an implicitly loose
contract on the API. While this can be desirable in some cases, it’s
certainly not desired in *all* cases. This proposal looks at modifying
how the error handling mechanism works today by adding the ability to
provide a strong API contract.
Error Handling State of the Union
This document will use the terminology and the premises defined in the Error
Handling Rationale
<https://github.com/apple/swift/blob/master/docs/ErrorHandlingRationale.rst>
document.
To very briefly summarize, there are four basic classification of errors:
1. Simple Domain Errors
2. Recoverable Errors
3. Universal Errors
4. Logic Failures
Each of these types of errors are handled differently at the call sites.
Today, only the first two are directly handled by Swift error handling
mechanism. The second two are uncatchable in Swift (such as fatalError(),
ObjC exceptions, and force-unwrapping of null optionals).
Simple Domain Errors
As stated in Error Handling Rationale
<https://github.com/apple/swift/blob/master/docs/ErrorHandlingRationale.rst> document,
the “Swift way” to handle such errors is to return an Optional<T>.
func parseInt(value: String) -> Int? {}
The simple fact of the result being Optional.None signifies that the
string could not be parsed and converted into an Int. No other
information is necessary or warranted.
Recoverable Errors
In this context, these are errors that need to provide additional
information to the caller. The caller can then decide a course of action
that needs to be taken. This could be any number of things, including, but
not limited to, logging error information, attempting a retry, or
potentially invoking a different code path. All of these errors implement
the ErrorType protocol.
func openFile(filename: String) throws {}
The throws keyword annotates that the function can return additional
error information. The caller must also explicitly make use of this when
invoking the function.
do {
try openFile("path/to/somewhere")
}
catch {}
Errors are able to propagate if called within another context that can
throw, thus alleviating the annoying “catch and rethrow” behavior:
func parent() throws {
try openFile("path/to/somwhere")
}
Lastly, functions can be marked to selectively throw errors if they take
a function parameter that throws with the rethrows keyword. The really
interesting part is that it’s only necessary to use try when calling the
function with a throwing closure.
func openFile(filename: String) throws {}
func say(message: String) {}
func sample(fn: (_: String) throws -> ()) rethrows {
try fn("hi")
}
try sample(openFile)
sample(say)
Converting Recoverable Errors to Domain Errors
Swift also has the try? construct. The notable thing about this
construct is that it allows the caller to turn a “Recoverable Error” into a
“Simple Domain Error”.
if let result = try? openFile("") {}
ErrorType Implementors
Errors are implemented using the ErrorType protocol. Since it is a
protocol, new error types can be a class, a struct, or an enum. A type
qualified throws clause would allow code authors to change the way that
the catch-clauses need to be structured.
Enum Based ErrorType
When enums are used as the throwing mechanism, a generic catch-clause is
still required as the compiler doesn’t have enough information. This leads
to ambiguous code paths.
enum Errors: ErrorType {
case OffBy1
case MutatedValue
}
func f() throws { throw Errors.OffBy1 }
do {
try f()
}
catch Errors.OffBy1 { print("increment by 1") }
catch Errors.MutatedValue { fatalError("data corrupted") }
The above code requires a catch {} clause, but it’s ambiguous what that
case should do. There is no *right* way to handle this error. If the
error is ignored, we’re now in the land of “Logic Errors”; the code path
should never be hit. If we use a fatalError() construct, then we are now
in the land of converting a potential compiler error into a “Universal
Error”.
Both of these are undesirable.
Struct and Class Based ErrorType
In the current design, errors that are thrown require a catch-all all the
time. In the proposed design, which will be explained further, a catch-all
would not be required if there was a case-clause that matched the base type.
class ErrorOne: ErrorType {}
func g() throws { throw ErrorOne() }
do {
try g()
}
catch is ErrorOne { print("ErrorOne") }
The advantage in these cases are different, these cases do not allow
pattern matching over the error type members (as you can in a
switch-statement, for example).
The workaround for this functionality is this:
class ErrorOne: ErrorType {
let value: Int
init(_ value: Int) { self.value = value }
}
do {
try g()
}
catch {
if let e = error as? ErrorOne {
switch e {
case _ where e.value == 0: print("0")
case _ where e.value == 1: print("1")
default: print("nothing")
}
}
}
This proposal would turn the above into:
class ErrorOne: ErrorType {
let value: Int
init(_ value: Int) { self.value = value }
}
do {
try g()
}
catch _ where error.value == 0 { print("0") }
catch _ where error.value == 1 { print("1") }
catch { print("nothing") }
}
No gymnastics to go through, just straight-forward pattern-matching like
you’d expect.
NOTE: This requires the promotion of the error constant to be allowed
through the entirety of the catch-clauses.
Overriding
In the context of types, it’s completely possible to override functions
with the throws annotations. The rules simply follow the rules today:
covariance on the return type is allowed, contravariance is not.
Generics
When looking at generics, I cannot come up with a reason why they
shouldn’t just work as normal:
func gen<SomeError: ErrorType>() throws SomeError {}
The only constraint would be that the specified error type must adhere to
the ErrorType protocol. However, this is no different than today:
func f<T>(a: T) throws { throw a }
This results in the compiler error:
Thrown expression type ’T’ does not conform to ‘ErrorType’
This seems like it should “just work”.
Design Change Proposal
The design change is simple and straight-forward: allow for the
annotation of the type of error that is being returned as an optional
restriction. The default value would still be ErrorType.
func specific() throws MyError {}
func nonspecific() throws {}
There is a secondary result of this proposal: the error constant should
be promoted to be allowed for use through-out all of the catch-clauses.
Impact on Existing Code
This is a non-breaking change. All existing constructs work today without
change. That said, there are a few places where this change will have an
impact on future usage.
Function Declarations
When a function has a throws clause that is attributed with a type, then
that type becomes part of the function signature. This means that these two
functions are not considered to be of the same type:
func one() throws {}
func two() throws NumberError {}
The function signatures are covariant though, so either one or two can
be assigned to f below:
let f: () throws -> ()
This is completely fine as NumberError still implements the ErrorType
protocol.
However, in this case:
let g: () throws NumberError -> ()
It would not be valid to assign one to g as the type signature is more
specific.
throws and rethrows
Functions currently have the ability to be marked as rethrows. This
basically says that if a closure parameter can throw, then the function
will throw too.
func whatever(fn: () throws -> ()) rethrows {}
The whatever function is up for anything that fn is up for. Keeping in
line with this mentality, the rethrows would exhibit the same behavior:
typed annotations simply apply if present and do not if they are missing.
func specific(fn: () throws HappyError -> ()) rethrows {}
This all works as expected:
func f() throws HappyError {}
func g() {}
try specific(f)
specific(g)
This works for the same covariant reason as the non-qualified throws implementation
works: a non-throwing function is always able to be passed in for a
throwing function.
The do-catch statement
There are two rule changes here, but again, it’s non-breaking.
The first rule change is to promote the error constant that would
normally only be allowed in the catch-all clause (no patterns) to be
available throughout each of the catch clauses. This allows for the error
information to be used in pattern matching, which is especially valuable in
the non-enum case.
The second change is to allow the error constant to take on a specific
type when *all* of the throwing functions throw the same specified type.
When this is the case, two things become possible:
1. In the enum-type implementation of ErrorType, the catch-clauses
can now be exhaustive.
2. In the all of the cases, the API of the specific ErrorType becomes
available in the catch-clause without casting the error constant.
This greatly simplifies the pattern-matching process.
In the case that there are heterogenous ErrorType implementations being
returned, the errorconstant simply has the type of ErrorType.
The try call sites
There is no change for the try, try?, or try! uses. The only
clarification I’ll add is that try?is still the appropriate way to
promote an error from a “Recoverable Error” to a “Simple Domain Error”.
Alternate Proposals
There is another common error handling mechanism used in the community
today: Either<L, R>. There are various implementations, but they all
basically boil down to an enum that captures the value or the error
information.
I actually consider my proposal syntactic sugar over this concept. If and
when Swift supports covariant generics, there is not a significant reason I
can see why the underlying implementation could not just be that.
The advantage is that the proposed (and existing) syntax of throws greatly
increases the readability and understanding that this function actually
possesses the ability to throw errors and they should be handled.
The other advantage of this syntax is that it doesn’t require a new
construct to force the usage of the return type.
Further, if functions where to ever gain the ability to be marked as
async, this could now be handled naturally within the compiler as the
return type could a promise-like implementation for those.
Criticisms
From the earlier threads on the swift-evolution mailing list, there are a
few primary points of contention about this proposal.
Aren’t we just creating Java checked-exceptions, which we all know are
terrible?
No. The primary reason is that a function can only return a single
error-type. The other major reason is that the error philosophy is very
different in Swift than in Java.
Aren’t we creating fragile APIs that can cause breaking changes?
Potentially, yes. This depends on how the ABI is handled in Swift 3 for
enums. The same problem exists today, although at a lesser extent, for any
API that returns an enum today.
Chris Lattner mentioned this on the thread:
The resilience model addresses how the public API from a module can
evolve without breaking clients (either at the source level or ABI level).
Notably, we want the ability to be able to add enum cases to something by
default, but also to allow API authors to opt into more
performance/strictness by saying that a public enum is “fragile” or “closed
for evolution”.
So if enums have an attribute that allows API authors to denote the
fragility enums, then this can be handled via that route.
Another potential fix is that *only* internal and private scoped
functions are allowed to use the exhaustive-style catch-clauses. For all
public APIs, they would still need the catch-all clauses.
For APIs that return non-enum based ErrorType implementations, then no,
this does not contribute to the fragility problem.
Aren’t we creating the need for wrapper errors?
This is a philosophical debate. I’ll simply state that I believe that
simply re-throwing an error, say some type of IO error, from your API that
is not an IO-based API is design flaw: you are exposing implementation
details to users. This creates a fragile API surface.
Also, since the type annotation is opt-in, I feel like this is a really
minor argument. If your function is really able to throw errors from
various different API calls, then just stick with the default ErrorType.
_______________________________________________
swift-evolution mailing list
swift-evolution@swift.org
https://lists.swift.org/mailman/listinfo/swift-evolution
_______________________________________________
swift-evolution mailing list
swift-evolution@swift.org
https://lists.swift.org/mailman/listinfo/swift-evolution