The computing scientist's main challenge is not to get confused by the complexities of his own making.
Edsger W. Dijkstra
Conditional logic complicates programs, and is often a mistake. Use polymorphism and program structure to avoid the use of unnecessary if statements.
One of the first introductions most students have to the world of code is that of the if statement. It teaches how to think about logic and conditionals, to view a program through the lens of taking action based on state. It is also one of the primary causes of program complexity (as measured by the number of linearly independent paths through the code, or “cyclomatic complexity”), is mostly misused, and is a major source of confusion for those trying to understand the flow of the code.
Every if statement creates a branch in the execution path that the code could take. The problem with this is that each branch and its implications must be fully understood. Branching logic has a high cognitive load: it is difficult to reason about multiple things at the same time.
Imagine if this book was available in multiple languages, and in order to find the subsequent section in your language, you needed to read the prior section that would tell you which page to go to next. Then, on that page, you needed to follow a further delineation based on dialect, and perhaps an even further one based on phrasing.
Once you combined the phrasing, dialect, and language subsections together you could deduce the meaning, after which you would backtrack in order to determine which page has the next section, before following the entire process again. This would be slow, tedious, and frustrating, which is why books do not work that way.
Instead, books are constructed for a given language, and when they are translated, they are translated in whole and published for that language. To make something comprehensible, it must be as linear as possible.
Often, if statements can be replaced with map values.
For example, using Python:
def calculate_discount(member_level, purchase_amount):
if member_level == "Silver":
discount = 0.95
elif member_level == "Gold":
discount = 0.90
elif member_level == "Platinum":
discount = 0.85
else:
discount = 1
return purchase_amount * discount
A clearer way to write this is by using a map (or dict), where the conditional has been replaced with a simple key lookup in the map:
membership_levels = {
"Silver": 0.95,
"Gold": 0.9,
"Platinum": 0.85
}
def calculate_discount(member_level, purchase_amount):
discount = membership_levels.get(member_level, 1)
return purchase_amount * discount
Using this pattern, it is trivial to add new levels if more are defined. Additionally, the reader can rapidly understand which membership levels are currently defined and their related discounts, ensuring MTC is kept low.
To represent more complex values, we should use the polymorphic type system that almost all languages have (see Chapter 5, “Polymorphism”). In object-oriented languages, we can associate state with behaviors.
In the following bad example, we use a property shape_type, when instead we should be using the built-in type system:
class Shape:
def __init__(self, shape_type, dimensions):
self.shape_type = shape_type
self.dimensions = dimensions
def area(self):
if self.shape_type == "Circle":
radius = self.dimensions[0]
area = 3.14 * radius * radius
elif self.shape_type == "Rectangle":
length = self.dimensions[0]
width = self.dimensions[1]
area = length * width
else:
area = 0.0
return area
Having recognized that we can use the type system, we can replace the if logic with polymorphism. We eliminate shape_type as a property, and create classes with names corresponding to their shape_type value: shape_type of circle becomes class Circle, for example.
class Shape:
def area(self):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius * self.radius
class Rectangle(Shape):
def __init__(self, length, width):
self.length = length
self.width = width
def area(self):
return self.length * self.width
In functional languages, depending on the type system, we can still do something similar by using dynamic dispatch, again associating behaviors with types rather than using conditional logic to emulate a type system. In this example, Utility.area will execute the correct behavior based on the type of the argument, so there is no need for an if statement.
require Math
defmodule Circle do
defstruct [:radius]
end
defmodule Rectangle do
defstruct [:length, :width]
end
defprotocol Utility do
@type shape :: map()
@spec area(shape) :: integer()
def area(shape)
end
defimpl Utility, for: Circle do
def area(shape), do: Math.pi * shape.radius * shape.radius
end
defimpl Utility, for: Rectangle do
def area(shape), do: shape.length * shape.width
end
defmodule Main do
IO.puts Utility.area %Circle{radius: 5}
IO.puts Utility.area %Rectangle{length: 5, width: 4}
end
This is excellent, as we are able to add behaviors orthogonally. For example, if we need behavior to calculate the area of a hexagon, we would simply add another function, and not modify any existing code. This allows us to follow an established pattern to introduce new behavior, it reduces the risk of introducing bugs, and it eliminates the need to understand all the existing area functions before working with the code.
Registries combine values and polymorphism in a practical pattern for creating composable behaviors.
For example, we may have the following embedded if statements (using Python):
class DateFormatter:
def __init__(self):
self.format_name = "Local Time"
def format(self, d):
if self.format_name == "Local Time":
return d.strftime("%d/%m/%Y %H:%M:%S")
elif self.format_name == "UTC Time":
return (d.astimezone(ZoneInfo("UTC"))
.format("%d/%m/%Y %H:%M:%S"))
elif self.format_name == "ISO Format":
return d.isoformat()
The content of those conditional statements is complex enough that we cannot create a simple value map for them, but we can map polymorphic functions using a registry.
def _format_local(d):
return d.strftime("%d/%m/%Y %H:%M:%S")
def _format_utc(d):
return d.astimezone(ZoneInfo("UTC")).format("%d/%m/%Y %H:%M:%S")
def _format_iso(d):
return d.isoformat()
class DateFormatter:
def __init__(self):
self.formats = {
"Local Time": _format_local,
"UTC Time": _format_utc,
"ISO Format": _format_iso
}
self._format_name = "Local Time"
@property
def format_name(self):
return self._format_name
@format_name.setter
def format_name(self, value):
if value not in self.formats:
allowed = ', '.join(self.formats.keys())
msg = (f"Invalid format_name {value}, "
f"must be one of: {allowed}")
raise ValueError(msg)
self._format_name = value
def format(self, d):
return self.formats[self.format_name](d)
Let's Get Technical
There are challenges called “code golf” where the objective is to accomplish a coding task in as few characters as possible. The solutions to these challenges are often completely incomprehensible without spending extensive time to understand them, much longer than it would take to understand a solution with more lines.
You will notice this code is longer than the example with if statements.
This is because we added validation when setting format_name using the registry approach; it would have required additional if statements with hardcoded values in the prior example.
Often, these trivial examples going from some bad practice to an improved rewrite involve an increase in the lines of code.
This is not a bad thing.
Code is written once (rewrites, after all, are different code that is also written once), but read many times.
More lines that are easier to understand is better than fewer lines that cause confusion.
In some cases, we must work with complex conditionals. In those instances, the set of conditionals can be modelled via an array of combined types, where those types have some construction function associated with them.
As an example, let us create a Repeater class, which can repeat until either a count, while, or duration value is met, and will repeat until any of those conditions, should they be set, are met. Here is a naive implementation, using if statements:
class Repeater:
def __init__(self, options):
if 'while' in self.options:
try:
self.evaluator = PredicateEvaluator(self.options['while'])
except EvaluationParseException as e:
raise RepeaterException(
f"Cannot parse predicate exception " +
self.options['while']
) from e
self.options = options
self.iteration = 0
self.start_time = time.time()
def is_done(self, current_state):
if 'count' in self.options:
if self.options['count'] == self.iterations:
return True
else:
self.iteration += 1
if 'while' in self.options:
return self.evaluator.evaluate(current_state)
if 'duration' in self.options \
and time.time() - self.start_time > self.options['duration']:
return True
return False
Note that by keeping the different behaviors in the same class, we have coupled the behaviors of each.
while at the beginning in order to create the evaluator, which is not used by the other two repetition types.iteration and start_time are only used by their associated repetition types.while.count repetition needs to internally modify state, and thus nests if statements.All of this complexity is multiplied if we want to create a good __str__ (toString) function for representing both the current state and objective state, or if we want a function to calculate an estimate of progress, both of which are entirely reasonable additions to a repetition system.
Contrast that with using polymorphism and a registry to solve the problem. In the following code, we create a model of repetition using policies, which have a single function to indicate whether or not they are done. Each policy has a “builder” function attached to it, and those “builder” functions are added to a registry. As a result, the registry is able to construct whichever set of policies is needed, reliably, at runtime.
class RepetitionRegistry:
def __init__(self, system):
# For brevity, we are defining the policies here,
# but it would be better to inject them during the building phase,
# as we would not need access to "system" in that case.
# See Chapter 4, "New".
self.policy_creators = [
CountPolicy.make,
WhilePolicy.from_system(system),
DurationPolicy.make,
]
def policies(self, options):
policies = []
for creator in self.policy_creators:
possibility = creator(options)
if possibility is None:
continue
policies.append(possibility)
class CountPolicy:
def __init__(self, count):
self.count = count
self.iteration = 0
@classmethod
def make(cls, options):
if 'count' not in options:
return None
return cls(options['count'])
def is_done(self):
self.iteration += 1
return self.iteration > self.count
class WhilePolicy:
def __init__(self, evaluator):
self.evaluator = evaluator
@classmethod
def from_system(cls, system):
# Let's say `system` has a `get_state`
# function to retrieve the current system state
def make(options):
if 'while' not in options:
return None
return cls(Evaluator(system, options['while']))
return make
def is_done(self):
return self.evaluator.evaluate()
class DurationPolicy:
def __init__(self, duration):
self.done_at = time.time() + duration
@classmethod
def make(cls, options):
if 'duration' not in options:
return None
return cls(options['duration'])
def is_done(self):
return time.time() > self.done_at
Let's Get Technical
Despite this chapter cautioning you about using `if`, we use it multiple times in this example. That is because these conditionals are being used for construction, rather than behavioral logic. See "Good Uses of If" later in the chapter.
An interesting property emerges from this structure: before, the is_done function returned True if any of the policies were considered done, so it always used the quickest policy, which was inflexible. Now, we can could organize the policies in whatever way we want. For example:
# Same behavior as before, done if any policies are done:
any([policy.is_done() for policy in policies])
# Require all policies to be done:
all([policy.is_done() for policy in policies])
# Require at least two to be done:
sum([policy.is_done() for policy in policies]) > 2
Additionally, it would be straightforward to add functions to the policies themselves to represent how they are configured (time remaining, iterations remaining, or a representation of the predicate function), or even their progress.
The key value of this model is that it scales horizontally, and could be combined orthogonally with other policies and registries. For example, perhaps there are policies that dictate which actions must be executed on a given iteration, or the order of those actions; that functionality could be added without any modification to the functions for controlling the repetition behavior.
Writing code like this is what allows us to be wrong in correctable ways.
Ultimately, the problem with if statements is not actually one associated with the keyword. Rather, it stems from the use of conditional logic to control program flow. The correct practice is instead to control flow using program structure and types.
Switches, in particular, can almost always be rewritten to use polymorphism and registries to great effect. By their nature, switch statements operate on singular value types, providing a strong signal to the developer that the problem would be better modeled with polymorphism.
Using switch statements generally results in functions that must do multiple things, and as a result have long parameter lists to accommodate all the potential things they could do.
function area(
shape, side, length, width,
radius, minor_axis, major_axis
) {
let area = null;
switch(shape) {
case 'square':
area = side * side;
break;
case 'rectangle':
area = length * width;
break;
case 'circle':
area = Math.PI * radius * radius;
break;
case 'oval':
area = Math.PI * minor_axis/2 * major_axis/2;
break;
}
return area;
}
area('rectangle', null, 3, 4, null, null, null);
Although the above Javascript example is extreme when it comes to unused parameters, this is a common side effect of using conditionals rather than polymorphism: functions require data that will only be used when a particular conditional path is hit. This decreases readability and increases MTC for maintainers.
Instead, we can separate out the functionality and again use a registry to allow the caller to choose the behavior. By doing this, we limit the parameters to only the ones that are necessary, and we ensure that adding new behaviors only requires an addition to the registry, without altering existing code.
class Square {
constructor(side) {
this.side = side;
}
area() {
return this.side * this.side;
}
}
class Rectangle {
constructor(length, width) {
this.length = length;
this.width = width;
}
area() {
return this.length * this.width;
}
}
class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return Math.PI * this.radius * this.radius;
}
}
class Oval {
constructor(minor_axis, major_axis) {
this.minor_axis = minor_axis;
this.major_axis = major_axis;
}
area() {
return Math.PI * this.minor_axis/2 * this.major_axis/2;
}
}
const shapes = {
Square,
Rectangle,
Circle,
Oval
}
(new shapes['rectangle'](length, width)).area();
As before, we can attach additional reporting or related functions to the types that we have created.
The process of going from if statements to polymorphism involves creating behavioral models, which is the representation in code of how users can interact with the system, and how to enhance the system to enable new interactions. Finding correct models can be challenging, as there may be multiple ways to pull apart the functionality. In such a case, the “Rule of Three” can be a helpful guide: often a behavior cannot be modeled correctly until there are at least three known sub-behaviors. In the same way that three points define a plane, or three locations are needed to triangulate a position, two are usually insufficient to establish a trend and determine the model.
In cases where the correct model is unclear, it may be preferable to keep a single conditional and an inaccurate but simple model, rather than attempting to create a polymorphic model with so little data.
For example, it might not be clear how to create the membership_levels if there are only two levels initially: premium and default.
However, when a third level is added, it becomes clear that the choice is not binary (few choices are), and in fact there is a set of options; therefore, the code must be structured accordingly.
When there are many unknowns, do not try to come up with the most comprehensive solution. Instead, recognize that good design emerges from deep understanding of the problem space, and that understanding may take time. Early in a project, prefer simplicity and rigorously reduce MTC. As the project progresses, pay attention to the ways in which the behaviors and code interactions change, and consider how to improve the underlying models.
If the rules in this book are followed, refactoring the code later should not be too difficult. What is difficult, however, is for the maintainer to know that the code should be refactored. I recommend leaving a comment for future programmers describing the intent to delay creating a behavioral model until more information is available.
Focus on keeping the code clear, and even if the initial design is not perfect, it can be corrected later.
Despite how commonly if and conditionals are misused, there are still many valid, important uses.
Input validation is a common one:
def validate(user_input):
choices = ['red', 'blue', 'green']
if user_input not in choices:
raise InvalidInputError(
f"Must select one of {', '.join(choices)}," +
" not \"{user_input}\"")
This type of validation should happen as close to the user input as possible, ideally immediately after the input is received. Often, it should also result in the creation of a typed value, which can then be used by the rest of the program rather than the raw input.
Another good use of if is during the object construction phase, to determine how to build the required object, or whether it should be built at all. In the following example, we have Sequence classes we construct based on provided options.
# These classes only show the "make" function,
# to demonstrate good uses of "if"
class RandomSequence:
@classmethod
def make(cls, options):
if options.get('sequence') != 'random':
return None
return cls()
class ReverseSequence:
@classmethod
def make(cls, options):
if options.get('sequence') != 'reverse':
return None
return cls()
The if statements in those classes are used for building the object, not as part of the business logic, and are thus good uses of if.
The examples in the “Registries” section also demonstrate both the validation use case, and the construction use case.
Using if in algorithms is also entirely reasonable.
In Chapter 4, “New,” we created a class for matching against file contents. Let’s imagine we need another matcher, which checks if a given character is used more than some number of times.
class CharMinUsageMatcher:
def __init__(self, character, threshold):
self.character = character
self.threshold = threshold
def match(self, contents):
count = 0
for c in contents:
if c == self.character:
count += 1
if count > self.threshold:
return True
return False
In the above example, we use if algorithmically to determine if we have matched the given character, and if we have crossed the given threshold.
While if statements and conditionals may not be inherently bad, their use should be carefully considered.
Most often, they are being used instead of the languages provided type system.
The use of polymorphism, mapped values, and registries to accomplish the same goal results in code that is far more flexible, scalable, readable, and critically, easier to understand as the requirements become more complex. As projects grow, conditionals always result in significantly more complexity than polymorphism to accomplish the same task.
One of the most reliable ways to determine whether a section of code will have many bugs is by looking at how many if statements it has. Avoid if.