Our programs usually contain conditional logic. It is what enables them to change behaviour based on external input, changes in internal state or a combination of both. However, conditional logic is also what can make programs hard to understand because it can create alternate execution paths which, when combined or nested, can lead to a combinatorial explosion of possible execution paths through the program. In this post, we explore ways to effectively express conditional logic, with conditional statements, without conditional statements and using a combination of the two approaches.
Contents
Conditional logic vs. conditional statements
It is quite natural to associate conditional logic with conditional statements, the most common being the if and if-else statements. In reality though, conditional logic can be implemented not only using the rich set of conditional statements provided by Swift but also without using any conditional statements at all. This can often make code simpler, more flexible and easier to extend. It pays to take a pragmatic approach, analyzing the requirements in each case to decide what would work best, balancing clarity and simplicity with flexibility and future-proofing.
Let’s say we need to define a function to assign an overall grade to a student based on marks obtained in various courses. Our function will take as its parameter an array of marks obtained and return a letter grade.
Consider the following business rules:
- If the student has not taken a minimum number of courses, the grade will be U (ungraded).
- If the student has taken the required number of courses but not passed a minimum number of courses, the grade will be F (Fail).
- Provided the student has passed the required number of courses, a grade of A, B, C, D or F will be assigned based on the average mark, using predefined grade boundaries.
Assuming that the maximum mark for each course is 100, let’s say we have the following grade boundaries:
- Average mark >= 90: Grade A
- Average mark >= 75: Grade B
- Average mark >= 60: Grade C
- Average mark >= 45: Grade D
- Average mark < 45: Grade F
First we define the following constants to help enforce the business rules.
let minCoursesToTake = 4
let minCoursesToPass = 2
let passingMark = 45
We also define an extension on Array to calculate the average of an array of integers.
extension Array where Element: BinaryInteger {
func average() -> Double {
return isEmpty ? 0 : Double(Int(reduce(0, +))) / Double(count)
}
}
Finally, we define an enum to represent the possible letter grades.
enum Grade {
case A, B, C, D, F, U
}
Having made the above preparation, we are ready to write code to calculate a letter grade given an array of marks obtained in various courses.
Let’s start by seeing an example of how not to write code dealing with multiple conditions.
How not to write conditional code
As a first stab at the problem, we may define a main grade calculation function that tries to deal with all the conditions using nested if-else statements and a helper function that calculates the letter grade from the average mark using a switch statement.
func grade(for marks: [Int]) -> Grade {
if marks.count >= minCoursesToTake {
if marks.filter({ $0 >= passingMark }).count >= minCoursesToPass {
return grade(for: marks.average())
} else {
return .F
}
} else {
return .U
}
}
func grade(for mark: Double) -> Grade {
switch mark {
case 90...:
return .A
case 75..<90:
return .B
case 60..<75:
return .C
case 45..<60:
return .D
default:
return .F
}
}
The above code has two main problems:
- In the main grade calculation function, the nested if-else statements make for unclear control flow and the so-called happy path is double indented. This kind of code is sometimes called the Pyramid of Doom, a play on the pyramid-like shape of the nested code blocks.
- In the helper function, grade boundaries are hard coded. If we need to use different grade boundaries, we will need to modify the code.
Separating early exit conditions
Let’s deal with the first problem identified above. If we are presented with a number of conditions, control flow can become much clearer if we separate conditions that should trigger an early exit and those that should be evaluated as part of the main flow.
We rewrite the main grade calculation function using guard statements, which allow us to evaluate a condition and exit early if the condition evaluates to false.
func grade(for marks: [Int]) -> Grade {
guard marks.count >= minCoursesToTake else { return .U }
guard marks.filter({ $0 >= passingMark }).count >= minCoursesToPass else { return .F }
return grade(for: marks.average())
}
This gets rid of the pyramid and indentation of the happy path. The code is much more compact and the control flow is also much clearer.
We still have an issue, however, which is that there is too much going on in the guard statements. It is not clear at first glance what each guard statement is evaluating. We will likely need inline comments to clarify the intention of the guard statements. There is a further refinement that can be made to deal with this issue, as we see in the next section.
Giving names to early exit conditions
When we have guard statements evaluating complex conditions, it can sometimes be helpful to factor out the conditions into their own functions or computed variables. This serves two purposes. First, it makes the code less dense. Second, it gives us the opportunity to give these functions or variables intention-revealing names so the purpose of the guard statements becomes clear. This can make the code more self-documenting. It does make the code a bit longer, but if it takes a few extra lines of code to enhance clarity and clarify intent, it is almost always a better choice.
func grade(for marks: [Int]) -> Grade {
guard sufficientCoursesTaken(marks) else { return .U }
guard sufficientCoursesPassed(marks) else { return .F }
return grade(for: marks.average())
}
func sufficientCoursesTaken(_ marks: [Int]) -> Bool {
return marks.count >= minCoursesToTake
}
func sufficientCoursesPassed(_ marks: [Int]) -> Bool {
return marks.filter({ $0 >= passingMark }).count >= minCoursesToPass
}
It is noteworthy that in situations where we are using a guard statement for any optional binding declaration, we should not factor out the condition being evaluated. This is because constants or variables assigned a value from an optional binding declaration in a guard statement remain available in the scope enclosing the guard statement. If we factor out the condition being evaluated by a guard statement, we will also lose the safe unwrapping of optionals.
Having dealt with the main grade calculation function, we now turn our attention to the helper function and how we could avoid hard-coding the grade boundaries so we can change them without having to modify the code.
To help accomplish this, we look at a way to express conditional logic without using conditional statements.
Using data structures to encapsulate conditional logic
It is possible and sometimes preferable to express conditional logic without using conditional statements, by embedding the selection logic in an appropriate data structure. This does away with the need to create code branches and can help make control flow clearer.
If the selection can be made based on the position of a particular option in an ordered group of options, an array can provide a simple selection mechanism. We demonstrate this with a function that outputs a formatted date as a string from components passed in as integers.
let daysOfWeek = [
"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
let months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
func formatAsString(date: Int, month: Int, year: Int, dayOfWeek: Int) -> String {
return "\(daysOfWeek[dayOfWeek - 1]), \(date) \(months[month - 1]) \(year)"
}
let dateString = formatAsString(date: 10, month: 5, year: 2017, dayOfWeek: 2)
print(dateString) // Tue, 10 May 2017
In cases where we cannot choose among options using positional references, a Swift dictionary could be the tool of choice.
It is noteworthy that since closures are types in Swift which can be assigned to variables and stored in collections, we can use an array or dictionary to store not just values but also operations, as we will demonstrate in the following section.
Flexible conditional logic
A big advantage of using data structures instead of conditional statements is the ability to change the behaviour or extend the functionality of our code without having to modify it, simply by substituting a new data structure.
Let’s say we are building a simple calculator that can perform binary addition and subtraction operations. Here is one way to build it, using a switch statement.
struct Calculator {
func performOperation(withSymbol symbol: String, on operands: (Decimal, Decimal)) -> Decimal? {
switch symbol {
case "+":
return operands.0 + operands.1
case "-":
return operands.0 - operands.1
default:
return nil
}
}
}
We can use the calculator as follows.
let calc = Calculator()
let symbols = ["+", "-"]
let results = symbols.compactMap { symbol in
calc.performOperation(withSymbol: symbol, on: (10, 4))
}
print(results) // [14, 6]
Note that the return type of the function is Decimal?, so nil can be returned in case an unsupported symbol is passed as an argument. This use of optionals for simple error handling is a common technique for situations where the reason can be easily discerned from the context. For more complex error handling needs, we would normally use the error handling mechanism built into Swift.
What if we subsequently want to extend the functionality of our calculator to add the ability to perform binary multiplication and division operations. Since the selection logic is hard-coded into the switch statement, the only way to modify or add functionality would be to change the code.
Let us look at an alternate design, using a dictionary to encapsulate the selection and calculation logic.
typealias ArithOperation = (Decimal, Decimal) -> Decimal
struct Calculator {
init(_ operations: [String: ArithOperation]) {
self.operations = operations
}
func performOperation(withSymbol symbol: String, on operands: (Decimal, Decimal)) -> Decimal? {
return operations[symbol]?(operands.0, operands.1)
}
private let operations: [String: ArithOperation]
}
The main change from the previous implementation is that our calculator does not contain the actual selection or calculation logic. It is provided using a dependency injection when an instance is created.
let operations: [String: ArithOperation] = [
"+": { $0 + $1 },
"-": { $0 - $1 }]
let calc = Calculator(operations)
let symbols = ["+", "-"]
let results = symbols.compactMap { symbol in
calc.performOperation(withSymbol: symbol, on: (10, 4))
}
print(results) // [14, 6]
With this design, we can add or remove operations as we want, without having to modify any code in the calculator, making our code flexible and extendable.
let operations: [String: ArithOperation] = [
"+": { $0 + $1 },
"-": { $0 - $1 },
"x": { $0 * $1 },
"÷": { $0 / $1 }]
let calc = Calculator(operations)
let symbols = ["+", "-", "x", "÷"]
let results = symbols.compactMap { symbol in
calc.performOperation(withSymbol: symbol, on: (10, 4))
}
print(results) // [14, 6, 40, 2.5]
The right tool for each job
Effective programming is all about knowing the relative strengths and weaknesses of the tools at our disposal and selecting the best tool for the job at hand. When expressing conditional logic, it is often most effective to combine data structures, types, conditional statements and other control flow mechanisms such as loops to find the most effective way to express the required logic.
We now use the techniques introduced above to rewrite the helper function from our grade calculation example without having to hard code the grade boundaries.
typealias GradeBoundary = (grade: Grade, lowerBound: Double)
func grade(for mark: Double, using gradeBoundaries: [GradeBoundary]) -> Grade {
for gradeBoundary in gradeBoundaries {
if mark >= gradeBoundary.lowerBound {
return gradeBoundary.grade
}
}
return .F
}
Here we have combined an array of tuples, a for-in loop and an if statement to express the required logic. This demonstrates that often the solution lies not in choosing between the available tools but finding the most effective way to combine them.
We also modify our main grade calculation function. Instead of hard coding the grade boundaries, we now pass them as an argument to the grade calculation function. This allows us to change the grade boundaries at any time without having to modify the grade calculation code.
func grade(for marks: [Int], using gradeBoundaries: [GradeBoundary]) -> Grade {
guard sufficientCoursesTaken(marks) else { return .U }
guard sufficientCoursesPassed(marks) else { return .F }
return grade(for: marks.average(), using: gradeBoundaries)
}
To implement the grade boundaries assumed for our example, we would use the following array of tuples.
let gradeBoundaries: [GradeBoundary] = [
(.A, 90), (.B, 75), (.C, 60), (.D, 45)]
Our grade calculation function above relies on the grade boundaries in the array to be in the correct order, i.e., from the highest lower bound to the lowest. The function, as implemented above, will not work correctly if the grade boundaries are unordered or ordered in a different way. This could be fine if, for instance, we can guarantee in some other way that the array will always be in the required order. If such a guarantee cannot be given or we want to build a function that will work correctly regardless of the order of the array passed in as the argument, we can make an addition to the function as shown below, which sorts the array in the correct order before using it.
func grade(for marks: [Int], using gradeBoundaries: [GradeBoundary]) -> Grade {
guard sufficientCoursesTaken(marks) else { return .U }
guard sufficientCoursesPassed(marks) else { return .F }
let sortedGradeBoundaries = gradeBoundaries.sorted(by: { $0.lowerBound > $1.lowerBound })
return grade(for: marks.average(), using: sortedGradeBoundaries)
}
Now we can pass in the grade boundaries array in any order and still get the correct result.
Conclusion
There is usually more than one way to incorporate conditional logic into our programs. Which way is chosen in a particular situation depends on a number of factors. The point is to choose the approach that best meets the needs in each case and leads to simple and appropriately flexible code. Conditional statements, data structures, types and loops are all tools in the programmer’s toolbox. Which tool to employ in what situation depends on an assessment of the nature and complexity of the problem and the degree of flexibility required to deal with variations.
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 conversation on Twitter.
ℳ i s h a says
Tnx for the article, it was very informative. Can you write more about avoiding nil, why there is a widespread belief like the “worst mistake of computer scenes”.
Khawer Khaliq says
Thanks Misha. I am happy that you found the post useful. I had been thinking of writing about optionals in Swift; how they can be invaluable but also hold pitfalls for the uninitiated. An article on optionals will be coming soon.