Precondition and Assertion Best Practices

From Coder Merlin
Revision as of 15:54, 5 February 2022 by Jeff-strong (talk | contribs) (→‎Conclusion: Ed correction review; references look good)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Within these castle walls be forged Mavens of Computer Science ...
— Merlin, The Coder

Overview[edit]

In Swift, an important debugging feature one must know is to use Assertions and Preconditions. In brief, these are tools in Swift that allow you to assert that certain aspects of a program are true or declare a precondition before executing the rest of the program. The following explains how to use these features, why they should be used liberally, and where you might want to implement them.

Definition of Assertion[edit]

In Swift, assert (the keyword for assertion) is a useful testing and debugging tool to help identify errors in code, especially during development.

Meaning[edit]

According to the Cambridge dictionary, the definition of the word assert is, "a statement that you strongly believe is true." [1]

Apple Documentation[edit]

"Use this function for internal sanity checks that are active during testing but do not impact performance of shipping code." [2]

Description[edit]

The assert function helps to enforce using valid data and if that is not the case, to ensure that the program terminates more predictably. This helps with debugging. Stopping the code execution as soon as an invalid state is detected also helps limit the damage caused by that invalid state, before compiling the code.

Similar to assert functions, preconditions are also useful to locate or handle errors in the code. The assert function is used in programs to detect mistakes and incorrect assumptions only in debug builds. Precondition functions are used to locate the errors in the code, both in debug and production builds.

Usage[edit]

To start, let's quickly examine these functions to better understand their purpose. To start, understand that this article looks at the following four methods: Assert(), Precondition(), AssertionFailure(), and PreconditionFailure().

Assert is found in many other languages such as C. It's used to perform what are called sanity-checks. Essentially, these are for areas of code where a developer wants to confirm relatively simple parts of a program that are assumed to be true [2]. An assertion takes a boolean as its first parameter. You assume that this boolean will evaluate to true. However, if the assertion was not true, the program stops executing and "crashes." These assertions are meant to be used solely in a testing/debugging stage.

AssertionFailure is similar to Assert; however, it lacks any boolean parameter [3]. This means an Assertion Failure always results in the app crashing. This function is meant to be used for when the program failed something that it shouldn't have, so the app will crash. But as with assert, assertionFailure is meant to be used for debugging environments.

Precondition, is different because it can be used in both production and debugging environments. [4]It works almost the same as assertions in this regard. The use case of a precondition is that a condition must evaluate to true, or the program cannot practically continue. Because assertions can be false but wouldn't necessarily mean the program has to stop executing, assertions are simply ignored during production. However, a precondition means that the application must stop executing whether it is in production or a testing environment.

PreconditionFailure is essentially the precondition counterpart to AssertionFailure[5]. These are used for when a precondition has failed and, therefore, lacks a boolean parameter. This is because if the program has gotten to the point where it's attempting to execute this line of code, something has gone wrong and the application must stop running. As with Precondition, this function can be used in production.

How Does the Assert Function Work?[edit]

With debugging, it is useful to have a function that helps to confirm/assert if a condition is reached before executing the code with a boolean expression value.

If the boolean condition is asserted to be "true," the program executes. If the program evaluates the condition to be "false," it stops and points to the exact location where the problem arose.

Benefits[edit]

In the world of software, there's a term called Defensive programming. It means ensuring that a program will continue to function under unforeseen circumstances. The tools just introduced are a phenomenal way of ensuring this. Let's examine the benefits both Assertions and Preconditions provide to you.

Why We Need assert[edit]

In real-life programming, two segments—namely the code associated with debugging and the code associated with final application—are separated with very clear distinctions. This is done because in the debugging phase, many possible exceptions occur and stress testing is performed to check and analyze the plausible pool of outcomes of the concerned code. Naturally, in every programming language, certain keywords and functionalities are used to help you with debugging.

Imagine a situation in which you have prepared a piece of software with thousands of lines of code. Now imagine that a bug exists at line 15 that you need to fix. But if you run the application like a user would, it executes its entire length of code. This process takes place again and again, which takes up time, resources, etc. To avoid this, many of the functionalities or methods used for debugging can focus on a certain part of code. After carrying out the examination, it will immediately "crash" the application, if a certain condition is met, so that the application no longer needs to execute through the entire program.[6]

These practices are useful for putting checks in the application code so as to avoid the any harmful ramifications if you somehow reach a situation that should not be happening. In this case, crashing the application is executed with the functions like assert.

Going DeeperGoingDeeperIcon.png
  • As the name implies, assertion makes it possible to assert if a certain condition is true before continuing the code's execution.

Assertions[edit]

The quintessential feature of Assertions is that they are ignored for production. This is incredibly valuable because it allows you to write in a series of assertions, perform the necessary sanity checks during debugging, and not have to worry about these checks affecting the production build. This is far superior than simply using an If statement because you have to go back and manually delete these If statements before you can use the program in an actual application; and an If statement makes the assumption that the error that is being tested for is expected to happen and that there is a handling of this error in the code.

Instead, Assertions allow you to perform many checks for an application to ensure that the program does not have any logic-errors. A logic error simply refers to when you have faulty logic when trying to execute a program, such as if a function doesn't return the desired output. Observe the following for several instances on how assertions should be used.

Primary Assert Functions[edit]

Swift has two primary assert functions:

assert(_:_:file:line:)

and

assertionFailure(_:file:line:)

Function:

assert(_:_:file:line:)

Declaration:

func assert(_ condition: @autoclosure () -> Bool, _ message: @autoclosure () -> String = String(), file: StaticString = #file, line: UInt = #line)


Function:

assertionFailure(_:file:line:)

Declaration:

func assertionFailure(_ message: @autoclosure () -> String = String(), file: StaticString = #file, line: UInt = #line)

Debugging Examples with Assertions[edit]

assert

let age = -3
assert(age >= 0, "A person's age can't be less than zero.")
// This assertion fails because -3 is not >= 0.

In this example, the code execution continues if the expression age >= 0 evaluates to true. Otherwise, the expression evaluates to false and the assertion fails, terminating the application.

assertionFailure The assertionFailure(_:file:line:) function is used to indicate that an assertion has failed. For example:

if age > 10 {
    print("You can ride both a bicycle or a ferris wheel")
} else if age >= 0 {
    print("You can ride only the ferris wheel.")
} else {
    assertionFailure("A person's age can't be less than zero.")
}

Assert is an invaluable tool to identify and catch unrecoverable errors.

Assertion Examples[edit]

For the first example, imagine receiving input from a user and putting that data into an array. Before iterating through the array, you can use an Assertion to make sure the array was populated with more than one item.

let array = [5]
assert(array.count>1, "Only one item in array") // assertion fails
for i in array {
  print(i)
}

Here, if the assertion fails, it could mean there's an issue with collecting user input.

Now for a more elaborate example. Imagine an app is looking for input from the user and, to prevent bad inputs, some logic is implemented. An Assertion Failure can be used to find if the logic is unsuccessful.

let array = ["Class1", "Class2", "Class3"]
print("Enter a number for which class to go")
var choice = ""
while (choice != "1" && choice != "2" && choice != "3"){
  choice = readLine()!
}
switch choice {
  case "1": print(array[0]);
  case "2": print(array[1]);
  case "3": print(array[2]);
  default: assertionFailure("While loop logic not working") // AsssertionFailure never called
}

Here, the AssertionFailure is never called because the logic is sound.

Quiz[edit]

1 Assertions can be used in:

Only development phase
Only debug phase
Both phases

2 Swift assertions have two functions:

False
True

3 How many arguments does a typical assert() function have?

3
5
1
4

4 What is the primary difference between an Assertion and a Precondition?

Preconditions assume that an Assertion will fail
Assertions can print a message, but Preconditions can't
Assertions should not be used in production, but Preconditions should be used in debugging and production
Preconditions and Assertions are the same

5 What is a sanity check's purpose?

To provide checking for correct syntax
To allow you to check a feature you think should be true
To check a complex portion of code where you are entirely unsure what the output could be
To provide checking for correct logic

6 Why are Assertions better than If statements for debugging?

Assertions document in the code explicitly that something unexpected has happened; but an If statement that indicates the outcome was expected
An If statement requires that a function or method be invoked instead of stopping the program
Assertions can be ignored automatically when the application goes into a production build
Assertions and If statements are the same and can be used interchangeably

7 What is the main benefit of using Assertions?

To find erroneous outputs in parts of code
To prevent corruption when going into production
To optimize code so that it can have higher performance by ignoring areas of logic
To maintain logic throughout a program that is assumed to be true

8 In what scenario would an Assertion or AssertionFailure be used?

To test the output of a function follows the logic that was expected
To stop the execution of a program for attempting to do something that was assumed impossible
To override user input or internal logic in a program if given unusable data
To stop execution of a program before files start to be corrupted

9 True or False: A Failed Assertion does not mean the program must stop executing.

True
False

10 True or False: assert() is mainly used to check your code for internal errors.

True
False


Other Assert Functions[edit]

In addition to the two Assert functions, three more assertion functions are used in Swift. They are:

  • Preconditions
  • Precondition failure
  • Fatal error


Preconditions[edit]

Preconditions provide an invaluable tool to defensively program. By using this feature, you can say that you do not expect a certain scenario to happen, but if it does, stop running. Of course, in a production build, this will be less than ideal because it means the application has crashed while being used. However, in testing, if a precondition is thrown, it allows you to find these potentially critical errors and address them before being shipped to a user.

However, even if the precondition had to be activated in a production build, it still provides benefits to the user because the program will simply terminate. This means the codebase and data are safeguarded from any corruption or vulnerabilities that could come up from having preconditions that are not met.

In this way, by liberally using the Precondition and Assertion features together, you can find any possible mistakes in logic, both small and critical, and address these bugs before they affect user experience. You can also provide safeguards if issues arise during production.

Observe the following for examples on how to use Preconditions in an application.

  • precondition()

At first glance, both the assert precondition function look very similar, and they each accept the same input parameters. When executed they might appear to work similarly, but they are different. We will get to later. First, let's have a look at this example:

precondition(2 > 3, "2 wasn't greater than 3")
// Causes an assertion failure and prints "2 wasn't greater than 3" to the console.

In this case, the same outcome could have been obtained by using an assert function. The difference between the two lies in the case where we use a mode of optimization other than the default. In the default mode, both of the functions behave similarly. But in the case of higher optimization levels, no evaluation takes place for the function assert(_:_:file:line:). For achieving higher levels of optimization but with similar circumstances, the function precondition(_:_:file:line:) is still evaluated. In simpler words, if we need to check the assertion functions in the builds other than default builds (release build), we should use precondition(_:_:file:line:) instead of assert(_:_:file:line:).

  • preconditionFailure()

If we consider the assert(_:_:file:line:) and precondition(_:_:file:line:) as a pair in form of counterparts from certain perspectives, in the same way, we will have assertionFailure(_:file:line:) and preconditionFailure(_:file:line:) as the equivalent counterparts.

Following the previous couple, assertionFailure(_:file:line:) and preconditionFailure(_:file:line:) have exactly same set of parameters and the outcomes are the same with the default debug mode. In the case of release mode, the function execution takes place for preconditionFailure(_:file:line:), but for assertionFailure(_:file:line:), no execution takes place.

  • "fatalError()"

At first glance, preconditionFailure(_:file:line:) and "fatalError(_:file:line:)" look the same. They both accept the same parameters with an optional message and an optional file. Also, both of them get executed in the optimization levels -Onone and -O. However, there are a few differences.

The difference can be demonstrated with an example: imagine we have a function that takes an integer index and looks up a certain String value, depending on the given index. But depending on other conditions, we know that the index values should never be greater than 2. In this case we can write a function 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)

The compiler raises an error when we run the code. It mentions that the function does not return a String value in the case of the switch statement. This can be solved by calling the abort() function, just after the assertionFailure(_:file:line:). This makes the code look like:

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)

