Try-Catch-Throws Helps Readability
Swift’s error-related syntax calls attention to possible errors through
try
and throws
. The do
/catch
syntax clearly separates the happy path (no errors) from the sad path (errors):func exampleSyncUsageOfThrows() -> Bool {
do {
/* happy path */
let cookie = try ezbake()
eat(cookie)
return true
} catch {
/* sad path */
return false
}
}
Because throws
is “viral”,
you’re forced to address it one way or another, even if it’s by deciding
to flip your lid when you hit an error by using the exploding try!
.No Async Syntax
Swift’s error-related syntax is great when every line of code
executes one after another, synchronously. But it all goes to heck when
you want to pause between steps to wait for an external event, like a
timer finishing or a web server getting back to you with a response, or
anything else happening asynchronously.
The Cocoa Completion Callback Pattern: Everything Is Optional
Let’s try that example again, only scheduling the cookie-baking for later, and then waiting for the cookie to cool before scarfing it:func exampleAsyncDoesNotPlayNiceWithThrows(completion hadDinner: @escaping (Bool) -> Void) {
ezbakeTomorrow { cookie, error in
// hope you don't forget to check for an error first!
// also hope you like optional unwrapping
guard error == nil, let cookie = cookie else {
return hadDinner(false)
}
wait(tillCool: cookie) { coolCookie, error in
guard error == nil, let coolCookie = coolCookie else {
// dog snarfed cookie?
return hadDinner(false)
}
eat(coolCookie)
hadDinner(true)
}
}
}
This approach of calling a completion closure with parameters for
both the desired result and the failure explanation all marked optional
is common across Cocoa APIs as well as third-party code. Correctly
unpacking those arguments relies heavily on convention. That is to say,
it relies heavily on you being very careful not to shoot yourself in the
foot.
Everything Is Optional?!
Because both the success value (
cookie
) and the failure value (error
)
might not be present, both end up being optionals. That means you end
up with four cases to consider, of which two should probably never
happen:- Success!
cookie
but noerror
. This is unambiguous. - Failure.
error
but nocookie
. This is similarly unambiguous. - Kind of a failure, I think? Like, maybe? Both
error
ANDcookie
. If you’re following classic Cocoa style, this gets lumped in with the success case, so that a successful run could, before ARC, leaveerror
pointing at fabulously uninitialized data or scratch errors that didn’t happen. (As you might imagine, that convention gets messed up pretty often.) - Super-duper extra-failure. Neither
error
norcookie
. This is probably a bug in whatever’s giving you this output. But, alas, you still have to deal with it as a possibility.
Result: Better Async Support Through Types
Result
is a popular enumeration for cleaning this up. It looks something like:enum Result<Value> {
case success(Value)
case failure(Error)
}
This addresses all the weirdness with the conventional approach:
- It explicitly has only two cases, so you don’t have to waste time considering the two “these are probably a bug” cases.
- You can’t mess up the convention and shoot yourself in the foot. You can only get access to either the failure or the success case by design.
- You can’t just ignore the error case and accidentally code for just the happy path.
case
exhaustiveness ensures the error is on your radar.
do
/catch
lets you clearly separate handling a successful result from a failure, so does Result through switch
/case
:func exampleAsyncLikesResult(completion hadDinner: @escaping (Bool) -> Void) {
ezbakeTomorrow { result in
switch result {
case let .success(cookie):
wait(tillCool: cookie) { result in
switch result {
// look ma, no optionals!
case let .success(coolCookie):
eat(coolCookie)
hadDinner(true)
case let .failure(_):
hadDinner(false)
}
}
case let .failure(_):
hadDinner(false)
}
}
}
Result: Sync, Async, It Just Works?
Result achieves the aims ofdo/catch
/throw
for async code. But it can also be used for sync code. This leads to
competition between Result and throws for the synchronous case:func exampleSyncUsageofResult() {
return
ezbake()
.map({ eat($0) })
.isSuccess
}
That’s…not so pretty. It would get even uglier if there was a sequence of possibly failing steps:
// this mess…
func exampleUglierSyncResult() {
return
open("some file")
.flatMap({ write("some text", to: $0) })
.map({ print("success!"); return $0 })
.flatMap({ close($0) })
.isSuccess
}
// …translates directly to this less-mess
func exampleSyncIsLessUglyWithTry() {
do {
let file = try open("some file")
let stillAFile = try write("some text", to: file)
print("success!")
try close(stillAFile)
return true
} catch {
return false
}
}
It’s kind of easy to lose the flow in all that syntax, plus it sounds like you have a funky verbal tic with the repeated
map
and flatMap
. You also have to keep deciding between (and distracting your reader with the distinction between) map
and flatMap
.Leave Result for Async, Switch to Throws When Sync
That suggests a rule of thumb: stick with
First, here’s a mechanical translation of the earlier throws
for synchronous code. Applying that even to mixed sync (within the body
of completion callbacks) and async (did I mention completion
callbacks?) code allows to play to the strengths of both throws
and Result
.exampleAsyncLikesResult
function:func exampleMechanicallyBridgingBetweenAsyncAndSync(completion hadDinner: @escaping (Bool) -> Void) {
ezbakeTomorrow { result in
do {
let cookie = try result.unwrap()
wait(tillCool: cookie) { result in
do {
let coolCookie = try result.unwrap()
eat(coolCookie)
hadDinner(true)
} catch {
hadDinner(false)
}
}
} catch {
hadDinner(false)
}
}
}
Each completion accepts a
Result
, but in working with it, it immediately returns to using the Swift try/throw/do/catch syntax.try
has a try?
variant that allows to clean this up even more nicely. This is more
like the code you’d likely write in the first place when using this
style:func exampleNicerBridgingBetweenAsyncAndSync(completion hadDinner: @escaping (Bool) -> Void) {
ezbakeTomorrow { result in
guard let cookie = try? result.unwrap()
else { return hadDinner(false) }
wait(tillCool: cookie) { result in
guard let coolCookie = try? result.unwrap
else { return hadDinner(false) }
eat(coolCookie)
hadDinner(true)
}
}
}
Bridging Helpers
This relies on some simple helper functions to bridge betweenResult
and throws
.-
Result.unwrap() throws
goes fromResult
tothrows
: The caller of an async method that delivers aResult
can then useresult.unwrap()
to bridge back fromResult
into something you cantry
andcatch
.unwrap()
is a throwing function that throws if it’s.failure
and otherwise just returns its.success
value. We saw plenty of examples earlier.
-
static Result.of(trying:)
goes fromthrows
toResult
: The implementation of async methods can useResult.of(trying:)
to wrap up the result of running a throwing closure as aResult
; this helper runs its throwing closure and stuffs any caught error in.failure
, and otherwise, wraps the result up in.success
.
This is used to implement async functions delivering a result. Since the running example delivered a Boolean, you haven’t seen this used yet. Here’s a small example:
func youComplete(me completion: @escaping (Result<MissingPiece>) -> Void) {
doSomethingAsync { boxOfPieces: Result<PieceBox> in
let result = Result.of {
let box = try boxOfPieces.unwrap()
let piece = try findMissingPiece(in: box)
return piece
}
completion(result)
}
}
That’s a Wrap
So that’s the bottom line:- Use
Result
as your completion callback argument. - Within the completion body (and anywhere else you’re working synchronously), use
do
/catch
to work with potential errors.
No comments: