Culling Conditionals 2
This great article discusses how either avoiding conditionals entirely or refactoring them out of an application can make a codebase easier for developers to understand and change. To expand on how culling conditionals
with abstractions not only works in terms of implementation but also improves the cleanliness of a codebase, we will take the same examples from the above article, modify them slightly and provide a detailed implementation of the abstractions working together.
Hopefully, this demonstration will make the message more clear on why conditionals can add unnecessary complexity and confusion and how removing them with abstractions will create a simpler and more flexible solution. Moreover, further detailed examples will bring into the discussion the tradeoffs a developer must consider when culling conditionals
with abstractions.
According to the author, conditionals are confusing and on top of being confusing they tend to spread and multiply throughout a codebase. To better understand how conditionals are confusing and spread throughout a code base, read the sections of the post, Conditionals Spread, and, Conditionals are Confusing. In short, developers tend to make the most obvious change so that development can be deployed to production quickly
and safely
. The convenience of conditionals means they tend to spread. Furthermore, conditionals are confusing because while looking at a conditional a developer must keep a lot in his head at once: the implementation of all of the branches of the conditional tree.
Let's get to it. This is the code with conditionals which we seek to refactor out with abstractions:
Multiple Execution Paths -- With Conditionals
export default function walkDog (dog) {
if (dog.hasEatenRecently()) {
dog.walk(length: LONG_WALK)
} else if ((time.readyForBed() && dog.hasWalkedRecently()) || time.aboutToLeave()) {
dog.walk(length: JUST_POP_OUTSIDE)
} else {
dog.walk(length: AROUND_THE_BLOCK)
}
}
This is how we can leverage abstractions to refactor away the conditionals:
One Execution Path Only -- No Conditionals
import * as AboutToLeave from './about-to-leave'
import * as BeforeBed from './before-bed'
import * as NormalWalk from './normal-walk'
import * as PooWalk from './poo-walk'
import * as Time from './time'
const walkingPatterns = [ new PooWalk(new Time()), new BeforeBed(), new AboutToLeave(new Time()), NormalWalk ]
export default function walkDog (dog, walkingPatterns = walkingPatterns) {
walkingPatterns
.find(pattern => pattern.matches(dog))
.execute(dog)
}
Let's look at all of the implementations of the abstractions:
// poo-walk.js
export class PooWalk {
constructor(time) {
this.time = time;
this.justPopOutside = 3;
}
matches(dog) {
return this.time.readyForBed() && dog.hasWalkedRecently();
}
execute(dog) {
dog.walk(length: this.justPopOutside);
}
}
// before-bed.js
export class BeforeBed {
constructor() {
this.time = time;
this.justPopOutside = 3;
}
matches() {
return this.time.readyForBed();
}
execute(dog) {
dog.walk(length: this.justPopOutside);
}
}
// about-to-leave.js
export class AboutToLeave {
constructor(time) {
this.time = time;
this.justPopOutside = 3;
}
matches(_dog) {
return this.time.aboutToLeave();
}
execute(dog) {
dog.walk(length: this.justPopOutside);
}
}
// normal-walk.js
export class NormalWalk {
constructor() {
this.aroundTheBlock = 5;
}
matches(_dog) {
return true;
}
execute(dog) {
dog.walk(length: this.aroundTheBlock);
}
}
With regards to the refactor, One Execution Path Only -- No Conditionals, note the following benefits:
1. Honors order of conditionals
The refactor respects the order of the conditionals -- thanks to the array which is ordered by index position. While it may be open to discussion what dog.hasEatenRecently()
vs time.readyForBed() && dog.hasWalkedRecently()
actually means in terms of business requirements and why one happens before the other, the point is all the same: the refactoring still gives us control over what we want to execute first. To honor the order of the original execution paths, the refactoring also leans on the find
method, which will return the first matching item in the array.
2. Respects else
part of conditional
The refactor respects the else
part of the conditional. NormalWalk#matches
always returns true
so if the abstractions before it do not return true eventually NormalWalk#matches
will.
3. Avoids null
The additional positive about NormalWalk#matches
is that null
is avoided here. There's never a possibility where #execute
will be called on null
. Perhaps, NormalWalk
effectively acts as a Null Object.
4. Duck typing
Each JS class leverages duck typing
-- in a world of instances with the same interface, when we are interacting with these instances duck typing let's us not worry about what kind of instance they are. In our example, we just call #matches
and #execute
. The program does not have to check -- hey are you a `PooWalk`, a `NormalWalk` etc...
-- it just calls the publicly available method of the instances. Duck typing lets the execution path manage less in terms of asking what something is before acting on it. This coding approach is called, Tell; Don't Ask. However, all the objects using #matches
and #execute
of course requires the same publicly facing API.
5. Less to keep in my head
There is significantly less to keep in one's head at any given moment when they are looking at the above refactor. If they want to understand a particular execution or matching part of the code, they just have to open up the class in question and focus on the code that they need to focus about. One's mind does not need to load all the implementation details of my abstractions at the same time. In the code sample with the conditionals, a developer has to fill their head with all the implementation details of all the execution paths and conditionals, which can over time be tiring and one will probably miss something given the amount of complexity they have to fit into their head.
6. Changes more easily
The refactor let's code change more easily. As one must do in the code with conditionals, no longer done one ...add one more branch to the conditional and cross our fingers.
(quote from Culling Conditionals). All a developer has to do is place an abstraction in an index position into the array, walkingPatterns
. This additional change makes no impact what so ever to the other abstractions. The same is equally true if one were to delete an abstraction from the array.
7. Better Naming
This refactor uses better naming conventions than the code with conditionals. In other words, what does the condition (time.readyForBed() && dog.hasWalkedRecently()) || time.aboutToLeave()
actually mean? We know it's a contrived example, but we see code like this all the time, and it is very confusing. Instead of this abstruse conditional, there's a #match
method on a well named class (example: BeforeBed
). When the abstraction #matches
returns true from a business requirement level one know exactly why -- it's a walk that happens before bed.
The only tradeoff in the refactor is that there is more code -- specifically four more files. Admittedly, the refactor is a more object oriented approach which means more classes or class like abstractions are written to accommodate more or less the same behavior as our original example, using conditionals.
Thank you for reading. We hope this article has helped you learn how to unshackle your codebase from confusing and brittle conditional branches. Of course, we are not saying to never use conditionals -- but when you can and if it is possible, taking the above approach with abstractions instead of writing conditionals can make for a cleaner code base where developers are happier to work on. If anything, this article proves that refactoring out conditionals with abstractions is not only possible but it also has many benefits to your codebase.