Destroy All IFs: A Perspective From Functional Programming
Destroy All IFs: A Perspective From Functional Programming
There is a motion afoot to remove conditionals; the anti-if campaign currently stands at 4,136 signatures. But what's the fuss all about?
Join the DZone community and get the full member experience.Join For Free
The new Gartner Critical Capabilities report explains how APIs and microservices enable digital leaders to deliver better B2B, open banking and mobile projects.
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?
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 purely 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 that destroys information is generally harder to reason about than code that 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 its 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)
That 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 that 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"
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 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
publish function 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.
Published at DZone with permission of John De Goes , DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.