Swift:Developing iOS Applications
上QQ阅读APP看书,第一时间看更新

Chapter 8. Paths Less Traveled – Error Handling

One of the biggest changes in Swift 2 is that Apple added a feature called error handling. Handling error situations is often the least fun part of programming. It is usually much more exciting to handle a successful case, often referred to as the happy path because that is where the exciting functionality is. However, to make a truly great user experience and therefore a truly great piece of software, we must pay careful attention to what our software does when errors occur. The error-handling features of Swift help us in handling these situations succinctly and discourage us from ignoring errors in the first place.

In this chapter, we will discuss exactly what error-handling features Swift has and how they help us to write better software. We will do so by covering the following topics:

  • Throwing errors
  • Handling errors
  • Cleaning up in error situations

Throwing errors

Before we talk about handling an error, we need to discuss how we can signal that an error has occurred in the first place. The term for this is throwing an error.

Defining an error type

The first part of throwing an error is defining an error that we can throw. Any type can be thrown as an error as long as it implements the ErrorType protocol, as shown:

struct SimpleError: ErrorType {}

This protocol doesn't have any requirements, so the type just needs to list it as a protocol it implements. It is now ready to be thrown from a function or method.

Defining a function that throws an error

Let's define a function that will take a string and repeat it until it is at least a certain length. This will be very simple to implement but there will be a problem scenario. If the passed in string is empty, it will never become longer, no matter how many times we repeat it. In this scenario, we should throw an error.

Any function or method can throw an error as long as it is marked with the throws keyword, as shown in the following code:

func repeatString(
    string: String,
    untilLongerThan: Int
    ) throws -> String
{
    // TODO: Implement
}

The throws keyword always comes after the parameters and before a return type.

Implementing a function that throws an error

Now, we can test if the passed in string is empty and throw an error if it is. To do this, we use the throw keyword with an instance of our error:

func repeatString(
    string: String,
    untilLongerThan: Int
    ) throws -> String
{
    if string.isEmpty {
        throw SimpleError()
    }

    var output = string
    while output.characters.count <= untilLongerThan {
        output += string
    }
    return output
}

An important thing to note here is that when we throw an error, it immediately exits the function. In the preceding case, if the string is empty, it goes to the throw line and then it does not execute the rest of the function. In this case, it is often more appropriate to use a guard statement instead of a simple if statement, as shown in the following code:

func repeatString(
    string: String,
    untilLongerThan: Int
    ) throws -> String
{
    guard !string.isEmpty else {
        throw SimpleError()
    }

    var output = string
    while output.characters.count < untilLongerThan {
        output += string
    }
    return output
}

Ultimately this doesn't act any differently from the previous implementation, but it reiterates that the rest of the function will not be executed if it fails the condition. We are now ready to try to use the function.

Handling errors

If we try to call a function, such as normal, Swift is going to give us an error, as shown in the following example:

let repeated1 = repeatString("Hello", untilLongerThan: 20)
// Error: Call can throw but is not market with 'try'

To eliminate this error, we must add the try keyword before the call. However, before we move forward, I would recommend that you wrap all of your code inside a function, if you are following along in a playground. This is because throwing errors at the root level of a playground will not be handled properly and may even cause the playground to stop working. To wrap your code in a function, you can simply add the following code:

func main() {
// The rest of your playground code
}
main()

This defines a function called main that contains all the normal playground code that is called once, at the end of the playground.

Now, let's get back to using the try keyword. There are actually three forms of it: try, try?, and try!. Let's start by discussing the exclamation point form, as it is the simplest form.

Forceful try

The try! keyword is called the forceful try. The error will completely go away if you use it, by using the following code:

let repeated2 = try! repeatString("Hello", untilLongerThan: 20)
print(repeated2) // "HelloHelloHelloHello"

The drawback of this approach might be intuitive, based on the exclamation point and what it has meant in the past. Just like with forced unwrapping and forced casting, an exclamation point is a sign that there will be a scenario which will crash the entire program. In this case, the crash will be caused if an error is thrown from the function. There may be times when you can really assert that an error will never be thrown from a call to a throwing function or method, but in general this isn't an advisable solution, considering the fact that we are trying to gracefully handle our error situations.

Optional try

We can also use the try? keyword, which is referred to as an optional try. Instead of allowing for the possibility of a crash, this will turn the result of the function into an optional:

let repeated3 = try? repeatString("Hello", untilLongerThan: 20)
print(repeated3) // Optional("HelloHelloHelloHello")

The advantage here is that if the function throws an error, repeated3 will simply be set to nil. However, there are a couple strange scenarios with this. First, if the function already returns an optional, the result will be converted to an optional of an optional:

func aFailableOptional() throws -> String? {
    return "Hello"
}
print(try? aFailableOptional()) // Optional(Optional("Hello"))

