今是昨非

今是昨非

日出江花红胜火,春来江水绿如蓝

Using error to control flow in Swift.

title: Using error to control flow in Swift
date: 2018-12-11 14:09
tags: Technology#

This article is a translation#

Original article link: Using errors as control flow in Swift

Managing control flow in an app or project has a significant impact on code execution speed and debugging complexity. The control flow of code is essentially the order in which functions and statements are executed, and the path that code takes during execution.

Although Swift provides many tools for defining control flow - such as if, else, while, and optional - this week, we will look at how to use Swift compile-time errors to throw and handle errors in order to make control flow easier to manage.

Throwing nullable values#

Optional values, as an important feature of Swift, can be safely ignored when dealing with empty data; they are also often used as templates for control flow in a given function.

Below, we rewrite the method for loading and adjusting images from the app's bundle. Since each step of the operation returns a nullable image, we have to write multiple guard statements to tell the function where it can exit:

func loadImage(named name: String,
               tintedWith color: UIColor,
               resizedTo size: CGSize) -> UIImage? {
    guard let baseImage = UIImage(named: name) else {
        return nil
    }

    guard let tintedImage = tint(baseImage, with: color) else {
        return nil
    }

    return resize(tintedImage, to: size)
}

The problem with the above code is that we use nil to handle runtime errors - which means we have to parse the result at each step and hide the root cause of why this error occurred.

Then let's see how to refactor the control flow by throwing functions and errors to solve the above problem. The first step is to define an enum that contains all the errors that may occur during the image processing process, as follows:

enum ImageError: Error {
    case missing
    case failedToCreateContext
    case failedToRenderImage
    ...
}

Then modify the function to throw the defined errors when it fails, instead of returning nil. For example, modify the loadImage(named:) method to return a non-empty image or throw ImageError.missing:

private func loadImage(named name: String) throws -> UIImage {
    guard let image = UIImage(named: name) else {
        throw ImageError.missing
    }
    return image
}

If all other image processing methods are modified in this way, other top-level functions can also follow suit - remove all optionals and make them either return a definite image or throw an error:

func loadImage(named name: String,
               tintedWith color: UIColor,
               resizedTo size: CGSize) throws -> UIImage {
    var image = try loadImage(named: name)
    image = try tint(image, with: color)
    return try resize(image, to: size)
}

The above modifications not only make the function body more concise, but also make debugging easier, because if an error occurs, we will get a clearly defined error - instead of having to check which step returned nil.

However, the fact is that not all places need to handle errors, so there is no need to force the use of the do, try, catch pattern; and abusing do, try, catch will cause us to avoid the boilerplate code as much as possible - carefully distinguish when used.

The good news is that we can always go back to using nullable values even if we use throwing methods. All we need to do is use the try? keyword when calling the throwing method, and then we get an optional value:

let optionalImage = try? loadImage {
    named: "Decoration",
    tintedWith: .brandColor,
    resizedTo: decorationSize
}

The best thing about using try? is that it combines the advantages of both approaches. It can get a nullable value in the call - and also use throw and error to manage control flow.

Validating input#

Next, let's take a look at how using errors can help us improve control flow when validating input. Although Swift has an advanced and powerful type system, it does not guarantee that our functions receive valid input - sometimes runtime checks are the only way out.

Taking another example, when a user registers, we validate the credentials they choose. Like before, the code uses guard statements to check each validation rule and displays an error message if an error occurs:

func signUpIfPossible(with credentials: Credentials) {
    guard credentials.username.count >= 3 else {
        errorLabel.text = "Username must contain min 3 characters"
        return
    }

    guard credentials.password.count >= 7 else {
        errorLabel.text = "Password must contain min 7 characters"
        return
    }

    // Additional validation
    ...
    service.signUp(with: credentials) { result in
        ...
    }
}

Although the above code only validates two conditions, the validation logic can grow beyond expectations. This kind of logic in the UI (especially in view controllers) becomes more difficult to test - so let's see how to decouple and improve code control flow.

Ideally, we want our code to be self-contained. This way it can be tested in isolation and used in our code. To achieve this, first create a specific type for all validation logic. Named Validator, it is a struct with a validation closure for a given Value:

struct Validator<Value> {
    let closure: (Value) throws -> Void
}

With the above code, you can build a validator that throws an error when the value fails validation. However, defining a new error type for each validation process would also generate unnecessary boilerplate (especially if we want to display these errors to the user) - so define a function that only requires a Boolean condition and an error message to display to the user when the validation fails:

struct ValidationError: LocalizedError {
    let message: String
    var errorDescription: String? { return message }
}

func validate { _ condition: @autoclosure () -> Bool, errorMessage messageExpression: @autoclosure () -> String } throws {
    guard condition() else {
        let message = messageExpression()
        throw ValidationError(message: message)
    }
}

Above, we use @autoclosure again - an expression that is automatically resolved within a closure. To learn more, see "Using @autoclosure when designing Swift APIs".

After completing the above, you can write a specific and neat validation logic code - a static computed property of the Validator type. For example, here is an implementation of a password validator:

extension Validator where Value == String {
    static var password: Validator {
        return Validator { string in
            try validate(string.count >= 7, errorMessage: "Password must contain min 7 characters")

            try validate(string.lowercased() != string, errorMessage: "Password must contain an uppercased character")

            try validate(string.uppercased() != string, errorMessage: "Password must contain a lowercased character")
        }
    }
}

To make it thorough, overload a new validate that is somewhat like syntactic sugar, passing in the value to be validated and the validator used for validation:

func validate<T>(_ value: T, using validator: Validator<T>) throws {
    try validator.closure(value)
}

All preparations have been made, and then update the call with the new validation system. The elegance of the above code is that although it requires some additional types and settings, it makes the code that needs to validate input cleaner.

func signUpIfPossible(with credentials: Credentials) throws {
    try validate(credentials.username, using: .username)
    try validate(credentials.password, using: .password)

    service.signUp(with: credentials) { result in
        ...
    }
}
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.