By adding the abort() function, we have solved the issue, but it is not an efficient way to handle the problem. Instead, we can use fatalError(_:file:line:). The major difference is that in the declaration of fatalError() there is an additional Swift attribute—the @noreturn attribute. With this attribute, the compiler is told not to return to the calling function that has invoked the method within which fatalError(_:file:line:) was called. This is done because the compiler should be instructed to terminate the program when encountering this. In such cases, the compiler does not check the return value from the default switch statement. So, while taking the advantage of this feature, we can rewrite the code in our example 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)
Going DeeperGoingDeeperIcon.png
  • For all the assertion functions, namely assert(_:_:file:line:) , precondition(_:_:file:line:), assertionFailure(_:file:line:), preconditionFailure(_:file:line:) and "fatalError(_:file:line:)", the evaluation is skipped in the highest mode of optimization level i.e. unchecked release (-Ounchecked)

Precondition Examples[edit]

Let's look at Precondition examples by building on top of the previous example. Imagine we're getting input from the user. We might have a few conditions that are necessary for the program to continue running. Here, there are two preconditions, one to check that the array is empty before its population and another to ensure the array has been populated and the values are unique.

var array = [""]

print("Which Three classes do you have? Type Done when finished")

precondition(array[0]=="") // checks to see if array is empty
while(true){
  let input = readLine()!
  if(input.uppercased() == "DONE"){
    break
  }
  else{
    array.append(input)
  }
}

precondition(array[1] != array[2], "array values not assigned correctly")

print("Enter a number for which class to go")
var choice = ""
while (choice != "1" && choice != "2" && choice != "3"){
  choice = readLine()!
}
switch choice {
  case "1": print(array[1]);
  case "2": print(array[2]);
  case "3": print(array[3]);
  default: assertionFailure("While loop logic not working") // AsssertionFailure never called
}

For the final example, imagine a function is attempting to find a target in an array before immediately breaking. A preconditionFailure can be used if the function does not return anything.

func findTarget(){
                    
  let target = 6
  
  var array = [Int]()
  var i = 0
  
  while (i<5){
    array.append(i)
    i+=1;     
  }
  
  for item in array{
    if(item == target) {
      print(item)
      return
    }
  }
  
  preconditionFailure("target not in array")
    
}
findTarget()

Differences between Assert and Other Constructs[edit]

It might seem that the functionality done by assert is something that can be just as easily done with language constructs like if, which is true to a certain extent, but with Assert, you get additional benefits and control during debugging.

The biggest advantage of assert is that whenever the assertion evaluates to false, the program immediately terminates. In the case of other conditional constructs, such as if, we see that depending on the result of the conditional statement, the program execution takes its path accordingly. This can make it harder to detect exactly where the problem occurred. In the case of assert, it becomes very easy to identify the code segment where the program was terminated. Although we can always use a debugger to identify some issues, the advantage of assert is that we can locate the troubling segment without needing to run with a debugging environment.

Another big advantage of assert is greater control of the code because you might want to remove the assert functions once you are done with the debugging phase. in Swift, we have something called the Level of Optimization, which acts to instruct the compiler how to compile a certain piece of code. Depending on its mode, the assert code segments can be excluded from the final applications. In the case of lower optimizations, the assertions are always checked during building. But in the case of higher optimizations, the assertions are omitted. With this flexibility, you get more control on the overall code structure and can very easily eliminate the assertions when the code is moved to production.

Controlling Compilation Method via Optimization Level[edit]

In Swift, the optimization levels are controlled with the keyword SWIFT_OPTIMIZATION_LEVEL. You can select from three different modes for this keyword. [7]

SWIFT_OPTIMIZATION_LEVEL = -Onone      // default mode or debug
SWIFT_OPTIMIZATION_LEVEL = -O          // fast mode or release
SWIFT_OPTIMIZATION_LEVEL = -Ounchecked // unchecked release mode