This means that you will have to unwrap the optional twice in order to get to the real value. The outer optional will be nil if an error is thrown and the inner optional will be nil if the method returned nil.

The other strange scenario is if the function doesn't return anything at all. In this case, using an optional try will create an optional void, as shown:

func aFailableVoid() throws {
    print("Hello")
}
print(try? aFailableVoid()) // Optional(())

You can check the result for nil to determine if an error was thrown.

The biggest drawback to this technique is that there is no way to determine the reason an error was thrown. This isn't a problem for our repeatString:untilLongerThan: function because there is only one error scenario, but we will often have functions or methods that can fail in multiple ways. Especially, if these are called based on user input, we will want to be able to report to the user exactly why an error occurred.

To allow us to get more precise information on the reason for an error, we can use the final keyword, which is simply try.

Catching an error

To get an idea of the usefulness of catching an error, let's look at writing a new function that will create a list of random numbers. Our function will allow the user to configure how long the list should be and also what the range of possible random numbers should be.

The idea behind catching an error is that you get a chance to look at the error that was thrown. With our current error type, this wouldn't be terribly useful because there is no way to create different types of errors. A great option to fix this is to use an enumeration that implements the ErrorType protocol:

enum RandomListError: ErrorType {
    case NegativeListLength
    case FirstNumberMustBeLower
}

This enumeration has a case for both the errors which we will want to throw, so now we are ready to implement our function:

func createRandomListContaininingXNumbers(
    xNumbers: Int,
    between low: Int,
    and high: Int
    ) throws -> [Int]
{
    guard xNumbers >= 0 else {
        throw RandomListError.NegativeListLength
    }
    guard low < high else {
        throw RandomListError.FirstNumberMustBeLower
    }

    var output = [Int]()
    for _ in 0 ..< xNumbers {
        let rangeSize = high - low + 1
        let betweenZero = Int(rand()) % rangeSize
        let number = betweenZero + low
        output.append(number)
    }
    return output
}

This function begins by checking the error scenarios. It first checks to make sure that we are not trying to create a list of negative length. It then checks to make sure that the high value of the range is in fact greater than the low one. After that, we repeatedly add a random number to the output array for the requested number of times.

Note that this implementation uses the rand function, which we used in Chapter 2, Building Blocks – Variables, Collections, and Flow Control. To use it, you will need to import Foundation and also seed the random number with srand again.

Also, this use of random is a bit more complicated. Previously, we only needed to make sure that the random number was between zero and the length of our array; now, we need it to be between two arbitrary numbers. First, we determine the amount of different numbers we can generate, which is the difference between the high and low number plus one, because we want to include the high number. Then, we generate the random number within that range and finally, shift it to the actual range we want by adding the low number to the result. To make sure this works, let's think through a simple scenario. Lets say we want to generate a number between 4 and 10. The range size here will be 10 - 4 + 1 = 7, so we will be generating random numbers between 0 and 6. Then, when we add 4 to it, it will move that range to be between 4 and 10.

So, we now have a function that throws a couple of types of errors. If we want to catch the errors, we have to embed the call inside a do block and also add the try keyword:

do {
    try createRandomListContaininingXNumbers(
        5,
        between: 5,
        and: 10
    )
}

However, if we put this into a playground, within the main function, we will still get an error that the errors thrown from here are not handled. This will not produce an error if you put it at the root level of the playground because the playground will handle any error thrown by default. To handle them within a function, we need to add catch blocks. A catch block works the same as a switch case, just as if the switch were being performed on the error:

do {
    try createRandomListContaininingXNumbers(
        5,
        between: 5,
        and: 10
    )
}
catch RandomListError.NegativeListLength {
    print("Cannot create with a negative number of elements")
}
catch RandomListError.FirstNumberMustBeLower {
    print("First number must be lower than second number")
}

A catch block is defined with the keyword catch followed by the case description and then curly brackets that contain the code to be run for that case. Each catch block acts as a separate switch case. In our preceding example, we have defined two different catch blocks: one for each of the errors where we print out a user-understandable message.

However, if we add this to our playground, we still get an error that all errors are not handled because the enclosing catch is not exhaustive. That is because catch blocks are just like switches in that they have to cover every possible case. There is no way to say if our function can only throw random list errors, so we need to add a final catch block that handles any other errors:

do {
    try createRandomListContaininingXNumbers(
        5,
        between: 5,
        and: 10
    )
}
catch RandomListError.NegativeListLength {
    print("Cannot create with a negative number of elements")
}
catch RandomListError.FirstNumberMustBeLower {
    print("First number must be lower than second number")
}
catch let error {
    print("Unknown error: \(error)")
}

The last catch block stores the error into a variable that is just of type ErrorType. All we can really do with that type is print it out. With our current implementation this will never be called, but it is possible that it will be called if we add a different error to our function later and forget to add a new catch block.

