There is a term in software development – defensive coding – it is an approach to software design and development that tries to ensure that an application continues to function despite unexpected events such as actions by users or changes in the environment in which the app is running. The thing is, despite employing these techniques, situations can, and sometimes do, still occur that mean that execution of our app simply can’t continue. In this article we’ll look at how to identify and debug those situations using a group of functions available in Swift called assertions.
Table of Contents
What Are Assertions?
In their simplest form, assertions are runtime checks that we can put into our code to confirm that a particular boolean expressions evaluate to true
. As such, they assert that a particular condition is true
.
Simple examples include checking that an integer index is within a given range before we attempt to use it as a subscript or ensuring that an optional value is not nil
.
As the name suggests, assertions allow us to assert that an essential condition is true
before execution continues.
On the face of it, this might not seem like much. After all, we can achieve a similar check using language constructs such as an if
statement or optional binding, so why exactly do we need assertions? The thing is, assertions are a little different.
Why Use Assertions?
The biggest difference between an assertion and the simple language constructs you’ll be used to is that with assertions, if the assertions fails (i.e. evaluates to false
), execution of the application will terminate immediately. Now this might sound pretty drastic but the key point is that by terminating, we provide ourselves with the opportunity to debug why our assertion failed.
This is an important point. If we were using normal language constructs such as if
statements or optional bindings, execution would simply limp along in some indeterminate state rather than giving us the opportunity to check what had gone wrong or understand how we got there. We could do this with the debugger, but assertions give us the opportunity to stop our program at that critical point where one of the codes assumptions fails regardless of whether we’re running in the debugger or not. This allows us to optionally print out additional information to help diagnose the reason that our assertion failed as well as, when running in a debug environment such as Xcode, allowing us to interrogate the state of the applications variables and call stack. This is invaluable for building quality into your applications.
Beyond this first point, the second thing that assertions do is give us a measure of control over when these checks are performed.
Depending on the level of optimisation with which the code is compiled, the assertions in our code may not actually be executed. Most commonly this means that assertions are checked in builds with little or no optimisation but are omitted in builds with higher optimisation levels. This allows us to use the assertions whilst we are developing and debugging and yet easily remove them when our code goes into production.
To understand this further, lets take a little detour and look at how we control build optimisation in Swift.
Controlling Build Optimisation in Swift
In Swift, debug or release modes are controlled through the SWIFT_OPTIMIZATION_LEVEL
compiler setting.
Things are a little simpler than they have been previously and there are only really three different levels of optimisation available in Swift. These are:
SWIFT_OPTIMIZATION_LEVEL = -Onone // none a.k.a. debug
SWIFT_OPTIMIZATION_LEVEL = -O // fast a.k.a. release
SWIFT_OPTIMIZATION_LEVEL = -Ounchecked // unchecked release
When we’re developing Swift code in Xcode or writing it within an Xcode Playground, by default, our code compiled as a debug build. Under the hood this equates to the -Onone
optimisation level. At this optimisation level the compiler performs no optimisations which in turn makes debugging your code and tracing it back to the original line of source code significantly easier.
The next level of optimisation is are release builds. By default, these equate to an optimisation level of -O
under the hood and in this mode, the compiler will strip symbols from your code (such as variable and function names) and perform optimisation on your code to improve its performance. Due to the symbol stripping tracing errors back to their original line of source code is more tricky than in it is with an optimisation level of -Onone
.
The final level of optimisation supported by the Swift compiler is -Ounchecked
.
This is the optimisation level you want if you’re going for the absolute best blazing performance you can. Although this level of optimisation does improve performance, it only achieves this through the introduction of a certain level of risk. At this optimisation level, the Swift compiler will skip many of the safety checks it would normally perform within the code in order to squeeze out the last ounce of performance. Usually, you will only want to use this mode if you really know what you’re doing.
Ok, so we’ve looked at the different levels of optimisation supported by the Swift compiler but how do these affect the performance of assertions in Swift. We’ll look at this in the next section.
The Five Types of Swift Assertion
If we dive into a bit more of the detail behind assertions in Swift, we find that the Swift language actually supports five, different types of assertion. These are:
– assert()
– precondition()
– assertionFailure()
– preconditionFailure()
– fatalError()
Each of these assertions behaves slightly differently and in the majority of cases, the optimisation level that the code is compiled with has a direct effect on whether they are evaluated or not. Let’s start by looking at the first of these assertions, the assert()
function.
assert()
Out of the five types of assertion in Swift, the assert(_:_:file:line:)
function, is the one that you will likely be most familiar with. Similar to the traditional C-style assert, the assert(_:_:file:line:)
function is the most basic of the five assertion functions available in Swift and takes up to four parameters.
The first parameter is a boolean expression to evaluate. As discussed earlier, if this boolean expression evaluates to false
, execution of the application will halt immediately. For example:
assert(false)
// triggers an assertion
The second parameter is an optional string parameter. Should the assertion fail, this string will be printed within the console log and can be used to provide additional information and context to why the assertion failed. For example:
assert(10 > 5, "10 is not less than 5")
// triggers an assertion
// prints message "10 is not less than 5" to the console.
If you’re following along in a playground or in an Xcode project, you’ll have noticed in the examples above, that not only was the assertion message printed in the console, but the output also included the name of the file and the line number of the assertion that failed. The file and line number can actually be modified using the last two parameters to the assert(_:_:file:line:)
function, the file:
and line:
parameters.
By default, these parameters are set to the result of the #file
identifier (which retrieves the name of the current file) and the #line
identifier (which retrieve the current line number) but you can replace them with anything you like. Note though that if you do want to modify them the file:
parameter is a StaticString
(a simply a simple String
-like type that can be used to store a static string literal) and the line:
parameter is a UInt
.
Ok, so that’s the basic syntax and semantics of the assert(_:_:file:line:)
function, but how does it actually behave and when is it actually evaluated? Well, the key thing to remember about the assert(_:_:file:line:)
function is that it is only evaluated when the code is compiled with an optimisation of -Onone
(essentially debug only). This means it will be evaluated in Xcode’s default Debug configuration and when written as part of a Swift playground.
For release builds (i.e. those builds compiled with an optimisation of -O
which is the default for Xcode’s Release configuration, the boolean condition passed into the assert(_:_:file:line:)
function is not evaluated and there are therefore no side effects of that evaluation. This essentially means the assertion check is skipped in release builds.
Finally, in builds with an even higher level of optimisation (i.e. with an optimisation of -Ounchecked
), not only is the boolean expression not checked, but the Swift optimizer may also automatically assume that the condition would have evaluated to true
. Obviously, if this isn’t the case, this can have some pretty serious dire consequences for your application.
So that’s the assert(_:_:file:line:)
function. Next up is the precondition(_:_:file:line:)
function.
precondition()
Although the names differ, the precondition()
function and the assert()
functions exhibit almost the same behaviour.
As such, the precondition(_:_:file:line:)
function takes the same four parameters as the assert(_:_:file:line:)
function – a boolean expression to evaluate, an optional message to be displayed in the console should the assertion fail, and optional file:
and line:
parameters that again default to the values of the #file
and #line
identifiers:
precondition(2 > 3, "2 wasn't greater than 3")
// Causes an assertion failure and prints "2 wasn't greater than 3" to the console.
So on the face of it, the assert(_:_:file:line:)
and precondition(_:_:file:line:)
functions are exactly the same. This similarity continues in Playgrounds and builds with an optimisation level of -Onone
with both functions printing the supplied message and then stopping the the application in a debuggable state should the assertion fail.
The difference comes in builds with a higher level of optimisation. You see, where evaluation of the assert(_:_:file:line:)
function is skipped in a build with an optimisation of -O
or higher, the precondition(_:_:file:line:)
is still evaluated in builds with an optimisation of -O
(though it as with the assert(_:_:file:line:)
function is skipped in builds with an optimisation of -Ounchecked
). This means that not only can we have the compiler perform these assertions in our debug builds as we could with the assert(_:_:file:line:)
function, we can also have it perform those checks in our release builds as well.
Ok, next up we have another pair of assertion functions whose behaviour differs slightly from the assert(_:_:file:line:)
and precondition(_:_:file:line:)
functions we just looked at. We’ll start with the assertionFailure()
function.
assertionFailure()
Although it’s name may sound similar to the assert(_:_:file:line:)
function we just looked at, the behaviour of the assertionFailure(_:file:line:)
function is a little different.
Where the two assertion functions we’ve looked at so far both had four parameters the assertionFailure(_:file:line:)
function only has three – an optional message and optional file:
and line:
parameters.
Instead of evaluating a boolean expression as part of its assertion check, the assertionFailure(_:file:line:)
is triggered on execution. As such, the function acts as a kind of back stop or watch dog, and is used to give a hint to the compiler that the particular context (such as the branch of an if
statement or case in a switch
statement) should never have been executed.
For example, say you had an index value and wanted to use it in a switch
statement but knew from logic elsewhere in your application that there was no way you should get an index value above 2
you may choose to place a call to assertionFailure(_:file:line:)
in the default
case to stop execution if it is ever executed:
let index : Int = Int(arc4random_uniform(4))
switch index {
case 0, 1, 2:
print(index)
default:
assertionFailure("Unexpected index \(index)")
}
// Note: The assertion will randomly fail and print the message.
The final thing to look at with the assertionFailure(_:file:line)
function is when is it evaluated? As with the assert(_:_:file:line:)
function, the assertionFailure(_:file:line)
function is a debug-only function and is evaluated only in builds with an optimisation level of -Onone
and therefore has no effect on release builds. The same cannot be said of our next assertion function though, the preconditionFailure(_:file:line:)
function.
preconditionFailure()
In a similar fashion to the assert(_:_:file:line:)
/ precondition(_:_:file:line:)
pairing we saw earlier, the counterpart to the assertionFailure(_:file:line:)
function is the preconditionFailure(_:file:line:)
function.
The preconditionFailure(_:file:line:)
function has exactly the same parameters and behaviour as the assertionFailure(_:file:line:)
function printing the optional message, terminating execution and leaving the application in a a debuggable state should the statement be executed. The difference is that instead of only being evaluated in builds with an optimisation level of -Onone
(i.e. debug builds), it is also evaluated in builds with an optimisation level of -O
(i.e. release builds) though as with all the functions we’ve looked at so far, evaluation is still skipped in highly optimised builds such as those built with an optimisation level of -Ounchecked
.
So that bring us onto the last of Swift’s assertion functions the fatalError(_:file:line:)
function.
fatalError()
In all honesty, the fatalError(_:file:line:)
function at first glance is almost a carbon copy of the preconditionFailure(_:file:line:)
function. Taking exactly the same parameters – an optional message and optional file:
and line:
parameters, and being evaluated in exactly the same situations (i.e. builds with optimisation levels of both -Onone
and -O
), you’d be forgiven for wondering what the difference between the two is. In reality the difference is subtle, and one best explained with an example.
Suppose you had a function that took an integer index and looked up a particular String
value based on the supplied index and returned it but also for other reasons you knew that those index values would never be greater than say, 2
, you might write a function something like this:
let index2 : Int = Int(arc4random_uniform(4))
func stringForIndex(index: Int) -> String {
switch index {
case 0, 1, 2:
return "\(index)"
default:
assertionFailure("Unexpected index \(index)")
}
}
stringForIndex(index2)
But if you put this into a Playground or Xcode project you’d see a problem. The compiler raises an error complaining that the function does not return a String
value in the case of the default
case of the switch
statement. We could solve this by adding a call to the abort()
function, immediately after the assertionFailure(_:file:line:)
function like so:
let index2 : Int = Int(arc4random_uniform(4))
func stringForIndex(index: Int) -> String {
switch index {
case 0, 1, 2:
return "\(index)"
default:
assertionFailure("Unexpected index \(index)")
abort()
}
}
stringForIndex(index2)
But this adds an additional extraneous line of code to our source code and in reality, it’s a more elegant solution to use the fatalError(_:file:line:)
function.
The subtle difference with the fatalError(_:file:line:)
function is that the functions declaration is marked with an additional Swift attribute – the @noreturn
attribute – that the other functions we’ve looked at so far, don’t have and in this particular example, this attribute makes a big difference.
By marking a function as @noreturn
, we are actually telling the compiler that any call to the marked function will not return to its calling function as it normally would, in this case because execution of the application would terminate. As a result, Swift compiler no longer checks for a return value from the default
case of the switch
statement as it can tell by the execution flow, that execution will enter the fatalError(_:file:line:)
function and won’t return. If we return to our example then, we can take advantage of this and re-write it as:
let index2 : Int = Int(arc4random_uniform(4))
func stringForIndex(index: Int) -> String {
switch index {
case 0, 1, 2:
return "\(index)"
default:
fatalError("Unexpected index \(index)")
}
}
stringForIndex(index2)
And this as you’ll see if you’re following along, the compiler is now happy!
A Final Note on Assertions
So that about wraps it up for assertions.
As we’ve seen, although their behaviour is a little different, all five of the assertion functions that are available to us within Swift, cause execution of our application to halt, an optional message to be printed and the application to be left in a state where we can debug both the state of our application and the events that led up to the assertion failure.
When it comes to designing, implementing and debugging our code, this can be extremely useful. Not only do assertions allows us to articulate our critical assumptions within code they also help us to design code that ensures the conditions leading up to those assertions are highly unlikely, whilst simultaneously providing us with flexibility and control over when those assertions are enforced.
In some cases, developers advocate that you should only use assertions when developing and debugging, whilst others recommend leaving them in in production as well. My personal preferences lean toward the latter of these two camps. My view is that although triggering an assertion failure is not good, and is definitely not a great experience for end users, I would far rather know about the failures and attempt to fix them, either though additional handling and checking in our code or redesigning things so ensure that the situations leading up to the assert failures don’t occur. For me I feel that this is a better approach to improving the quality of my applications. Whatever your views though, there is no escaping it – assertions in Swift are a very useful tool and one that you should definitely think about using.