In this article, we start by looking at the likely sources of errors in an application, some ways to prevent errors from occurring and implementing error handling, thinking about resilience as well as usability. We will explore the error handling model built into Swift, covering the powerful tools, associated techniques and how to apply them to build robust applications.
Contents
Sources of errors
Preventing errors
Handling errors
1. User experience considerations
2. Where to put error handling code
Using optionals for simple error handling
Representing errors
Throwing errors
Propagating errors
Catching errors
Selectively propagating errors
Try vs. try? vs. try!
1. Converting errors to optionals with try?
2. Asserting that errors will not occur with try!
Throws vs. rethrows
Performing clean-up actions with defer
Errors and polymorphism
Conclusion
Recoverable vs. unrecoverable errors
Recoverable errors are errors that may temporarily halt normal program execution but the program should be able to recover gracefully and continue executing without the loss of any vital functionality. Unrecoverable errors are those from which the program cannot recover and must shut down. These could be caused by bugs in program logic or other unexpected situations such as unavailability of a resource without which the program cannot execute in a meaningful manner.
In this article, we will be focusing on recoverable errors since normal error handling does not apply to unrecoverable errors. Given the unexpected nature of unrecoverable errors, it is hard to plan for them. One strategy could be to put appropriate checks at places where a potential unrecoverable error could occur. If a check fails, a crash sequence could be initiated to shut the program down in as orderly a fashion as possible. This could include informing user that the application has encountered a fatal error and must terminate, saving any unsaved state and information related to the unrecoverable error, either seeking the user’s consent to send crash-related information to the developer or displaying an error code or other information that the user could quote when reporting the issue.
Sources of errors
Our programs may encounter errors in various ways. Before we delve into the specifics of how to handle errors, let’s look at how they may come about. Getting a better understanding of sources of errors can help write code to try and avoid many errors in the first place.
- Invalid user input: A user may not be aware of what constitutes invalid input or may make a mistake while providing input, for instance, non-numeric input where a number is expected or a non-existent choice when choosing among options.
- Contextually inappropriate user input: A user may provide input which is not appropriate given the context in which the input is being sought. This can often be the case when prior choices or selections made by the user affect what can be considered valid input. For instance, if an application asks a user to choose her country, any country could constitute a valid input. However, if the application has already asked the user to select a region and she has selected Europe, then a country choice of Canada would be contextually inappropriate. Similarly, if the user has already selected a certain service, only locations where that service is offered would be appropriate choices.
- Unavailability of an external system: An external system could be temporarily unavailable or non-responsive. For instance, a network may have a temporary outage or may be busy. It is also possible that an external system such as a persistence mechanism may have failed.
- Unexpected behaviour of an external system: An external system may fail to provide the expected response or information. This could be the result of a malfunction, an unexpected configuration change in the external system, lack of the required credentials, etc.
- Internal inconsistency or violation of invariants: There could be instances where a combination of external inputs and internal processes, each of which may be valid in isolation, may put our program in an internally inconsistent state. We could also have cases where actions taken by individual modules of a larger system, which may be valid in the context of each module, may violate invariants at the overall system level.
Preventing errors
Programs should be designed, wherever practicable, to try and prevent errors.
- Structuring and validating user input: Structuring user input with radio buttons, check boxes, slider controls, etc., can limit the type or range of input a user can provide. Even freeform input should be validated where appropriate. To guard against contextually inappropriate input, funnels can be created which use options selected earlier to limit the input a user can provide to a given prompt. To use our earlier example, if a user selects Europe as the region, only countries in Europe should be presented for the next selection. Similarly, if a certain type of service has been selected, only locations where that service is offered should be available for selection.
- Prompts and warnings: Users can be prompted to reconfirm input provided. This can be done for each screen or related information provided over various screens can be summarized for verification. A warning prompt may be appropriate before committing irreversible changes or before accessing an external system, such as a network, which may not always be available. To maintain a balance between error prevention and streamlined user experience, users can be given the ability to disable certain prompts and warnings. Alternatively, a program may be able to adapt based on the frequency of a particular error for a certain user or group of users.
- Instructions and context-sensitive help: Assisting users in navigating the application by providing appropriate instructions and context-sensitive help can greatly aid in minimizing instances of users selecting inappropriate options or providing erroneous input.
- Consistency: Consistency is important not only for enhancing user experience but also for minimizing errors. From consistent screen design, so the same action is triggered in the same or similar way, to standardizing how users navigate through the application, when and how prompts and warnings are displayed and how users can seek help, it is best to choose one style and stick to it.
Handling errors
Whilst we should always design applications with error prevention as an objective, errors are inevitable in any real-world application. A robust mechanism to gracefully recover from errors is thus an essential part of application design.
The elegance of code is in maintaining as light a touch as possible in handling errors. Unnecessary or unnecessarily complex error-handling code can obfuscate the natural flow of application logic. The normal pathway through the application should always be clear, with errors and exceptional processing handled as simply and as unobtrusively as possible.
1. User experience considerations
From a user experience perspective, it is important that error handling should blend as seamlessly as possible into the natural flow of the application. It should not stand out like something the application designer did not expect or had not planned for. User interface elements such as screens, dialogs, popups, etc., used to indicate and recover from errors should fit with the overall theme of the application. While it is natural to want to make error situations stand out in some way to capture user attention, user interface elements used to handle errors should not appear as an afterthought or as something bolted on after the application had been designed.
Error handling code should explain, in as much detail as is appropriate, why a certain error occurred, what the user should do to get back to the normal flow of the application and, optionally, what steps the user could take to prevent recurrence.
2. Where to put error handling code
It is generally good practice to handle errors as close to the source as practically possible. However, given the architecture of modern applications, it is desirable in most situations to push errors some way up the call chain to get to a place where it is appropriate to handle them.
The reason for not handling errors as soon as they occur has to do with the architectural goal of separating user interface control from domain logic and infrastructure. With this separation of concerns, an error encountered in the model layer, which contains domain logic and business rules, or in the infrastructure layer, which deals with networking, persistence, etc., must be captured in an appropriate way along with the information required to effectively respond to it.
The error and the associated information then needs to be propagated back up the call chain to a place where it is appropriate to handle the user interaction required to report and suitably recover from the error. It is important, however, not to unnecessarily propagate errors and to deal with them as close to the source as possible within the context of the architecture pattern being used.
Using optionals for simple error handling
There are situations where we expect a function, method, initializer or closure to return an instance of a type but we also want to account for the possibility of failure. This could be the result of calling a function that is expected but not guaranteed to return an instance of a type, or an initializer that may fail to create a valid instance under certain circumstances. For simple operations where the reason for the error can be discerned from the context, it can be quite efficient to return an optional wrapping an instance of the expected type when the operation succeeds and return nil to indicate an error.
Let’s consider a class that models a person. For the sake of simplicity, let’s assume we record just the name of each person. We want to ensure, however, that an instance is created only if the name provided is not blank. Otherwise, the calling code should be notified that an instance could not be created. To accomplish this, we can use a failable initializer, which will either return an optional wrapping a Person instance or return nil.
class Person {
init?(named name: String) {
guard !name.isEmpty else { return nil }
self.name = name
}
let name: String
}
The calling code can safely unwrap the result using optional binding.
func personCreationTester(name: String) {
if let person = Person(named: name) {
print("Created person named \(person.name)")
} else {
print("Person creation failed. Name is blank")
}
}
personCreationTester(name: "Laura") // Created person named Laura
personCreationTester(name: "") // Person creation failed. Name is blank
Use of optionals to indicate simple errors is a common feature in the Swift Standard Library as well. The Collection protocol, for instance, defines an index(of:) method that returns the first index where the specified value appears in the collection, returning nil if the value is not found.
Here, we demonstrate it with an array.
func arrayIndexTester(number: Int) {
let numbers = [1, 2, 3]
if let index = numbers.index(of: number) {
print("Index of \(number) is \(index)")
} else {
print("\(number) is not in the array")
}
}
arrayIndexTester(number: 2) // Index of 2 is 1
arrayIndexTester(number: 4) // 4 is not in the array
This basic form of error handling works quite well for simple operations with a return value and a single known point of failure. However, a number of operations in real-world applications are more complex and may have multiple points of failure. The calling code would likely need to know why a certain operation failed to be able to take appropriate action.
For such cases, we turn to the error handling mechanism built into Swift.
Representing errors
Errors in Swift are modeled using a type that conforms to the Error protocol. It is an empty protocol so there are no properties or methods to implement. It just indicates that a type can be used for error handling.
Swift enums are particularly suited to modeling errors. We define an enum that conforms to Error. Each case of the enum represents one error case. Where required, associated values can be used to provide supplementary information related to the error. As an example, when an error is thrown because a value is outside the permitted range for an operation, associated values can be used to indicate the upper and lower bonds of the permitted range. This can help create error messages that explain to the user why the error occurred and how to take corrective action.
Another use of associated values could be to encapsulate errors thrown at a lower level of abstraction or by methods dealing with infrastructure or third-party APIs. If methods at a higher level of abstraction simply propagate errors thrown by a lower-level abstraction or by a call to infrastructure services, third-party APIs, etc., it would pollute the higher-level API and expose implementation details. Errors thrown by lower layers should normally be caught and errors appropriate to the abstraction thrown in their place, with associated values used, where required, to preserve the underlying error.
Let’s suppose we are working in a domain dealing with enrollment of students in courses offered by a university. Students must be registered before they can enroll in any course. Their basic information is captured at the time of registration. Students may become inactive for a certain period of time due to non-payment of fees, suspension, extended leave of absence, etc. Inactive students are not allowed to enroll in courses. All students have a current level of achievement, which is used to determine whether they are eligible to enroll for a certain course, each of which has a minimum required level of achievement.
An attempt to enroll a student in a course may fail for three reasons. The student may be inactive, the course may already be full, or the student may not meet the minimum required achievement level. We define an EnrollmentError enum to represent these three error cases.
enum EnrollmentError: Error {
case inactiveStudent
case courseFull
case doesNotMeetMinLevel(Int)
}
Note the use of an associated value to provide supplementary information for the error case where the minimum achievement level is not met.
Throwing errors
Errors can be thrown using the keyword throw. For example, an error to indicate that a course is full would be thrown like this.
throw EnrollmentError.courseFull
Once an error has been thrown, it must be dealt with in some way. An error thrown inside a function can be handled in the same function. However, as noted in an earlier section, there is often a need to pass errors thrown inside a function up the call chain to a place where it is more appropriate to handle them. This can be done by adding the keyword throws to the function’s signature, immediately after the closing parenthesis of the parameter list and before the return arrow, if one is present. This has the effect of throwing errors, which are not handled by the same function, up the call chain to the scope from where the function was called. A function with the keyword throws in its signature is called a throwing function.
Swift functions, methods, closures and initializers can all throw errors. For the sake of brevity, in the remainder of this post, the term throwing function will be used to refer to any function, method, closure or initializer that can throw errors.
Continuing our example of enrolling students in courses, we define a class to model a student. For every student, we record the name and the current level of achievement. All newly created students are active by default. Each student also has an ID. In a real-world application, students may be assigned unique IDs that are meaningful from a domain perspective, reflecting information such as year of graduation, department code, etc. For our purposes, we just use an instance of UUID to generate a unique value for each student.
class Student {
init(named name: String, level: Int) {
self.name = name
self.level = level
}
let id = UUID()
let name: String
var level: Int
var active = true
}
Next, we define a class to model a course, which has properties to store the name of the course, minimum achievement level required to enroll in the course and course capacity. Each course also has a unique ID. As with students, while courses in a real application may have domain-relevant IDs, for our purposes, we just use an instance of UUID.
We also give the class a method to enroll students in the course. We define a lightweight Enrollment type to capture the IDs of the student and the course for each successful enrollment. The enroll(_:) method throws the appropriate error if the student attempting to enroll in a course does not meet the minimum achievement level for the course.
typealias Enrollment = (studentId: UUID, courseId: UUID)
class Course {
init(named name: String, minLevel: Int, capacity: Int) {
self.name = name
self.minLevel = minLevel
self.capacity = capacity
}
func enroll(_ student: Student) throws -> Enrollment {
guard student.level >= minLevel else {
throw EnrollmentError.doesNotMeetMinLevel(minLevel)
}
return (studentId: student.id, courseId: id)
}
let id = UUID()
let name: String
let minLevel: Int
let capacity: Int
}
Note the keyword throws in the signature of the method.
Propagating errors
A function that calls a throwing function is not required to handle any errors that may be thrown. It can simply propagate the errors, i.e., pass them up the call chain, to be handled elsewhere. To be able to propagate errors, a function must itself be throwing. Inside a throwing function, we prefix any expression that may throw an error with the try keyword. An expression prefixed with try is called a try expression.
To demonstrate this, we define a Registrar class, which has a method to enroll a student in a course. It first checks if the student is inactive or if the course is already full and throws appropriate errors as required. It then uses a try expression to call the enroll(_:) method on the course instance and stores the resulting Enrollment in a list of valid enrollments. Since the function itself is throwing, any error that may be thrown in the try expression is simply propagated.
class Registrar {
func enroll(_ student: Student, in course: Course) throws {
guard student.active else {
throw EnrollmentError.inactiveStudent
}
guard isNotFull(course) else {
throw EnrollmentError.courseFull
}
let enrollment = try course.enroll(student)
enrollments.append(enrollment)
}
private func isNotFull(_ course: Course) -> Bool {
return enrollments.filter({ $0.courseId == course.id }).count < course.capacity
}
private var enrollments: [Enrollment] = []
}
Note that, from the perspective of any code calling the above method, it can throw all three cases of EnrollmentError. The fact that two of the error cases are thrown inside the method and one is just propagated is not relevant.
Catching errors
All errors thrown by our code must be handled somewhere. If an error is not handled and keeps on getting propagated, it will eventually bubble all the way up the call stack, causing the application to crash. It is advisable, therefore, to keep track of errors being thrown and to handle them as soon as it is appropriate to do so.
To handle errors, we first catch them by means of a do-catch statement. This consists of a do clause, within which any statement or function call that can throw an error must appear in a try expression. The do clause is immediately followed by one or more catch clauses, each of which may match one or more errors using pattern matching. A catch clause may also include a where clause, allowing more specific matching behaviour.
Here is the general form of the do-catch statement.
do {
try expression
statements
} catch pattern 1 {
statements
} catch pattern 2 where condition {
statements
} catch {
statements
}
Note the last catch clause, which does not contain any pattern. This will match any error and bind it to a local constant named error.
If no error is thrown in a do clause, execution continues along the same path and all catch clauses which are part of the same do-catch statement are skipped. If an error is thrown by any try expression, no further code in the enclosing do clause is executed and the catch clauses are evaluated in the order in which they appear to see which one matches the error. The first catch clause that matches the error is executed and the remaining ones are skipped. If no catch clause matches the error, the error is transferred to the surrounding scope, where it must either be handled by an enclosing do-catch statement or propagated.
To demonstrate catching errors, we define a method to test enrolling students in a course. This method uses an instance of Registrar to try and enroll students, catching all the errors thrown and printing appropriate messages to the console.
func testEnroll(_ students: [Student], in course: Course, using registrar: Registrar) {
for student in students {
do {
try registrar.enroll(student, in: course)
print("Successfully enrolled \(student.name) in "\(course.name)"")
} catch EnrollmentError.inactiveStudent {
print("\(student.name) is not an active student")
} catch EnrollmentError.courseFull {
print("Could not enroll \(student.name). Course is full")
} catch EnrollmentError.doesNotMeetMinLevel(let minLevel) {
print("Could not enroll \(student.name). Must at least be at level \(minLevel)")
} catch {
print("Unknown error")
}
}
}
Note the last catch clause, without which this code will not compile. This may seem counter-intuitive since we have only one error type and all of its cases are matched by the first three catch clauses. It is important to bear in mind, however, that errors in Swift are not typed. All the compiler knows is that errors thrown will be of a type that conforms to Error. The only way it has to ensure that all error cases will be matched is to insist on having the mop-up catch clause.
Here is some code to test the enrollment process.
let swiftBasics = Course(named: "Swift Basics", minLevel: 5, capacity: 2)
var students = [
Student(named: "Laura", level: 6),
Student(named: "Chris", level: 5),
Student(named: "Charles", level: 4),
Student(named: "Paul", level: 7),
Student(named: "Jenna", level: 8)]
students[1].active = false
var registrar = Registrar()
testEnroll(students, in: swiftBasics, using: registrar)
// Successfully enrolled Laura in "Swift Basics"
// Chris is not an active student
// Could not enroll Charles. Must at least be at level 5
// Successfully enrolled Paul in "Swift Basics"
// Could not enroll Jenna. Course is full
Of the five students we tried to enroll above, only Laura and Paul were successfully enrolled. Chris was denied because his active status was revoked and Charles fell short of the minimum required achievement level. Jenna met the course requirements but could not be enrolled because the course capacity had already been reached.
Selectively propagating errors
A function that calls a throwing function may catch certain errors and propagate others. Such a function must be a throwing function to be able to propagate errors that it does not handle. Errors that get propagated may include errors thrown in one or more try expressions enclosed in do-catch statements, which are not matched by any of the catch clauses, as well as errors thrown in one or more try expressions that appear outside do-catch statements.
To demonstrate a throwing function that selectively handles some errors, we modify the Registrar class to give it a list of students who can exceptionally be enrolled in a course, provided they are only one short of the minimum achievement level required. We add a do-catch statement to the enroll(_:in:) method to catch errors related to minimum achievement level, using a where clause to match achievement level errors only if the achievement level of the student is exactly one below the required minimum and the student is included in the list of exceptions. The catch block ensures that such students are also enrolled in the course. All other errors are propagated as before.
class Registrar {
func enroll(_ student: Student, in course: Course) throws {
guard student.active else {
throw EnrollmentError.inactiveStudent
}
guard isNotFull(course) else {
throw EnrollmentError.courseFull
}
var enrollment: Enrollment
do {
enrollment = try course.enroll(student)
} catch EnrollmentError.doesNotMeetMinLevel(let minLevel) where qualifiesForException(student, minLevel) {
enrollment = (studentId: student.id, courseId: course.id)
}
enrollments.append(enrollment)
}
private func isNotFull(_ course: Course) -> Bool {
return enrollments.filter({ $0.courseId == course.id }).count < course.capacity
}
private func qualifiesForException(_ student: Student, _ minLevel: Int) -> Bool {
return (minLevel - student.level) == 1 && exceptions.contains(student.id)
}
private var enrollments: [Enrollment] = []
var exceptions: [UUID] = []
}
Let’s see how this works.
let unitTesting = Course(named: "Unit Testing", minLevel: 5, capacity: 2)
students = [
Student(named: "Mary", level: 4),
Student(named: "Pauline", level: 4)]
registrar.exceptions.append(students[1].id)
testEnroll(students, in: unitTesting, using: registrar)
// Could not enroll Mary. Must at least be at level 5
// Successfully enrolled Pauline in "Unit Testing"
Both Mary and Pauline have an achievement level one below the minimum requirement and as such they should not be allowed to register for the course. Pauline, however, has been added to the exceptions array in the Registrar instance. When the code above is run, the error related to minimum achievement level for Pauline is caught by the Registrar instance. In the case of Mary, the Registrar instance just propagates the error.
Try vs. try? vs. try!
So far, we have seen only the try keyword, which requires handling the error in some way, either by catching it with a do-catch statement or propagating it by marking the function as throwing. There are two other flavours of try that can be used to deal with errors without catching or propagating them, so they don’t need to appear inside a do-catch statement or a throwing function.
1. Converting errors to optionals with try?
The try? keyword can be used to convert an error into an optional. If an error is thrown inside a try? expression, the expression evaluates to nil. If there is no error, the expression evaluates to an optional wrapping the value of the expression.
This comes in handy in situations where a particular use case only requires knowing whether an error got thrown and not which error it was. It can also be useful, for instance, when using frameworks and third-party libraries where we may not be interested in the details of some of the errors thrown but only in whether an operation failed.
To demonstrate, we define an error type with a single error case. We also define two throwing functions, only one of which actually throws an error.
enum TestError: Error {
case someError
}
func canThrowButDoesNot() throws -> String {
return "My string"
}
func canAndDoesThrow() throws -> String {
throw TestError.someError
}
We use the above functions to see the result of a try? expression, when the throwing function actually throws an error and when it does not.
print((try? canThrowButDoesNot()) ?? "nil") // My string
print((try? canAndDoesThrow()) ?? "nil") // nil
When an error does not get thrown, we get an optional wrapping a string, which we unwrap using the ?? (nil-coalescing) operator. When an error does get thrown, we get nil.
2. Asserting that errors will not occur with try!
The try! keyword can be used to assert that an error will not be thrown. If there is no error, execution continues normally. If, however, an error does get thrown in a try! expression, the application will crash.
We can test the use of try! using the same functions we used above.
// Using try! is dangerous. Will cause a crash if error is thrown
print(try! canThrowButDoesNot()) // My string
print(try! canAndDoesThrow()) // Crash!!!
When no error is thrown, we get a String (not an optional wrapping a String). When an error does get thrown, our application crashes and burns.
Using try! is akin to forced unwrapping of an optional, which I would advise avoiding altogether. Swift provides a plethora of language features and associated techniques to work safely with optionals. Using try? and working with the resulting optional can lead to safer and more robust code.
Even in cases where we want to assert that an error should only occur if the application is unable to continue executing, we should not use try! as it will abruptly crash the application. It is advisable in such scenarios to use try?. This way, we get the opportunity to terminate the application in as orderly a manner as possible, as already explained in the section dealing with unrecoverable errors.
Throws vs. rethrows
We have already seen that a function that contains code that throws errors but does not handle all possible errors must itself be a throwing function, to be able to propagate any errors that don’t get handled. But what about a function that does not itself contain code that throws errors but takes a throwing closure as a parameter. Such a function could be given as an argument a closure that can throw but also a closure that cannot throw.
If the argument is a closure that can throw, the function should only be called with one of the flavours of try. However, if the argument is a closure that cannot throw, we should be able to call the function normally. Marking such a function with throws is not appropriate and we need another way.
Swift provides an elegant solution to this problem with the keyword rethrows, which is used for functions that take throwing closures as parameters. It indicates that the function can throw if the closure passed as the argument can throw. In such a case, any call to the rethrowing function must be annotated with one of the flavours of try. However, it can be called like a normal function when the closure passed as the argument cannot throw.
To demonstrate this, we define a function that takes as its only parameter a closure that returns a string but can also throw. The body of the function simply executes the closure and returns the same string.
func throwingClosureExpecter(closure: () throws -> String) rethrows -> String {
return try closure()
}
Note the rethrows keyword in the function signature immediately after the closing parenthesis of the parameter list and before the return arrow. This indicates that the function may or may not throw depending on whether a throwing closure is passed in as the argument.
We need to test the behaviour of this function under three scenarios: When the closure cannot throw, when the closure can throw but does not, and when the closure can and does throw. To do this, we will use the two throwing functions canThrowButDoesNot() and canAndDoesThrow() that we had defined in the last section. For the scenario where the closure cannot throw, we define the following function.
func cannotThrow() -> String {
return "My string"
}
Now we can test the three scenarios.
print(throwingClosureExpecter(closure: cannotThrow)) // My string
print((try? throwingClosureExpecter(closure: canThrowButDoesNot)) ?? "nil") // My string
print((try? throwingClosureExpecter(closure: canAndDoesThrow)) ?? "nil") // nil
In the first case, we pass in a closure that cannot throw so there is no need for any form of try and our expression simply prints out the string returned by the closure. In the second case, we pass in a closure that can throw so we use a try? expression. Since no error is actually thrown, we get an optional wrapping the string returned by the closure, which we unwrap using the nil coalescing operator. In the final case, we pass in a closure that can and does throw so we again use a try? expression. Since an error is actually thrown, we get nil.
Just to complete the demonstration, we pass in a closure that can throw to our rethrowing function and try calling it without any form of try. The result is a compiler error.
// Compiler error: Call can throw but is not marked with 'try'
print(throwingClosureExpecter(closure: canThrowButDoesNot)) // Does not compile
print(throwingClosureExpecter(closure: canAndDoesThrow)) // Does not compile
It does not matter whether the closure actually throws an error. If the closure can throw, the rethrowing function can also throw and it must be called using some form of try.
It is important to note that a rethrowing function can only throw errors thrown by a closure passed in as an argument. It cannot throw any errors of its own.
Performing clean-up actions with defer
When an error is thrown, the current scope is exited. This could result in skipping clean-up actions that would have been performed had execution continued along the normal path. A defer statement can be used to ensure that certain actions are performed before the current scope is exited. A defer statement can contain as many statements as required and one scope can contain multiple defer statements.
It is noteworthy that defer statements are executed regardless of whether the current scope is exited normally or early due to a return statement, an error being thrown, etc. Defer statements are recognized as they are encountered in the normal execution path. If an early exit gets triggered, only defer statements encountered up to that point are executed prior to exiting the scope. Therefore, all defer statements that need to be executed prior to a particular early exit should be placed before the point where that early exit may occur.
When order of execution is important, we must bear in mind that defer statements are executed in the reverse of the order in which they are encountered during the normal flow of execution. We can think of them as being stored in a stack rather than a queue. Moreover, a defer statement itself cannot cause an early exit, so should not include statements such as return and should not throw errors.
To see how defer works in practice, we define a throwing function that takes a boolean parameter, which determines whether it will throw an error. The function contains three defer statements, each of which just prints a string to the console to indicate that it was executed. Two of the three defer statements appear before the point at which an error may be thrown.
func throwerWithDefer(shouldThrow: Bool) throws {
defer {
print("First defer statement executed")
}
defer {
print("Second defer statement executed")
}
print("Prior to throw")
if shouldThrow {
throw TestError.someError
}
print("After throw")
defer {
print("Third defer statement executed")
}
}
We now execute the above function and see the order of execution, first when no error is thrown and then when an error is thrown.
try? throwerWithDefer(shouldThrow: false)
// Prior to throw
// After throw
// Third defer statement executed
// Second defer statement executed
// First defer statement executed
When no error is thrown, the function executes normally and all three defer statements are executed, in reverse order, before the function is exited.
try? throwerWithDefer(shouldThrow: true)
// Prior to throw
// Second defer statement executed
// First defer statement executed
When an error is thrown, it causes an early exit from the function so code that appears after the point at which the error is thrown does not get executed. The first two defer statements, which are placed before the point at which the error is thrown, get executed before the function is exited. The third defer statement, which is placed after the point at which the error is thrown, does not get executed.
Errors and polymorphism
Polymorphism allows us to program to interfaces rather then implementations. With object-oriented programming, this is made possible by inheritance; with protocol-oriented programming, by making types conform to protocols. For polymorphism and abstraction to work correctly, our subclasses and conforming types should comply with the Liskov Substitution Principle, which requires that we should be able to freely substitute any subclass instance where a superclass type is expected and, by extension, any conforming type instance where a protocol type is expected.
In the context of methods that can throw, this means the following:
- If a method in a class cannot throw, it cannot be overridden in a subclass by a throwing method. Similarly, a protocol requirement for a method that cannot throw cannot be satisfied with a throwing method. This is because code that calls a method that cannot throw does not need to handle or propagate errors. If the same method can be overridden by a subclass or implemented by a conforming type as a throwing method, it will break client code since it will end up calling a throwing method without being able to handle errors.
- If a method in a class can throw, it can be overridden in a subclass by throwing method as well as a method that cannot throw. Similarly, a protocol requirement for a throwing method can be satisfied with a throwing method as well as a method that cannot throw. This is because code that calls a throwing method must be able to handle or propagate errors and it should be able to deal with situations when an error is thrown and when it isn’t. Therefore, such a method can be overridden by a subclass or implemented by a conforming type as a throwing method or a method that cannot throw without breaking any client code.
Here is some code to demonstrate the above with protocols and structs. It can easily be rewritten for class inheritance as well.
protocol X {
func someFunc()
}
// Compiler error: Type 'A' does not conform to protocol 'X'
struct A: X {
func someFunc() throws {}
}
Our attempt to satisfy the protocol requirement for a method that cannot throw with a throwing method is met with a compiler error.
protocol Y {
func anotherFunc() throws
}
struct B: Y {
func anotherFunc() throws {}
}
struct C: Y {
func anotherFunc() {}
}
A protocol requirement for a throwing method can be satisfied by a throwing method as well as a method that cannot throw.
Conclusion
Swift provides excellent error handling capabilities. We can represent various cases for each error, use the error cases to encapsulate related information, throw the errors, propagate them and, when appropriate, catch them using pattern matching. We can even convert errors to optionals, suppress them or simply assert that they will not occur. This can enable us to build robust and relatively unobtrusive error handling right into our applications.
It is important to remember though that we should always think about how to prevent errors in the first place. For this, we need to understand the sources of errors, both in a general sense and in ways that are specific to a particular application. The sweet spot lies in achieving the right balance between error prevention and error handling. This is not always easy and takes experience to get right, but the payoff in usability and resilience should be well worth the effort.
Thank you for reading! I always appreciate constructive comments and feedback. Please feel free to leave a comment in the comments section of this post or start a discussion on Twitter.
ℳ i s h a says
Nice article!
Khawer Khaliq says
Thanks Misha.
Miles Brickman says
Help! I haven’t got a clue how the return statement in isNotFull works.
What is the result of filtering the closure and how did it happen?
I take it it’s an array with the Enrollment tuple corresponding to a student but how did it get to be that?
What happens next when count < course.capacity runs? Is this a second filter process that returns a second array?
As you can see, I'm grasping at straws. I would be most thankful for your help.
Miles
Khawer Khaliq says
Hi Miles,
Thanks for reading the article and for your question.
The isNotFull(_:) method is meant to return true if the course is not yet full, i.e., the number of elements in the enrollments array which have the same courseId as the id of the course passed as the argument to the function is less than the capacity of the course. If not, false is returned.
The return statement, as written, does this in two steps. To make it easier to understand, I have rewritten below the isNotFull(_:) method with the original return statement split into two statements:
The first statement above calls the filter(_:) method on the enrollments property of the Registrar instance. This property is an array of instances of the Enrollment tuple. The filter(_:) method takes a closure as its parameter, passes each element of the array to the closure and returns a new array of the same type, consisting only of elements for which the closure returns true. The closure we pass in is { $0.courseId == course.id }, which returns true if the courseId of the given tuple element equals the id property of the course instance passed as the argument to the isNotFull(_:) method. Moreover, since filter(_:) returns an array of the same type as the array on which it is called, the filtered array is also an array of instances of the Enrollment tuple.
The return statement above takes the filtered array and compares the number of its elements with the capacity of the course. Unlike the filter(_:) method, which returns an array of the same type, count is simply a property of type Int, which gives the number of elements in the array. So count < course.capacity evaluates to true if the array of enrollments for the given course contains fewer elements than the capacity of the course, i.e., the course is not yet full, and false otherwise.
Miles Brickman says
Hello again,
No need to reply to my previous email. I’ve figured out how isNotFull works.
It’s so simple now that I wonder that I took so long to get it.
At this point I’d like to add the following to the previous comment by one of your readers: I haven’t finished the article yet, but it’s been a pleasure to read so far. The quality of the writing is excellent, the explanations are clear and the coverage is comprehensive. I learn programming from Internet sources and have read tons of stuff. Some is not very good so it’s always great to find something really well done. I look forward to finishing your article.
Miles Brickman
Khawer Khaliq says
Many thanks. Writing long-form content does take quite a bit of time, but it is engagement and feedback like this that makes it all worthwhile.
Miles Brickman says
I made some additions to your code in the section ‘Selectively propagating errors’ that your readers might find interesting.
In your code, you show how to selectively propagate errors by adding a where clause after a catch statement and creating an array of students who are allowed to register for a course even if they are only level 4. The array is called exceptions.
The catch clause with where is called when students with level 4 or less apply for a course. The next thing that happens is the where clause calls the method qualifiesForException, which tests whether the student is exactly level 4 and is in the exceptions array.
If the student passes the test, true is returned and the catch clause runs. The problems is that in the example, the body of the catch clause is empty and doesn’t actually enroll the level 4 students, that is, it doesn’t append them to the enrollments array. This means that the course can reach capacity without it being reflected in enrollments, which is used to determine whether a course is full and new students can be admitted.
Nonetheless, execution continues, control passes to the do clause where everything started and a message is printed informing the student they have been successfully enrolled even though they haven’t been and even though the course might be full.
This problem may not be apparent in the sample code since only two students try to register, so enrollment can never go beyond capacity.
However, a simple adjustment solves this problem. All you have to do is add code to the empty catch clause to enroll the student. This is the code I used:
catch EnrollmentError.doesNotMeetMinLevel(let minLevel) where qualifiesForException(student, minLevel) {
let enrollment = (student.id, course.id)
enrollments.append(enrollment)
}
To better demonstrate that the code worked, I increased the number of students from two to five as follows:
var students = [
Student(named: “Ana”, level: 8),
Student(named: “Mary”, level: 4),
Student(named: “Pauline”, level: 4),
Student(named: “Alice”, level: 4),
Student(named: “Ella”, level: 4) ]
I then placed three students in the exceptions array: Mary, Alice and Ella. Thus Pauline is the only level 4 student not in this array. (Ana doesn’t need to be because she’s level 8.)
Note that none of the students in my code were inactive. I also changed the capacity of both courses to 3.
If you print the final enrollments array, you see that three students – Ana, Mary and Alice – got into both courses. Pauline didn’t get into either because she wasn’t in the exceptions array nor did Ella because the courses were full by the time she applied.
A final note. Using 5 students, each applying for two courses, enabled me to see how the filter method’s closure argument ( { $0.courseId == course.id } ) made it possible to have 6 students in a single enrollments array even though the course capacity used to control admission was only 3.
Khawer Khaliq says
Hi Miles,
Thanks for your comment. You are right. There was indeed a bug in the code. Just goes to show the importance of testing not just individual scenarios, but combinations of scenarios as well.
Here is how I have rewritten the enroll(_:in:) method in the Registrar class.
This is very similar to your suggested approach. I have just factored out the statement that appends the new enrollment to the array.
Not sure I understand the other issue you have pointed out. The enrollments array is meant to hold enrollments for all courses, not just a single course, so if we are enrolling for more than one course, the number of elements in this array can be more than the capacity of any individual course. For instance, if we have two courses, each with a capacity of 3 students, and we enroll 3 students in both the courses, the enrollments array will have 6 elements, one for each course-student combination.
Dasem says
This is one of the best Tutorials i have ever seen, so clear and well written,
Thank you so much, i have learned a lot!
Khawer Khaliq says
Thanks. I am glad that you liked the article and found it useful.