Note that currently there is no way to specify what type of error can be thrown from a specific function; so with this implementation there is no way for the compiler to ensure that we are covering every case of our error enumeration. We could instead perform a switch within a catch block, so that the compiler will at least force us to handle every case:

do {
    try createRandomListContaininingXNumbers(
        5,
        between: 5,
        and: 10
    )
}
catch let error as RandomListError {
    switch error {
    case .NegativeListLength:
        print("Cannot create with a negative number of elements")
    case .FirstNumberMustBeLower:
        print("First number must be lower than second number")
    }
}
catch let error {
    print("Unknown error: \(error)")
}

This technique will not cause the compiler to give us an error if we throw a completely different type of error from our function, but it will at least give us an error if we add a new case to our enumeration.

Another technique that we can use would be to define an error type that includes a description that should be displayed to a user:

struct UserError: ErrorType {
    let userReadableDescription: String
    init(_ description: String) {
        self.userReadableDescription = description
    }
}

func createRandomListContaininingXNumbers2(
    xNumbers: Int,
    between low: Int,
    and high: Int
    ) throws -> [Int]
{
    guard xNumbers >= 0 else {
        throw UserError(
            "Cannot create with a negative number of elements"
        )
    }
    
    guard low < high else {
        throw UserError(
            "First number must be lower than second number"
        )
    }

    // ...
}

Instead of throwing enumeration cases, we are creating instances of the UserError type with a text description of the problem. Now, when we call the function, we can just catch the error as a UserError type and print out the value of its userReadableDescription property:

do {
    try createRandomListContaininingXNumbers2(
        5,
        between: 5,
        and: 10
    )
}
catch let error as UserError {
    print(error.userReadableDescription)
}
catch let error {
    print("Unknown error: \(error)")
}

This is a pretty attractive technique but it has its own drawback. This doesn't allow us to easily run certain code if a certain error occurs. This isn't important in a scenario where we are just reporting the error to the user, but it is very important for scenarios where we might more intelligently handle errors. For example, if we have an app that uploads information to the Internet, we will often run into Internet connection problems. Instead of just telling the user to try again later, we can save the information locally and automatically try to upload it again later without having to bother the user. However, Internet connectivity won't be the only reason an upload might fail. In other error circumstances, we will probably want to do something else.

A more robust solution might be to create a combination of both of these techniques. We can start by defining a protocol for errors that can be reported directly to the user:

protocol UserErrorType: ErrorType {
    var userReadableDescription: String {get}
}

Now we can create an enumeration for our specific errors that implements that protocol:

enum RandomListError: String, UserErrorType {
    case NegativeListLength =
        "Cannot create with a negative number of elements"
    case FirstNumberMustBeLower =
        "First number must be lower than second number"

    var userReadableDescription: String {
        return self.rawValue
    }
}

This enumeration is set up to have a raw type that is a string. This allows us to write a simpler implementation of the userReadableDescription property that just returns the raw value.

With this, our implementation of the function looks the same as earlier:

func createRandomListContaininingXNumbers3(
    xNumbers: Int,
    between low: Int,
    and high: Int
    ) throws -> [Int]
{
    guard xNumbers >= 0 else {
        throw RandomListError.NegativeListLength
    }
    guard low < high else {
        throw RandomListError.FirstNumberMustBeLower
    }

    // ...
}

However, our error handling can now be more advanced. We can always just catch any UserErrorType and display it to the user, but we can also catch a specific enumeration case if we want to do something special in this scenario:

do {
    try createRandomListContaininingXNumbers3(
        5,
        between: 5,
        and: 10
    )
}
catch RandomListError.NegativeListLength {
    // Do something else
}
catch let error as UserErrorType {
    print(error.userReadableDescription)
}
catch let error {
    print("Unknown error: \(error)")
}

Keep in mind that the order of our catch blocks is very important, just like the order of switch cases is important. If we put our UserErrorType block before the NegativeListLength block, we would always just report it to the user, because once a catch block is satisfied, the program will skip every remaining block.

This is a pretty heavy handed solution; so, you may want to use a simpler solution at times. You may even come up with your own solutions in the future, but this gives you some options to play around with.

Propagating errors

The last option for handling an error is to allow it to propagate. This is only possible when the containing function or method is also marked as throwing errors, but it is simple to implement if that is true:

func parentFunction() throws {
    try createRandomListContaininingXNumbers3(
        5,
        between: 5,
        and: 10
    )
}

In this case, the try call does not have to be wrapped in a do-catch, because all errors thrown by createRandomListContainingXNumbers:between:and: will be rethrown by parentFunction. In fact, you can still use a do-catch block, but the catch cases no longer need to be exhaustive, because any errors not caught will simply be rethrown. This allows you to only catch the errors relevant to you.

