Destroy All Ifs — A Perspective from Functional Programming
The Anti-IF Campaign currently stands at 4,136 signatures, and there’s a reason: conditional statements are frequently a troublesome source of bugs and brittle logic, and they make reasoning about code difficult because they multiply the code paths.
The problem is not necessarily with conditionals: it’s with the boolean values
that are required to use conditionals. A boolean value reduces a huge amount of
information to a single bit (
1). Then on the basis of that bit,
the program makes a decision to take one path or a totally different one.
What could possibly go wrong — Right?
The Root Problem
I’ve long argued that, despite all its flaws, one of the great things about functional programming is its intrinsic inversion of control.
In an impure imperative language, if you call some function
void doX(State *state), the function can do anything it wants. It can modify
state (both passed in and global), it can delete files, it can read from the
network, and it can even launch nukes!
In a pure functional language, however, if you call some function
doX :: State -> IO (), then at most it’s going to return a value. It can’t
modify what you pass it, and if you like, you can ignore the value returned by
the function, in which case calling the function has no effect (aside from
sucking up a little CPU and RAM).
IO in Haskell is not a strong example of an abstraction that
has superior reasoning properties, but the fundamental principle is sound: in
pure functional programming languages, the control is inverted to the caller.
Thus, as you’re looking at the code, you have a clearer sense of what the functions you are calling can do, without having to unearth their foundations.
I think this generalizes to the following principle:
Code that inverts control to the caller is generally easier to understand.
(I actually think this is a specialization of an even deeper principle — that code which destroys information is generally harder to reason about than code which does not.)
Viewed from this angle, you can see that if we embed conditionals deep into functions, and we call those functions, we have lost a certain amount of control: we feed in inputs, but they are combined in arbitrary and unknown ways to arrive at a decision (which code path to take) that has humongous ramifications on how the program behaves.
It’s no wonder that conditionals (and with them, booleans) are so widely despised!
A Closer Look
In an object-oriented programming language, it’s generally considered a good practice to replace conditionals with polymorphism.
In a similar fashion, in functional programming, it’s often considered good practice to replace boolean values with algebraic data types.
For example, let’s say we have the following function that checks to see if some target string matches a pattern:
match :: String -> Boolean -> Boolean -> String -> Bool match pattern ignoreCase globalMatch target = ...
(Let’s ignore the boolean return value and focus on the two boolean parameters.)
Looking at the
match function, you probably see how it’s very signature is
going to cause lots of bugs. Developers (including the one who wrote the function!)
will confuse the order of parameters and forget the meaning.
One way to make the interface harder to misuse is to replace the boolean parameters with custom algebraic data types:
data Case = CaseInsensitive | CaseSensitive data Predicate = Contains | Equals match :: String -> Case -> Predicate -> String -> Bool match pattern cse pred target = ...
By giving each parameter a type, and by using names, we force the developers who call the function to deal with the type and semantic differences between the second and third parameters.
This is a big improvement, to be sure, but let’s not kid ourselves: both
Predicate are still “boolean” values that have 1 bit of information each,
and inside of
match, big decisions will be made based on those bits!
We’ve cut out some potential for error, but we haven’t gone as far as our object-oriented kin, because our code still contains conditionals.
Fortunately, there’s a simple technique you can use to purge almost all conditionals from your code base. I call it, replace conditional with lambda.
Replace Conditional with Lambda
When you’re tempted write a conditional based on boolean values that are passed into your function, I encourage you to rip out those values, and replace them with a lambda that performs the effect of the conditional.
For example, in our preceding
match function, somewhere inside there’s
probably a conditional that looks like this:
let (pattern', target') = case cse of CaseInsensitive -> (toUpperCase pattern, toUpperCase target) CaseSensitive -> (pattern, target)
which normalizes the case of the strings based on the sensitivity flag.
Instead of making a decision based on a bit, we can pull out the logic into a lambda that’s passed into the function:
type Case = String -> String data Predicate = Contains | Equals match :: String -> Case -> Predicate -> String -> Bool match pattern cse pred target = ... let (pattern', target') = (cse pattern, cse target)
In this case, because we are accepting a user-defined lambda which we are then applying to user-defined functions, we can actually perform a further refactoring to create a match combinator:
type Matcher = String -> Predicate -> String -> Bool caseInsensitive :: Matcher -> Matcher match :: Matcher
Now a user can choose between a case sensitive match with the following code:
match a b c -- case sensitive caseInsensitive match a b c -- case insensitive
Of course, we still have another bit and another conditional: the predicate flag.
Somewhere inside the
match function, there’s a conditional that looks at
case pred of Contains -> contains pattern' target' Equals -> eq pattern' target'
This can be extracted into another lambda:
match :: String -> (String -> Bool) -> String -> Bool
Now you can test strings like so:
caseInsensitive match "foo" eq "foobar" -- false caseInsensitive match "fOO" contains "foobar" -- true
Of course, now the function
match has been simplified so much, it no longer
needs to exist, but in general that won’t happen during your refactoring.
Note that as we performed this refactoring, the function became more general- purpose. I consider that a side-benefit of the technique, which replaces highly- specialized code with more generic code.
Now let’s go through a real world case instead of a made-up toy example.
The PureScript compiler has a publish tool, with a function defined approximately as follows:
publish :: Bool -> IO () publish isDryRun = if isDryRun then do _ <- unsafePreparePackage dryRunOptions putStrLn "Dry run completed, no errors." else do pkg <- unsafePreparePackage defaultPublishOptions putStrLn (A.encode pkg)
The purpose of
publish is either to do a dry-run publish of a package (so the
user can make sure it works), or to publish the package for real.
Currently, the function accepts a boolean parameter, which indicates whether or not it’s a dry-run. The function branches off this bit to decide the behavior of the program.
Let’s unify the two code branches by extracting out the options, and introducing a lambda to handle the different messages printed after package preparation:
type Announcer = String -> IO String dryRun :: Announcer dryRun = const (putStrLn "Dry run completed, no errors.") forReal :: Announcer forReal = putStrLn <<(A.encode pkg) publish :: PublishOptions -> Announcer -> IO () publish options announce = unsafePreparePackage options >>= announce
This is better, but it’s still not great because we have to feed two parameters
into the function
publish, and we’ve introduced new options that don’t make
sense (publish with dry run options, but announce with
To solve this, we’ll extend
PublishOptions with an
announcer field, which
lets us collapse the code to the following:
forRealOptions :: PublishOptions forRealOptions = ... dryRunOptions :: PublishOptions dryRunOptions = ... publish :: PublishOptions -> IO () publish options = unsafePreparePackage options >>= announcer options
Now a user can call the function like so:
publish forRealOptions publish dryRunOptions
There are no conditionals and no booleans, just functions. You never need to decide what bits mean, and you can’t get them wrong.
Now that the control has been inverted, the caller of the
has the ultimate power, and can decide what happens deep in the program merely
by passing the right functions in.
If you don’t have any trouble reasoning about code that overflows with booleans and conditionals, then more power to you!
For the rest of us, however, conditionals complicate reasoning. Fortunately, just like OOP has a technique for getting rid of conditionals, we functional programmers have an alternate technique that’s just as powerful.
By replacing conditionals with lambdas, we can invert control and make our code both easier to reason about and more generic.
So what are you waiting for?
Go sign the Anti-IF Campaign today. :)
P.S. I’m just joking about the Anti-IF campaign (but it’s kinda funny).
After writing this post, it has occurred to me the problem is larger than just boolean values.
The problem is fundamentally a protocol problem: booleans (and other types) are often used to encode program semantics.
Therefore, in a sense, a boolean is a serialization protocol for communicating intention from the caller site to the callee site.
At the caller site, we serialize our intention into a bit (or other data value), pass it to the function, and then the function deserializes the value into an intention (a chunk of code).
Boolean blindness errors are really just protocol errors. Someone screwed up on the serialization or deserialization side of things; or maybe just caller or callee misunderstood the protocol and thought different bits had different meanings.
In functional programming, the use of lambdas allows us to propagate not merely a serialized version of our intentions, but our actual intentions! In other words, lambdas let us pull the intentions out of the callee site, and into the caller site, since we can propagate them directly without serialization.
In older programming languages, this could not be done: there was no way to ship semantics (a hunk of code) from one part of the program to another. Today’s programming languages support this style of programming (to varying degrees), and functional programming languages encourage it.
Inversion of control, in this limited sense, is about removing the possibility of a protocol error by giving the caller direct control over semantic knobs exposed by the callee.
Kinda cool, eh?