[swift-evolution] [Proposal Idea] catching functions for composable and cps error handling
Brent Royal-Gordon
brent at architechies.com
Thu Dec 17 09:09:03 CST 2015
> I am definitely interested in seeing your proposal if you’ve already written it.
[Proposal follows.]
Swift 2 codified error parameters, turning them into a language feature with dedicated syntax. I think most people are pretty happy with that. Everyone wants to do the same for async APIs, replacing the pyramids of nested completion handlers with something more sensible. But this is a difficult problem and will take time to figure out.
In the mean time, though, I think we can improve tremendously on completion handlers by codifying them and giving them dedicated syntax. And it won’t really change our eventual pyramid-of-doom replacement. There are hundreds of existing APIs which use completion handlers; whatever we come up with in the future will have to be compatible with them, and therefore, with this proposal.
My proposal comes in three parts.
CATCHING FUNCTIONS
----------------------------------
A function type can be marked as `catching`.
catching T -> U
A catching function or closure has one or more `catch` blocks appended to it.
func foo() {
// Normal, non-error behavior
}
catch {
// Handle errors thrown to this function
// Possible point of confusion: errors thrown *in* the main body of foo() do *not* go here.
}
cloudKitQueryOp.queryCompletionBlock = { cursor in
// non-error behavior
}
catch CKErrorCode.NotAuthenticated {
nagUserToEnableCloud()
}
catch CKErrorCode.RequestRateLimited {
retryLater()
}
catch {
displayCloudError(error)
}
Catching functions must be exhaustive (i.e. must include a plain `catch` block), with two exceptions to be described later.
Catch blocks have the same return type as the regular block of their function. For instance, the catch blocks of a `catching Void -> Int` must return `Int`s. If the function is marked `throws`, then the catch blocks can `throw`.
func convertOnlyCloudErrors() throws -> String {
return “OK”
}
catch let error as CKErrorCode {
return error.description
}
catch {
throw error
}
Here’s the first exception to catch exhaustiveness: a `catching throws` function has an implicit `catch { throw error }` block added if necessary. So that second catch block in the previous example is redundant.
To call the function normally, just…call it normally.
foo()
cloudKitQueryOp.queryCompletionBlock!(cursor)
To send an error to it, use one of these:
foo(throw error)
foo(try someOperationThatMightFail()) // unconditionally calls foo() with the result, whether error or datum
foo(try? someOperationThatMightFail()) // if an error is thrown, calls foo with the error and returns nil; if not, returns the result of the operation without calling foo
I’m not totally satisfied with this syntax, and we should probably try to come up with something better. BUT NOT RIGHT NOW. Bikeshedding can wait.
One issue with this is the question of whether a `throw` or non-optional `try` with a catching function implicitly `return`s immediately after calling the catching function. My current thinking is that it does *not* cause an immediate return, so you can come up with a return value yourself. But this is not entirely satisfying.
To Objective-C, a catching function is a block with the same parameters, only nullable, followed by a nullable NSError. If there are no parameters, then an initial BOOL parameter is inserted.
@property (nonatomic, copy, nullable) void (^queryCompletionBlock)(CKQueryCursor * __nullable cursor, NSError * __nullable operationError);
var queryCompletionBlock: (catching CKQueryCursor -> Void)?
ONCE ATTRIBUTE
--------------------------
This one is not strictly necessary, but it's very helpful for safety.
The `@once` attribute on a function-typed parameter indicates that the parameter will be called exactly once. Not zero times, not two times. Thrice is right out. Throwing to a catching function counts as “calling” it.
func tooMany(@once fn: Void -> Void) {
fn()
fn()
}
func tooFew(@once fn: Void -> Void) {
}
func justRight(@once fn: Void -> Void) {
fn()
}
@once makes no guarantees about *when* the parameter will be called; it does not imply @noescape. It may be called asynchronously, after the function it’s passed to has finished executing. But it will still be called exactly once.
To the extent possible, swiftc should try to validate that the parameter really is called exactly once. Obviously, this won’t be possible if the parameter is called in a closure, unless that closure is passed to a parameter which is itself marked @once.
Note: It may be desirable to apply @once to function-typed properties as well—NSOperation and CloudKit’s NSOperation subclasses could certainly use it.
ASYNC FUNCTIONS
-----------------------------
A function may be marked `async`. This keyword goes after the argument list, but before `throws` or the return type arrow. An `async` function is simply one that takes a completion handler. So these two are equivalent:
func fetch() async
func fetch(@once completion: Void -> Void)
You can probably say `async(bar)` to rename the completion argument to `bar`, and `async(bar baz)` to name the variable `baz` and the keyword `bar`. Whatever—it’s not very important.
The `async` keyword is magical in that it hijacks the function’s return type, turning it into an argument to the completion. So these two are equivalent:
func fetch() async -> MyRecord
func fetch(@once completion: MyRecord -> Void)
It also hijacks the `throws` specifier, turning it into a `catching` specifier on the completion. So these two are equivalent:
func delete() async throws
func delete(@once completion: catching Void -> Void)
As are these:
func fetch() async throws -> MyRecord
func fetch(@once completion: catching MyRecord -> Void)
Inside the body of an `async throws` function, all errors thrown with `try` or `throw`, and otherwise not caught by anything, are implicitly directed to `completion`.
func fetch() async throws -> MyRecord {
guard let recordURL = recordURL else {
throw MyRecordError.NoRecordURL // this is really `completion(throw MyRecordError.NoRecordURL); return`
}
fetchURL(recordURL) { data in
let record = try MyRecord(data: data) // this is really something like `let record = completion(try? MyRecord(data: data)) ?? return`
completion(record)
}
catch {
throw error // this is also really `completion(throw error); return`
}
}
Note that this occurs even in the main body of the async function. That means the completion may be called before the async function returns.
Remember how I said there was a second exception to the rule that `catching` functions must have an exhaustive set of `catch` clauses? That exception is inside an `async throws` function. There, they automatically have a `catch { completion(throw error) }` clause added. To indicate this hidden `throw`, however, you have to add `try` to the statement that creates the closure. So the above could have been written:
func fetch() async throws -> MyRecord {
try fetchURL(recordURL) { data in
let record = try MyRecord(data: data)
completion(record)
}
// Look, Ma, no catch!
}
At the site where an `async` function is called, absolutely nothing changes. It is exactly equivalent to the completion-block form.
Objective-C APIs with a trailing block parameter labeled `completion` or `completionHandler` should have `@once` implicitly added to this parameter, allowing them to be treated as `async` methods.
CONCLUSION
--------------------
This proposal radically improves the experience of creating an asynchronous call by formalizing the notion of a function which takes a result-or-error, hugely reducing the amount of boilerplate involved in defining an async function, and introducing new safety checks to help you ensure you return data properly from an async function. It even improves the call site modestly by better organizing error-handling code. And it doesn’t interfere with any future work on new ways of representing calls to asynchronous functions.
--
Brent Royal-Gordon
Architechies
More information about the swift-evolution
mailing list