By default (-Onone), all the code gets compiled in debug mode. In this mode, no optimization is performed, which makes debugging and tracing the code easier. The second mode is release build, whose equivalent optimization is set as -O. This mode of compilation works by stripping off the variables and method names from the code and then compiling it to yield better performance. With this mode of compilation, although the performance is better, debugging is more complicated than with lower levels of optimization. Last is the unchecked release mode, which is the best option in terms of performance. But with this greater level of optimization comes with a drawback: while compiling your code with this setting, the compiler sets aside a number of checks; it thus comes with some inherent risk and is meant for advanced Swift coders. This level of optimization requires a greater effort to debug.

Hint.pngHelpful Hint

Compiling in the default mode, or debug optimization mode, makes it easier to debug your code. When the performance is the primary concern, unchecked release level of optimization is the best option.

General Coding Practice[edit]

As mentioned, the whole concept of assertions is based on helping with debugging and troubleshooting. Although different kind of assertion functions can become very useful while debugging a chunk of code, it can cause unpleasant experience for the end users. Though there is no prohibition against using assertions in production, many coders prefer to use it solely during debugging and to not leave it in production.


Quiz[edit]

1 What's the benefit of a Precondition?

Provide a way to stop a program in production at any point
To find areas where code cannot practically move forward during debugging
Preconditions can be used during production to prevent errors
To fix syntax errors before they cause errors during execution

2 In what scenario would a Precondition or PreconditionFailure be used?

To use before a function or method that requires certain data or variables to be set a certain way
To use in an area of code that should not have been able to execute, meaning preconditions have failed
To address an area of code where it is assumed necessary conditions will not be present
To use as a last resort following a severe error in a program to prevent file corruption

3 True or False: Both Assertions and Preconditions stop executing a program if they fail.

True
False

4 Why is using Assertion and Precondition a best practice?

Using these features allows you to maintain logic
Using these features allows you to dynamically debug your code
Using these features allows you to anticipate errors linked to your logic and address them
Using these features allows you to address the errors in logic you have found in your code

5 How many optimization levels does the Swift compiler support?

1
3
2

6 True or False: Release mode optimization level is more efficient than debug mode optimization level.

True
False

7 True or False: Unchecked release mode is represented as -O.

True
False

8 What is the difference between assertionFailure() and preconditionFailure():

The method is evaluated in Debug Mode with preconditionFailure()
The method is evaluated in Unchecked release Mode with preconditionFailure()
No difference
The method is evaluated in Release Mode with preconditionFailure()

9 True or False: fatalError() always returns a value.

False
True

10 True or False: precondition() is mainly used at the time of public documentation.

True
False


Key Concepts[edit]

Key ConceptsKeyConceptsIcon.png
  • Assertion is a set of basic functions used primarily for debugging in the Swift.
  • Assertion functions are similar in that the application is terminated when predefined conditions are met.
  • Five kinds of assertion functions are used: assert(_:_:file:line:) , precondition(_:_:file:line:), assertionFailure(_:file:line:), preconditionFailure(_:file:line:) and "fatalError(_:file:line:)"
  • Almost all the functions have similar parameters except for "fatalError(_:file:line:)", which has a special argument: @noreturn
  • Three types of optimization levels can be selected. They are are default mode, release mode, and unchecked release mode. These modes differ in terms of efficiency of code execution.
  • We can consider assert(_:_:file:line:) and precondition(_:_:file:line:) as a pair. They are just like assertionFailure(_:file:line:) and preconditionFailure(_:file:line:), which are respective counterparts.

Conclusion[edit]

All in all, these tools prove to be a significantly more efficient way of debugging an application than a rudimentary If statement. By using these tools, you can significantly improve an application by providing a dynamic testing routine, ensuring that assumptions made hold true, that the logic used holds true, and that the preconditions that are essential to the function of the program are present.

References[edit]