However, while this can be a useful technique, I would be careful not to do it too much. The earlier you handle the error situations, the simpler your code can be. Every possible error thrown is like adding a new road to a highway system; it becomes harder to determine where someone took a wrong turn if they are going the wrong way. The earlier we handle errors, the fewer chances we have to create additional code paths in the parent functions.

Cleaning up in error situations

So far, we have not had to be too concerned about what happens in a function after we throw an error. There are times when we will need to perform a certain action before exiting a function, regardless of if we threw an error or not.

Order of execution when errors occur

An important part to remember about throwing errors is that the execution of the current scope exits. This is easy to think about for functions if you think of it as just a call to return. Any code after the throw will not be executed. It is a little less intuitive within do-catch blocks. A do-catch can have multiple calls to functions that may throw errors, but as soon as a function throws an error, the execution will jump to the first catch block that matches the error:

do {
    try function1()
    try function2()
    try function3()
}
catch {
    print("Error")
}

Here, if function1 throws an error, function2 and function3 will not be called. If function1 does not throw but function2 does, then only function3 will not be called. Also note that we can prevent that skipping behavior using either of the two other try keywords:

do {
    try! function1()
    try? function2()
    try function3()
}
catch {
    print("Error")
}

Now if function1 throws an error, the whole program will crash and if function2 throws an error, it will just continue right on with executing function3.

Deferring execution

Now, as I hinted before, there will be circumstances where we need to perform some action before exiting a function or method regardless of if we throw an error or not. You could potentially put that functionality into a function which is called before throwing each error, but Swift provides a better way called a defer block. A defer block simply allows you to give some code to be run right before exiting the function or method. Let's take a look at an example of a personal chef type that must always clean up after attempting to cook some food:

struct PersonalChef {
    func clean() {
        print("Wash dishes")
        print("Clean counters")
    }

    func addIngredients() throws {}
    func bringToBoil() throws {}
    func removeFromHeat() throws {}
    func allowItToSit() throws {}

    func makeCrèmeBrûlée(URL: NSURL) throws {
        defer {
            self.clean()
        }

        try self.addIngredients()
        try self.bringToBoil()
        try self.removeFromHeat()
        try self.allowItToSit()
    }
}

In the make crème brûlée method, we start out with a defer block that calls the clean method. This is not executed right away; it's executed immediately after an error is thrown or immediately before the method exits. This ensures that no matter how the making of the crème brûlée goes, the personal chef will still clean up after itself.

In fact, defer even works when returning from a function or method at any point:

struct Ingredient {
    let name: String
}

struct Pantry {
    private let ingredients: [Ingredient]

    func openDoor() {}
    func closeDoor() {}

    func getIngredientNamed(name: String) -> Ingredient? {
        self.openDoor()

        defer {
            self.closeDoor()
        }

        for ingredient in self.ingredients {
            if ingredient.name == name {
                return ingredient
            }
        }
        return nil
    }
}

Here, we have defined a small ingredient type and a pantry type. The pantry has a list of ingredients and a method to help us get an ingredient out of it. When we go to get an ingredient, we first have to open the door, so we need to make sure that we close the door at the end, whether or not we find an ingredient. This is another perfect scenario for a defer block.

One last thing to be aware of with defer blocks is that you can define as many defer blocks as you like. Each defer block will be called in the reverse order to which they are defined. So, the most recent deferred block will be called first and the oldest deferred block will be called last. We can take a look at a simple example:

func multipleDefers() {
    defer {
        print("C")
    }
    defer {
        print("B")
    }
    defer {
        print("A")
    }
}
multipleDefers()

In this example, "A" will be printed first because it was the last block to be deferred and "C" will be printed last.

Ultimately, it is a great idea to use defer any time you perform some action that will require clean-up. You may not have any extra returns or throws when first implementing it, but it will make it much safer to make updates to your code later.

Summary

Error handling isn't usually the most fun part of programming, but as you can see, there can absolutely be some interesting design strategies around it. It is also absolutely critical in developing quality software. We like to think that our users will never run into any problems or unforeseen scenarios, but you might be amazed at how often that happens. We want to do the very best we can to make those scenarios work well, because users will form lasting negative impressions of your product if they get bogged down in unavoidable error situations.

We saw that Swift provides us with a paradigm to help with this called error handling. Functions and methods can be marked as possibly throwing errors and then we can throw any type that implements the ErrorType protocol. We can handle those thrown errors in different ways. We can assert that an error will never be thrown using the try! keyword, we can convert a throwing function or method into an optional with the try? keyword, or we can catch and inspect errors with do-catch blocks. Lastly, we went over defer blocks, that help us ensure certain actions happen no matter if we throw an error or return early.

Now that we've got error handling out of the way, we can jump into the more artful side of computer programming called design patterns.