This post is essentially a write up of a talk my friend Johan Lindstrom did years and years ago, which in turn are ideas stolen from other people. This advice is aimed at really novice programmers who heavily rely only the initial pieces of knowledge they leverage when they start out. I don’t see this advice shared a lot online despite being common knowledge in some circles so please forgive me if you think it’s overly simplified beginner stuff.
IF statements in programming are bad. Johan and I worked on an warehouse backend system. One that involved taking orders, reserving stock, doing stock checks etc. At the time we had two warehouses, DC1 in England and DC2 in America, so code would often look like this (examples are transposed from Perl into Scala):
if (warehouse == DC1)
startConveyorBelt()
else
showManualInstructions()
Our code was absolutely full of these bad boys. Hundreds upon hundreds of separate statements throughout enormous legacy monstrosity. This code base will celebrate it’s 20 year anniversary next year.
def printInvoice(warehouse :String) = {
val address = if (warehouse == "DC1") "England" else "America"
val papersize = if (warehouse == "DC2") USLetter else A4
val invoice = generateInvoice(address, papersize)
...
}
Of course, when we added a third warehouse nothing worked and it took an enormous effort to isolate all the behaviour and fix it. Some of the changes were in little blocks that went together. IF <something> assumes a key exists in a map etc or that a function had already been called. Adding the third DC didn’t result in a random blend of features. Just unpredictable crashes and a world of pain.
The way == or != were used would shape the way the default behaviour would play out. Stringification and easy regexs in Perl also made it harder to track down where comparisons or warehouse specific logic even resided.
warehouse.toLowercase == "dc1" // lowercased alternatives
wh == "DC2" // alternative names
warehouse.matches("1") // regexes are seamless in Perl
they aren't so unnoticeably odd
if (letterSizeIsUSLegal) // warehouse derived from something
set earlier and not passed through
Perl doesn’t have the support of rich IDEs to help track references and all these different programming styles that have grown over 20 years means the process of finding these errors involves dozens of GREPs, lots of testing and a lot of code base inspection.
It didn’t take too long to realise that our IF statements should be based on small reusable features (ie. modular reusable components) and not switch on a global “whole warehouse” value. This code would have been much easier to manage:
if (warehouseHasConveyorBelts)
sendRoutingMessage()
else
showDestinationPageOnScreen()
if (shipmentRequiresInvoice) {
val invoice = getInvoiceTemplateForCountry(
getWarehouseCountry(warehouse)
)
Printer.print(invoice)
}
Ultimately however, the problem also extends passed this modularity and the realisation that IF statements themselves are bad. Necessary in a few places and possibly the simplest fundamental building blocks of all programs… but still bad… Lets look at a comparison to find out why.
The history of goto
Many languages like C, C++, Java, VB, Perl etc support the GOTO keyword, which is a language construct that allows you to jump around a function by providing a label next to a statement. GOTO will jump to the named label. Here is an example.
#include <stdio.h>
int main(void) {
int someNumber = 0;
int stop = 0;
BEGIN:
if (someNumber < 23)
goto ADD_THIRTEEN;
printf("hello. app finished with someNumber = %d", someNumber);
stop = 1;
ADD_THIRTEEN:
someNumber += 13;
if (stop == 0)
goto BEGIN;
return 0;
}
The code is really difficult to read since execution jumps around all over the place. You may have difficulties even following the simple example above. Tracking the state of variables is really hard. Pretty much everyone is in agreement that GOTO statements are too low level and difficult to use and that IF, FOR/WHILE/DO loops and a good use of function calls actually make GOTOs redundant and bad practice.
Foreach loops are so much more elegant than GOTO statements because it’s obvious that you’re visiting each element once. It really speaks to the intent of the programmer or algorithm. Do-while-loops make it obvious the loop will always execute at least once. Scala supports .map, .filter, .headOption, dropWhile, foldLeft which all perform very simple well defined operations that convey intent to other people reading that GOTO simply cant.
So if a construct like GOTO is confusion, leads to spaghetti code, and can be replaced with more elegant solutions should we not prefer those alternatives? Of course! IF statements scatter your business logic around and leave it in disjointed locations across your code base that are hard to track, follow and change. They make refactoring hard. IF statements are bad for the same reasons that GOTO statements are bad, and that’s why we should aim to use them as little as possible.
Switching it up
Here’s a collection of constructs that can be used instead of IF statements to keep your application more readable, and more easy to follow and maintain.
Switch Statements
Not exactly much of an improvement, especially in most languages, but Scala’s specifically can be. If your choices extend a Sealed Trait, Scala can warn you which switch statements aren’t exhaustive. No DC3 slipping into DC2’s warehouse code paths!
sealed trait Warehouse
case object DC1 extends Warehouse
case object DC2 extends Warehouse
case object DC3 extends Warehouse
val myWarehouse :Warehouse = DC1
myWarehouse match {
case DC1 => println("europe")
case DC2 => println("america")
}
// scala reports: warning: match may not be exhaustive.
// It would fail on the following input: DC3
Option.map
A super common one, especially for Scala is to map over an optional value only doing something if it exists and doing nothing if it isn’t. This is the functional equivalent of an “if null” check.
invoices.map { invoice => invoice.print() }
Map is way more generic than this. It applies a function to a value inside a Monad and is commonly used to manipulate lists. Please don’t punish my brevity, it’s just an example for my own ends.
Inheritance
Inheritance allows you to override the behaviour of an existing object to do many specific things so it’s absolutely perfect at reducing the use of IF.
trait Warehouse {
def hasAutomation() :Boolean
def address() :String
def isInEurope() :Boolean
}
class DC1 extends Warehouse {
override def hasAutomation = true
override def address = "England"
override def isInEurope = true
}
class DC2 extends Warehouse {
override def hasAutomation = false
override def address = "America"
override def isInEurope = false
}
class DC3 extends Warehouse {
override def hasAutomation = false
override def address = "Europe"
override def isInEurope = true
}
// App is set up once.
val warehouse = if ("DC1") new DC1 else new DC2.
// use in code
if (warehouse.hasAutomation && warehouse.isInEurope)
...
When it comes to adding DC3, we have an interface to extend so we know exactly which methods we need to define in order to specify how a warehouse behaves. Our behaviour is vastly centralised. We only have to extend the initial warehouse setup once as well since we’ve bought everything together.
We can also go a step further and make the Warehouse class responsible for doing things. This removes IF statements even more!
object Printer { def print() = ??? }
object Browser { def handle() = ??? }
case class RoutingInstruction(destination :String)
val REDIRECT = 303
type Invoice = String
trait Warehouse {
def packItem() :Either[String, Boolean]
def generateInvoice() :List[Invoice]
def maybeRouteItem() :Option[RoutingInstruction]
def getNextWebpage() :Option[(Int, String)]
}
class DC1 extends Warehouse {
override def packItem() = Right(true)
override def generateInvoice() = List.empty // no invoice since we are in england
override def maybeRouteItem() = Some(RoutingInstruction("PackingArea11")) // we have automation
override def getNextWebpage() = Some((REDIRECT, "/confirmation/place-on-conveyor"))
}
val warehouse :Warehouse = new DC1
// look, no if statements yet lots of diverse functionality
// being used.
warehouse.packItem()
warehouse.generateInvoice.map { Printer.print }
warehouse.getNextWebpage.map { Browser.handle }
There are some variations of Inheritance I won’t cover, such as Mixins and Traits or Interfaces. They all follow the same theme so I won’t list them individually. The code might be a little crap here because I’m trying to be slightly language independent in my samples.
Function Pointer Tables
You can effectively have cheap object orientation by having a Hash/Map of functions and passing around whole “collections of decisions” together.
def accessGranted() = println("granted!")
def accessDenied() = println("denied!")
val permission = "allowed"
// old, redundant.
if (permission == "allowed") accessGranted() else accessDenied()
// single place for logic.
val mapOfAnswers = Map(
"allowed" -> accessGranted _,
"denied" -> accessDenied _
)
val func = mapOfAnswers(permission) // no if here
func() // executes function which causes println to run
Partial Functions / Closures
Partial functions allow us to build functions using composition which can help mix up and select the appropriate logic without actually having to use IF statements.
def makeAddress(inEurope :Boolean)(country :String)(addressLines :String) =
println(s"$addressLines\n$country\ninEurope: $inEurope")
val europeanFactory = makeAddress(true) _ // variables type
// refers to a function
val britishFactory = europeanFactory("UK")
britishFactory("London")
Closures are functions that reference variables outside of their direct scope. It allows you to do something like this:
def setTimeout(timeMs :Int, onTimeout :Unit => Unit)
val myVariable = 66
def doingMyThing() = println("myVariable")
setTimeout(500, doingMyThing) // setTimeout doesnt have any logic
but does the right thing
Lambdas are typically short hand syntax for functions so this general class of ideas can be used to encapsulate decision making without callers having to use IF statements everywhere.
Dependency Injection
Dependency injection is generally a technique to remove global variables from an application and is just an application of inheritance to a certain degree but it’s perfect for dynamically changing the behaviour of code without using repetitive IF statements.
// Old code with embedded IF statements
class FetchData {
def fetchOrders() :List[Order] = {
if (testMode == true)
List(sampleOrder1, sampleOrder2)
elseif (DC == 1)
httpLibrary.httpGet("http://backend-1/")
else
httpLibrary.httpGet("http://backend-2").andThen(doConvert)
}
}
// New version simply trusts whatever is passed in.
class FetchData(httpLibrary :DCSpecificHttpLibrary, convertor :Option[Order => Order] = None) = {
def fetchOrders() :List[Order] = {
val order = httpLibrary.httpGet() // was built knowing which DC
convertor.map { c => c(order) }.getOrElse(order)
}
}
// testing code would make a fake httpLibrary and pass it in before the test. Real code would use the real one.
Summary
I’m going to stop list alternatives now but hopefully you go away with some interesting thoughts on the subject and possibly an idea that sometimes IF statements can be detrimental if overused.
Some of my examples are really poor, especially my Inheritance one. I was going to model lots of subprocesses of a warehouse like ScreenFlowChartManager, StockCheckManager and make a warehouse point to them but the code was getting too big for a simple example.
I would accept some criticism that some IF statements can’t be avoided and I would accept that some of these alternatives only move the IF statement to another place in the code base. Certainly dependency injection only moves things to when the application starts. Still armed with this knowledge you can write applications which are easier to maintain and move your variables and mutable state around into places that make it easier to work with.
I’ve come across many people who don’t like Microservices. They complain that it fragements the code and adds potential network failures. These people are not stupid and they are not wrong. They just haven’t worked with a true monolith (even if they claim they have).
Microservices do add network latency and there are more potential failures. It does make transactional operations less reliable. It does add serialisation overhead. It does divide teams or offer the chance to split the solution over too many technologies. It does mean you’re app has to cope with multiple different versions in production. It does mean that integration testing scope is limited to smaller individual pieces like unit tests and not true end-to-end tests. It adds overhead in terms of the beaurcracy of adding or changing contracts. It adds documentation and the need to use Swagger endpoints everywhere! It just fundamentally adds more code and therefore a greater chance of bugs.
However, all that overhead is worth it, if your app is so unmanageable it takes 6 hours for the test suite to run. It is worth it, if a series of breaks had made the business enforce some sort of bi-weekly release schedule on you. The knock on effect of that bad decision is that each bi-weekly release is now bigger, more risky, and potentially causing even more failures. You have a monolith if you branch to make a large refactor and by the time you come to merge it back to master, master has moved on by 50 commits by 10+ people that make you feel like you’re back at square one. You have go around to people and ask how to sensibly merge the changes because you don’t know anything about what their code does because they’re in a completely different business domain to you, the acronyms are completely foreign and you’ve never met the person. The project is so large that each Agile team have adopted their own coding guidelines within the same codebase.
In those situations, Microservices are a real way out. Having your system as a collection of smaller microservices means you can drop a lot of these troubles.
“Monolith” does not mean a shitty product, an unreliable product, a clunky slow product or an old product with a lot of technical debt. It means one so frought with edge cases, fear and uncertaintly that even small, isolated and obvious bug fixes are delayed or forced through tons of beaucracy for fear of breaking the critical paths of the application.