If your API overflows with Boolean parameters, this is usually a bad smell.
Consider the following function call for example:
toContactInfoList(csv, true, true)
When looking at this snippet of code it is not very clear what kind of effect the two Boolean parameters will have exactly. In fact, we would probably be without a clue.
We have to inspect the documentation or at least the parameter names of the function declaration to get a better idea. But still, this doesn't solve all of our problems.
The more Boolean parameters there are, the easier it will be for the caller to mix them up. We have to be very careful.
Moreover, functions with Boolean parameters must have conditional logic like if
or case
statements inside. With a growing number of conditional statements, the number of possible execution paths will grow exponentially. It will become more difficult to reason about the implementation code.
Can we do better?
Sure we can. Lambdas and combinators come to the rescue and I'm going to show this with a simple example, a refactoring of the function from above.
This post is based on a great article by John A De Goes, Destroy All Ifs — A Perspective from Functional Programming.
I'm going to take John's ideas that he backed up with PureScript examples and present how the same thing can be elegantly achieved in Scala.
Example: Extracting names and emails from a CSV file
Let's take an example that might be a bit artificial, but should not be too complex to demonstrate the techniques to eliminate the pitfalls of conditionals.
The function toContactInfoList
from the opening example parses the contents of a CSV file into a sequence of ContactInfo
.
The Boolean parameters control whether rows containing either an empty name or an empty email should be omitted from the result or not.
An implementation of this function might look like this:
def toContactInfoList(
csv: Seq[String],
nameRequired: Boolean,
emailRequired: Boolean): Seq[ContactInfo] = {
csv
.map(_.split(';'))
.map(tokens =>
(tokens.headOption.getOrElse(""), tokens.drop(1).headOption.getOrElse("")))
.flatMap {
case (name, email) =>
if ((name == "" && nameRequired) || (email == "" && emailRequired)) {
None
} else {
Some(ContactInfo(name, email))
}
}
}
Inversion of control
Boolean parameters can be seen as a serialization protocol.
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). -- John A De Goes
The serialization on the call side and the deserialization on the implementation side are spots where things can potentially go wrong.
Therefore the first step to improve the design is to get rid of these Boolean parameters by replacing them with a lambda.
This way the caller no longer has to encode their intentions as Boolean values. Instead the caller can now pass a function, the actual intention, to the API.
Here is a new version of our original function where the Boolean parameters are replaced by a lambda. Out of convenience we will use a type alias for the lambda:
type Converter = (String, String) => Option[ContactInfo]
def toContactInfoList(
csv: Seq[String],
convert: Converter): Seq[ContactInfo] = {
csv
.map(_.split(';'))
.map(tokens =>
(tokens.headOption.getOrElse(""), tokens.drop(1).headOption.getOrElse("")))
.flatMap { case (name, email) => convert(name, email) }
}
With an implementation like this the caller has to construct the converter
parameter themselves, e.g. like this:
def noEmptyNameOrEmail: Converter = {
case ("", _) | (_, "") =>
None
case (name, email) =>
Some(ContactInfo(name, email))
}
toContactInfoList(csv, noEmptyNameOrEmail)
It can be good to give the caller a greater amount of control like this. On the other hand, this is not very convenient most of the time, and also error prone.
So, we are only half way there.
Exposing available options as combinators
For a better usability the API should expose ready made implementations (in the form of functions) that the caller can choose to pass back into the API method.
In our example these functions have the type Converter
.
Let's start with the simplest converter:
def makeContactInfo: Converter = {
case (name, email) => Some(ContactInfo(name, email))
}
Passing this converter (like so: toContactInfoList(csv, makeContactInfo)
) is equivalent to calling the original version like this: toContactInfoList(csv, false, false)
.
Now, to enable the caller to specify the other options, we will implement combinators. These combinators are higher-order functions that take a Converter
as input and return a new Converter
, therefore have the type: Converter => Converter
. The returned converter combines the behavior of the converter that was given as an input parameter with a new behavior.
A combinator that will omit empty emails looks like this:
def noEmptyEmail: Converter => Converter = {
converter => {
case (_, "") =>
None
case (name, email) =>
converter(name, email)
}
}
The combinator that will omit empty names is very similar:
def noEmptyName: Converter => Converter = {
converter => {
case ("", _) =>
None
case (name, email) =>
converter(name, email)
}
}
Now these functions can be combined like this:
noEmptyEmail(noEmptyName(makeContactInfo))
Enabling a nicer syntax
For better readability and to enable auto-completion which makes it easier for the caller to find available combinators, we will use Scala's implicit classes:
implicit class ConverterSyntax(convert: Converter) {
def noEmptyName = ContactInfoConverters.noEmptyName(convert)
def noEmptyEmail = ContactInfoConverters.noEmptyEmail(convert)
}
The syntax is much nicer now:
makeContactInfo.noEmptyEmail.noEmptyName
Conclusion
Finally, a call of the new version of the API method looks like this:
toContactInfoList(csv, makeContactInfo.noEmptyEmail.noEmptyName)
Not only is it much easier than before to reason about what's going on, we also eliminated the potentials for errors related to encoding and decoding of intent. Also the caller can now provide custom converters.
Moreover, with the new design it is possible to extend the API by adding more combinators in the sense of the Open-Closed Principle.
Note that in Scala we don't have the same guaranties for purity and control of side effects as in PureScript or Haskell.
However, I definitely think the benefits described in this post make it worthwhile giving these techniques a try. What do you think? Let me know in the comments!