The article is from collated notes on objccn. IO/related books. “Thank you objC.io and its writers for selflessly sharing their knowledge with the world.”
Error handling architecture
It is important that the programming language itself provides a good error-handling framework. Some of the characteristics that architecture should have:
- Conciseness: Logically correct code should not be drowned out by code that throws errors.
- Propagation: Errors do not have to be handled in the same place. In general, it is better to keep error-handling code away from where errors occur. An error-handling architecture should make it easy to pass errors up the call stack to the appropriate location. Intermediate functions (functions that do not themselves throw errors and do not handle errors, but whose implementation calls other functions that do) can pass errors without introducing complex syntax changes.
- Documentation: Allows programmers to determine where errors are likely to occur and the specific types of errors.
- Safety: Helps programmers ignore error handling for unexpected reasons.
- Universality: Develop a mechanism for throwing and handling errors that can be used in all scenarios.
Wrong type
The words “Error” and “Failure” can express various types of problems. Some categories for “events that can go wrong” :
Expected error: A failure in a routine operation that can be foreseen by the programmer. For example, network connection problems and invalid user input problems. Based on the complexity of the causes of failure, the expected errors can be further categorized into the following categories:
- Trivialerrors: Some operations have only one predictable failure. For example, the result of a dictionary query is that the key value exists (the operation succeeded) or does not exist (the operation failed). In Swift, there is a preference for functions to return optional values for simple and unambiguous everyday error conditions such as “nonexistent” or “illegal input.”
When the cause of the error is clear to the function caller, optional values excel in terms of brevity, security, documentation, transitivity, and generality.
- Errors requiring detailed information (Richerrors): For network and file system operations, more substantive problem descriptions should be provided about failure situations. There are many factors that can cause failure in these scenarios, and programmers take different approaches based on each factor.
Although most standard library apis return an error that can be ignored for details (usually an optional value), Codable systems use an error that requires details. Encoding and decoding involve many conditions that trigger errors, so accurate error information is valuable in pointing out where a problem occurred.
- Unexpected error: An error caused by unexpected conditions that usually make it difficult for a program to continue. Usually this means that some assumed (” never happening “) condition has been broken.
The way to handle such errors is usually to crash the program because it is not safe to execute in an indeterminate state. These conditions are considered errors by the programmer and should be detected and fixed in testing.
The Result type
Result is an enumeration similar to the Optional structure, containing two members: Success and Failure, which have the same function as some and None in Optional. In contrast, result.failure also has associated values, so Result can express errors that require detailed information:
enum Result<Success.Failure: Error> {
case success(Success)
case failure(Failure)}Copy the code
Result adds an Error constraint to the generic parameter that expresses the failure case, indicating that this case is only used to express errors.
The difference between Optional and Result can indicate whether details of the error can be ignored or if additional information must be provided for the error.
Suppose you’re writing a function that reads a file from disk. At first, you define the interface with optional values. Because reading a file may fail, in which case return nil:
func contentsOrNil(ofFile filename: String) -> String?
Copy the code
The function signature is very simple and there is no specific reason why the file fails to be read. It is necessary to tell the caller the reason for the failure.
enum FileError: Error {
case fileDoesNotExist
case noPermission
}
Copy the code
Thus, we can tell the function to return a Result, either a string (the operation succeeded) or a FileError (the operation failed):
func contents(ofFile filename: String) -> Result<String.FileError>
let result = contents(ofFile: "input.txt")
switch result {
case let .success(contents):
print(contents)
case let .failure(error):
switch error {
case .fileDoesNotExist:
print("File not found")
case .noPermission:
print("No permission")}}Copy the code
Throw and catch
Swift’s built-in error handling borrows from the use of Result in many ways, but with a different syntax. Swift does not indicate failure by returning Result, but marks the method as throws. For each function call that can throw an error, the compiler verifies that the caller caught the error or passes the error up to its caller.
Throws syntax throws contents(ofFile:)
func contents(ofFile filename: String) throws -> String
Copy the code
All calls to contents(ofFile:) must be marked with the try keyword, otherwise the code will not compile. The try keyword is a signal, both to the compiler and to readers of the code, that the function may throw an error.
Calling an error-throwing function also forces us to decide how to handle the error. You can choose to use do/catch directly, or mark the current function as throws to pass the error to the caller higher up the call stack. If you use catch, there can be multiple catch statements, and pattern matching can be used to catch a particular type of error. In catch-all, the compiler also automatically generates an error variable (much like newValue in the attribute’s willSet):
do{
let result = try contents(ofFile: "input.txt")
print(result)
} catch FileError.fileDoesNotExist {
print("File not found")}catch {
print(error)
// Handle other errors.
}
Copy the code
Swift’s exception mechanism does not impose additional runtime overhead as many languages do. The compiler considers the throw to be a normal return, so the normal code path and the exception code path are both fast.
If you want to give more information in the error, you can use enumerations with associated values. For example, a file parser library might model possible error conditions as follows:
enum ParseError: Error {
case wrongEncoding
case warning(line: Int, message: String)}Copy the code
You can also use a structure or class as an error type; Any type that complies with the Error protocol can be thrown as an Error by the function. And because there are really no requirements in the Error protocol, any type can declare compliance without adding any additional implementations.
It can sometimes be helpful to have String implement Error for quick testing of some code, or for writing simple prototypes. All it takes is one line: Extension String: Error {}. In this way, you can directly use the string representing the error message as a throwable error value, for example: throw “File not found”. In production code, this is not recommended because it is not recommended to implement a protocol for a type that is not yours.
With ParseError, our parsing function looks like this:
func parse(text: String) throws- > [String]
do{
let result = try parse(text: "{ \"message\": \"We come in peace\" }")
print(result)
} catch ParseError.wrongEncoding {
print("Wrong encoding")}catch let ParseError.warning(line, message) {
print("Warning at line \(line): \(message)")}catch {
preconditionFailure("Unexpected error: \(error)")}Copy the code
Specific type error and no type error
Some of the do/catch in the previous section didn’t quite fit. Even if the compiler is pretty sure that all possible errors are of type ParseError and handles each of its cases one by one, it still needs to write a catch at the end to make sure that all possible errors are handled.
Swift’s native error handling mechanism uses untyped errors. We can only use throws to declare that a function throws errors, but cannot specify which specific errors it throws. Therefore, to ensure that all errors can be handled at the language level, the compiler always requires that a Catchall statement be written. The use of typeless errors in the error handling system is intentional by Swift.
Result is typed errors. Result takes two generic parameters, Success and Failure, which specifies the specific type of error. It’s this structure that allows you to implement contents(ofFile:) by iterating over FileError in Result
, and handling every error.
For another example, here is a variation of the parse(text:) method that replaces Result<[String], ParseError>. Parse then limits its possible errors to ParseError and can handle only each of its cases (without having to provide a “redundant” catchall statement like do/catch):
func parse(text: String) -> Result"[String].ParseError>
Copy the code
Using untyped errors in Swift’s built-in error handling mechanism does not behave in the same way as using typed errors such as Result.
Swift also provides a Result variant that holds typeless errors, and the associated value of case failure is any type that implements Error. Because of this, Result is actually a hybrid that supports both error handling paradigms. If you do not want to specify a specific error type, Result<… , Error> as the return value.
So Result provides a choice between using untyped errors and concrete errors. I’m just going to have to write a little bit more code when I say Result for an untyped Error, because I’m going to write Error in my generic parameter list. You can define an alias for this Result:
typealias UResult<Success> = Result<Success.Error>
Copy the code
The reason that Result<Success, Error> can be defined is that the compiler specifically provides a backdoor for the Error protocol. As you saw earlier, Failure in Result is a type that implements Error:
enum Result<Success.Failure: Error>
Copy the code
But in Swift, protocols are not a type that implements themselves, so Result<… Error> In this notation, Error is not a representation that satisfies type constraints. To allow untyped errors to be expressed in this form, Swift adds special handling for errors to the compiler, making it a “self-fulfilling” protocol that no other protocol has.
Instead of the compiler always prompting us to catchall errors through catchall, it’s a good idea to use Result to include a specific error type. If you get type-specific errors everywhere, specifying what type of error to throw should certainly be an optional feature of the function, but it doesn’t have to be required for every function that throws an error. Because specific type errors have their own serious problems:
- The specific error types make it difficult to combine functions that throw exceptions, and to aggregate the errors thrown by those functions. If a function calls multiple functions that might throw errors, it either passes multiple errors up the call stack, or it defines a new error type for the lower level of the call stack that contains these errors. This can quickly get out of hand.
- Strict error types limit the extensibility of libraries. For example, every time a new error condition is added to a function, it is a destructive update to the calling code that needs to capture the full list of errors. To maintain binary compatibility between different versions of libraries, each do/catch code has a default handling statement that catches all exceptions.
- It is often unnecessary and unrealistic to require that all error cases be handled. Most programs only deal with a few common cases and provide a common solution for other reasons, such as logging a log or displaying an error message to the user.
A non-negligible error
Consider security as a factor in determining a good error handling system. One of the big benefits of using built-in error handling is that when you call a method that might throw errors, the compiler won’t let you ignore them. With Result, this is not always the case.
For example, consider the data.write (to:options:) method in Foundation (writing several bytes to a file) or the Filemanager.removeItem (at:) method (deleting a specified file):
extension Data {
func write(to url: URL.options: Data.WritingOptions = []) throws
}
extension FileManager {
func removeItem(at URL: URL) throws
}
// If these methods use result-based error handling, their declarations might look like this:
extension Data {
func write(to url: URL.options: Data.WritingOptions = [])
-> Result< (),Error>}extension FileManager {
func removeItem(at URL: URL) -> Result< (),Error>}Copy the code
What makes these methods special is that they are called for their side effects, not for the return value. Neither method has a truly meaningful return value other than indicating whether the operation was successful.
Therefore, the above two versions of Result, intentional or not, are too easy for programmers to ignore any failures and simply write code like this:
_ = FileManager.default.removeItem(at: url)
Copy the code
Throws version, the compiler forces the call to be preceded by the try prefix. The compiler also requires that the call be either nested in a do/catch block or that the error be passed up the call stack. This is a clear and clear reminder that the currently called function is likely to fail and the compiler will force it to handle the error.
Error converting
Throws between throws and Optionals
Errors and optional values are common ways for a function to report that something is not working correctly. Provides some advice on how to select an error for a custom function. When passing the result of a function to another API, it is inevitable to switch back and forth between a function that can throw an error and a function that returns an optional value.
try? The keyword allows us to ignore errors thrown by the function and change the return value of the function to Optional containing the original return value. This Optional option tells us if the function executed successfully:
if let result = try? parse(text: input) {
print(result)
}
Copy the code
Use a try? It means that we get fewer error messages than we did before, and the only thing we know is whether the function executed successfully, or whether something went wrong, and the specific information associated with the error is lost and can’t be retrieved. Similarly, in order to turn a function that returns Optional into a function that throws an error, we have to supply nil with the corresponding error value. Here’s an Optional extension that unpacks itself and throws an error if it’s nil:
extension Optional {
/// Unpack self if it is not nil.
/// If 'self' is' nil ', an error is thrown.
func or(error: Error) throws -> Wrapped {
switch self {
case let x?: return x
case nil: throw error
}
}
}
do{
let int = try Int("42").or(error: ReadIntError.couldNotRead)
} catch {
print(error)
}
Copy the code
The or(error:) extension is useful when we want to convert multiple function calls that return Optional into function calls that throw exceptions, or when we want to write them in a function that has already been marked as throws. This is also a good model for unit testing. The XCTest framework can automatically mark a test case as a failure if you mark it as throws errors inside it. If your test case relies on an Optional not nil to continue, you can use the above pattern to unpack the data. If the Optional is nil, the test will fail. And all of that logic, it only takes one line of code.
try? The emergence of keywords may cause some controversy. Goes against Swift’s philosophy of not allowing errors to be ignored. But after all, you have to explicitly use try, right? Keyword, which can also be thought of as some kind of response the compiler forces you to make to an error, and readers of the code can also pass a try? Make your intentions clear. So, when not at all interested in error messages, try? Is a reasonable choice.
There is a third form of try :try! . Use this form only if you are sure that the function is absolutely impossible to fail. Similar to forcing Optional unpacking, if you use try! The called function throws an error, causing the App to blink.
Converts between throws and Result
Result and throws keywords are only two different presentation modes of Swift error handling mechanism. You can think of Result as an improvement on the return value of a function that can throw an error. Because of this dual identity, the library provides a way to switch between these two representations.
To call a function that can throw an error and wrap its return value as a Result, you can use the init(catching:) initialization method, which takes a function that can throw an error as an argument and wraps its return value as a Result object. Its implementation looks like this:
extension Result where Failure= =Swift.Error {
/// Create a new 'Result' object by evaluating the return value of an error-throwing function,
// wrap the return result on success in 'case success' and throw an error on failure
/// wrap in 'case failure'.
init(catching body: () throws -> Success) {
do{
self = .success(try body())
} catch {
self = .failure(error)
}
}
}
// This initializer works like this:
let encoder = JSONEncoder(a)let encodingResult = Result { try encoder.encode([1.2])}// success(5 bytes)
type(of: encodingResult) // Result<Data, Error>
Copy the code
This method can be useful if you want to delay error handling, or if you want to send the return result of a function to another function.
The opposite of init(catching:) is called result.get (). It evaluates the Result of Result and throws the value from Failure as an error. Its implementation:
extension Result {}
public func get(a) throws -> Success {
switch self {
case let .success(success):
return success
case let .failure(failure):
throw failure
}
}
}
Copy the code
The error chain
It is very common to call multiple functions in succession that might throw an error. For example, an operation may be divided into subtasks, with the output of each subtask being the input of the next. If each subtask can fail, the entire operation should exit as soon as one of them throws an error.
Throws the chain
Not all error-handling systems handle this well, but it’s easy with Swift’s built-in error-handling mechanism, which doesn’t require nested if statements or similar structures to keep code running. Simply place these function calls into a do/catch block (or wrap them into a function marked throws). When the first error is encountered, the calling chain ends and the code is switched to a catch block or passed to the upper caller.
Here is an example of an operation with three subtasks:
func complexOperation(filename: String) throws- > [String] {
let text = try contents(ofFile: filename)
let segments = try parse(text: text)
return try process(segments: segments)
}
Copy the code
Result the chain
Compare the try-based example with its equivalent code that uses Result. Manually chaining multiple functions that return Result requires a lot of effort. You need to call the first function, unpack its output, and if.success is encountered, pass the value to the next function to restart the process. Once the function returns.failure, the chain needs to be broken, all subsequent calls abandoned, and the failure returned to the caller:
func complexOperation1(filename: String) -> Result"[String].Error> {
let result1 = contents(ofFile: filename)
switch result1 {
case .success(let text):
let result2 = parse(text: text)
switch result2 {
case .success(let segments):
return process(segments: segments) .mapError { $0 as Error }
case .failure(let error):
return .failure(error as Error)}case .failure(let error):
return .failure(error as Error)}}Copy the code
This makes the code so messy that each concatenation of a function that returns Result requires an additional layer of nesting of switch statements and the repetition of the same error handling statements. Before refactoring this code, let’s take a look at how errors are handled in all failure statements. Here are the signatures of these methods:
func contents(ofFile filename: String) -> Result<String.FileError>
func parse(text: String) -> Result"[String].ParseError>
func process(segments: [String]) -> Result"[String].ProcessError>
Copy the code
Each function has a different error type :FileError, ParseError, and ProcessError. Thus, along the call chain of these subtasks, not only must the result of each successful step be converted (from String to [String] to [String]), but any errors that may occur at each step must be converted to an aggregate type, which in the above code is Error, It could be any other specific type. This type of error conversion occurred three times:
- The first two return. Failure (error as error) convert error from a concrete type to an error-abiding type. We can also ignore the as Error part, and the compiler does implicit type conversions. But writing it explicitly shows what’s really being done here.
- At the end of the call chain, you cannot simply return process(segments: segments) because process returns values that are incompatible with Result<[String] and Error>. The error type must be cast again with mapError (the method provided by Result).
The result. flatMap method encapsulates the pattern of deciding whether to pass a success value to the next link based on the Result, or whether to exit the call chain due to failure. Its structure is the same as flatMap.
Throws flatMap instead of nested switch statements, this is very elegant, although somewhat worse than the throws implementation:
func complexOperation2(filename: String) -> Result"[String].Error> {
return contents(ofFile: filename).mapError { $0 as Error }
.flatMap { text in parse(text: text).mapError { $0 as Error } } .
flatMap { segments in
process(segments: segments).mapError { $0 as Error}}}Copy the code
Note that you still have to deal with incompatible error types. Result.flatMap only converts the Result of a successful execution, leaving failure unchanged. Therefore concatenating multiple maps or flatmaps requires the Failure type in Result to be the same. This is done by repeatedly calling mapError, whose job is to generalize the specific Error type to “a type that implements Error.”
Errors in asynchronous code
Swift’s built-in error handling mechanism does not work with asynchronous apis that pass errors to callers via callback functions. An example of an asynchronous large number calculation that notifies the result of the calculation after completion via a callback function:
func compute(callback: (Int) - > ())
compute { number in
print(number)
}
Copy the code
How do you integrate errors in this pattern? If the optional value is sufficient to express the error to be passed, we can make the callback accept Int? As a parameter. Thus, whenever the callback receives nil, the calculation fails:
func computeOptional(callback: (Int?). - > ())
Copy the code
Now, in callback functions, arguments must be unpacked in some way, for example, using?? Operator:
computeOptional { numberOrNil in
print(numberOrNil ?? -1)}Copy the code
What if you want to pass multiple error messages to a callback function? The following function signature seems like a natural thing to do:
func computeThrows(callback: (Int) throws- > ())
Copy the code
It does not mean that the method for calculating large numbers will fail, but that the callback function itself may fail. Write the above callback as Result:
func computeResult(callback: (Int) - >Result< (),Error>)
Copy the code
Of course, the signature is wrong. Instead of wrapping the return value of the callback function with Result, we need to wrap the computed Int in Result:
func computeResult(callback: (Result<Int.Error>) - > ())
Copy the code
The incompatibility of Swift’s built-in error handling mechanism with asynchronous apis illustrates a key difference when handling errors using throws versus Optional or Result. Only the latter can freely pass error information, but throws is not so flexible.
A throw, much like a return, can only work in one direction, passing messages up. You can throw the error to the caller of a function, but you cannot throw the error downward as an argument to other functions that will be called later.
This ability to throw errors to functions that will execute next is exactly what is needed for error handling in an asynchronous code environment. Unfortunately, there isn’t a very clear way to indicate how throws should be used in asynchronous environments. Wrapping Int in a function that can throw an error only complicates the signature of compute:
func compute(callback: (() throws -> Int) - > ())
Copy the code
Also, compute is more difficult to use. To get the calculated integer, call this function in the callback function that throws an error. This means error handling in callback functions:
compute { (resultFunc: () throws -> Int) in
do {
let result = try resultFunc()
print(result)
} catch {
print("An error occurred: \(error)")}}Copy the code
While this works, it’s not the way Swift advocates programming. Even though there is some inconvenience in not being able to use throws to handle both synchronous and asynchronously executed code, using Result in an asynchronous environment is a better error-handling method.
Use defer to clean up
Many programming languages have constructs such as try/finally. When a function returns, the code block specified in finally is always executed, regardless of whether an error has occurred. The defer keyword function in Swift is similar, but in a slightly different way. Like finally, the code block that defer specified will always be executed when it leaves the current scope, whether because the execution returned successfully, or because some error occurred, or for some other reason. This makes the defer code block the preferred place to perform the cleanup. Unlike finally, defer doesn’t require a try or DO block in front and can be deployed anywhere in the code.
func contents(ofFile filename: String) throws -> String {
let file = open(filename, O_RDONLY)
defer { close(file) }
return try load(file: file)
}
Copy the code
Regardless of whether contents executes successfully or an error is thrown, the defer code block in the second line ensures that the file is closed when the function returns.
While defer is often used in conjunction with error handling, this keyword can be useful in other contexts as well. For example, when resource initialization and cleanup code are put together (such as opening and closing files). Putting this kind of code together can greatly improve the readability of your code, especially in longer functions.
If there are multiple defer blocks in the same scope, they will be executed in reverse order, as defined. You can think of these defers as a stack.
let database = try openDatabase(.)
defer { closeDatabase(database) }
let connection = try openConnection(database) defer { closeConnection(connection) }
let result = try runQuery(connection, .)
Copy the code
A defer code block is executed when the application leaves the scope defined by defer. Even the evaluation of the return statement is completed before the scoped defer is executed. You can use this feature to change the value of a variable after returning it. In the following example, the increment function increments the captured counter using the defer block after returning counter:
var counter = 0
func increment(a) -> Int {
defer { counter + = 1 }
return counter
}
increment() / / 0
counter / / 1
Copy the code
Rethrows
Because functions can throw errors, this poses a problem for functions that take functions as arguments, such as map or filter.
func filter(_ isIncluded: (Element) - >Bool)- > [Element]
Copy the code
This definition is fine, but it has one drawback: the compiler will not accept an error-throwing function as a predicate, because the isIncluded parameter is not marked as throws.
Start by writing a function that checks the file for certain availability. CheckFile can either return a Boolean value (true for available, false for unavailable) or throw an error that occurred while checking the file:
func checkFile(filename: String) throws -> Bool
Copy the code
Suppose we have an array of file names and want to filter out unusable files. Choose to use the filter method, but the compiler won’t let you do this because checkFile is a function that can throw an error:
As a solution, error handling can be done in the predicate function of filter:
let validFiles = filenames.filter {
filename in do {
return try checkFile(filename: filename)
} catch {
return false}}Copy the code
This is inconvenient because the code above hides all possible checkFile errors with false.
One solution is to have the library decorate its predicate function with throws in the filter signature:
func filter(_ isIncluded: (Element) throws -> Bool) throws- > [Element]
Copy the code
This can work, but it can also be inconvenient. Because now every filter call must use a try (or try!) To modify. This causes all higher-order functions in the library to have to be called with a try. Obviously, this defeats the purpose of the try keyword, which is designed to help code readers quickly identify error-throwing functions.
Another implementation is to implement two versions of filter, one that accepts plain and one that can throw errors as predicate functions. The two versions of the filter implementation are identical, except that the predicate function is called with a try. You can rely on the compiler to automatically select the correct version based on rules for function overloading. It looks better this way, at least the different versions of the call are clear, but it’s still too wasteful.
Fortunately, Swift provides a better solution via the REthrows keyword. Marking a function with REthrows tells the compiler that the function will throw an error only if its arguments throw an error. Therefore, the filter method ends up signing like this:
func filter(_ isIncluded: (Element) throws -> Bool) rethrows- > [Element]
Copy the code
The predicate function is still marked as a throws function, indicating that the caller may pass a function that throws an error. In the filter implementation, you must call the predicate function with a try. Rethrows ensures that filter passes errors in predicate functions up the call stack, but does not throw any errors by itself. Therefore, the compiler does not require a try call to filter when the passed predicate function does not throw an error.
In the standard library, methods that take function type parameters are labeled with REthrows for almost all sequence and collection types. One exception is lazy-loaded collection methods. This is mainly due to the fact that throws does not work with asynchronous code.