• Using errors as control flow in Swift
  • By John Sundell
  • The Nuggets translation Project
  • Permanent link to this article: github.com/xitu/gold-m…
  • Translator: Swants
  • Proofreader: Bruce-Pac, iWeslie

The way we manage control flow in our App and systems has a huge impact on everything from how fast our code executes to how easy it is to Debug. The flow of control in our code is essentially the order in which our various method functions and statements are executed, and which branch of the process the code ends up in.

Swift provides us with a number of tools for defining control flow — if, else, and while statements, as well as structures like Optional. This week let’s look at how we can use Swift’s built-in error throwing and processing Model to make it easier to manage the flow of control.

Quite apart from the Optional

Optional is an important language feature and a good way to deal with missing fields in data modeling. It is also the source of a lot of repetitive boilerplate code in specific functions that involve control flow.

Below I wrote a function to load the images in the App Bundle, then resize and render them. Since each of the above operations returns a picture of an optional value type, we need to use guard statements several times to indicate where the function might 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)
}
Copy the code

The problem with the code above is that we actually use nil for runtime errors in two places, both of which require us to unpack the results of each operation and make the statement that raised the error unsearchable.

Let’s look at how to solve both of these problems by refactoring control flow with error, rather than using a throwing function. We’ll start by defining an enumeration that contains the case for each error that can occur in our image-processing code — something that looks like this:

enum ImageError: Error {
    case missing
    case failedToCreateContext
    case failedToRenderImage
    ...
}
Copy the code

For example, here’s how we can quickly update loadImage(named:) to return a non-optional UIImage or throw imageError.missing:

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

If we modify the other image processing functions in the same way, we can make the same changes to the higher-level functions — removing all the optional values and ensuring that it either returns a correct image or throws any errors from our series of operations:

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)
}
Copy the code

The code changes above not only make our function body simpler, but also make debugging easier. Because when something goes wrong it’s going to return an error that we clearly defined, rather than trying to figure out which operation actually returned nil.

However, we may not have the least interest in handling errors all the time, so we don’t need to use do, try, and catch statements all over our code (ironically, these statements also produce a lot of the template code we were trying to avoid in the first place).

The good news is that we can go back to Optional whenever we need to use it — even when we use throwing functions. The only thing we need to do is use try where we need to call the throw function. Okay? Keyword, so we get the result of the optional value type we started with:

let optionalImage = try? loadImage(
    named: "Decoration",
    tintedWith: .brandColor,
    resizedTo: decorationSize
)
Copy the code

Use a try? One of the benefits is that it brings together two of the best things in the world. We can both get an optional value type result after calling the function — while at the same time allowing us to manage our control flow with the benefit of throwing an error 👍.

Validate the input

Next, let’s see how much we can improve our control flow by using error when validating input. Even though Swift is already a very dominant and strongly typed environment, it can’t always guarantee that our functions receive validated input values — sometimes using run-time checking is the only thing we can do.

Let’s look at another example where we need to validate the user’s choice when registering a new user. In the past, our code used to validate each rule with guard statements, printing an error message when an error occurred — something like this:

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) { resultin. }}Copy the code

Even if we only validated the above two pieces of data, our validation logic grew faster than we expected. Mixing this logic with our UI code (especially in the same View Controller) also makes the whole test more difficult — so let’s see if we can decouple some of the code to improve the control flow.

Ideally, we want to keep the validation code to ourselves, so that development and testing are isolated from each other and that our code is easier to reuse. To do this, we create a common type for all validation logic to contain the closure of the validation code. We can call this type a validator and define it as a simple structure that holds a closure that validates against the given Value type:

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

Using the above code, we refactor the validation function to throw an error when an input value fails validation. However, Defining a new Error type for each validation procedure can again raise the issue of generating unnecessary template code (especially if we just want to show the user an Error) — so let’s introduce a write validation logic that simply passes a Bool Conditions and a function that displays information to the user when an error occurs:

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

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

Again, we used @Autoclosure, which is the inference statement that lets us unwrap the closure automatically inside the closure. For more information, click”Using @autoclosure when designing Swift APIs”.

With that in mind, we can now implement all the validation logic of the shared Validator — constructing computed static properties within the Validator type. For example, here’s how we implemented password authentication:

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"
            )
            
            tryvalidate( string.lowercased() ! = string, errorMessage:"Password must contain an uppercased character"
            )
            
            tryvalidate( string.uppercased() ! = string, errorMessage:"Password must contain a lowercased character")}}}Copy the code

Finally, let’s create another validate overload function that acts a bit like a syntax sugar and calls it whenever we have a value to validate and a validator to use:

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

With all the code written, let’s modify where we need to call to use the new validation system. The elegance of the above approach is that it makes our code for validating input values very nice and clean, although it requires some extra typing and some basic preparation:

func signUpIfPossible(with credentials: Credentials) throws {
    try validate(credentials.username, using: .username)
    try validate(credentials.password, using: .password)
    
    service.signUp(with: credentials) { result in. }}Copy the code

Perhaps even better, we could call the signUpIfPossible function above with the do, try, catch structure to put all the logic to verify the error in a single place — we would then simply display the description of the error thrown to the user:

do {
    try signUpIfPossible(with: credentials)
} catch {
    errorLabel.text = error.localizedDescription
}
Copy the code

It is important to note that while the code example above does not use any localization, we always want to use localized strings when displaying all error messages to the user in a real application.

Throw exception test

Another benefit of building code around possible errors is that it generally makes testing easier. Since a throw function essentially has two different possible outputs — a value and an error. In many cases, overriding these two scenarios to add tests is straightforward.

For example, here’s how we could add a test for our password validation quite simply — by simply asserting that the error use case did throw an error and the success case did not throw an error, which covers our two requirements:

class PasswordValidatorTests: XCTestCase {
    func testLengthRequirement(a) throws {
        XCTAssertThrowsError(try validate("aBc", using: .password))
        try validate("aBcDeFg", using: .password)
    }
    
    func testUppercasedCharacterRequirement(a) throws {
        XCTAssertThrowsError(try validate("abcdefg", using: .password))
        try validate("Abcdefg", using: .password)
    }
}
Copy the code

As the code above shows, since XCTest supports throwing tests — and every unhandled error is treated as a failure — all we need to do is call our validate function with a try to verify that the use case was successful. If no error is thrown, we test successfully 👍.

conclusion

There are many ways to manage the flow of control in Swift code — error combined with throwing functions is a good choice, whether the operation succeeds or fails. Although doing so requires some additional operations (such as introducing the error type and using try or try? Call the function) — but keeping our code simple would really make a big difference.

It is certainly advisable for functions to return alternative types as a result — especially if there are no reasonable errors to throw, but if we need to use guard statements for alternative values in several places at once, using error instead may give us a clearer flow of control.

What do you think? If you are currently using error in conjunction with throwing functions to manage the flow of control in your code — or are you trying something else? Let me know on Twitter @johnsundell and I look forward to your questions, comments and feedback.

Thanks for reading! 🚀

If you find any mistakes in your translation or other areas that need to be improved, you are welcome to the Nuggets Translation Program to revise and PR your translation, and you can also get the corresponding reward points. The permanent link to this article at the beginning of this article is the MarkDown link to this article on GitHub.


The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, front-end, back-end, blockchain, products, design, artificial intelligence and other fields. If you want to see more high-quality translation, please continue to pay attention to the Translation plan of Digging Gold, the official Weibo, Zhihu column.