if (isChainedIfElseStatement) then Polymorph

Robin van den Bogaard
Team Rockstars IT
Published in
5 min readJan 15, 2024

Today I was working on my Encore! game. A project that helps me to learn Kotlin. And right when I was adding another if/else statement to the chain used for checking valid cell selection, Uncle Bob tweeted this friendly reminder:

So how does one sequester them you might ask? In most cases you can use Polymorphism to address the if/else statements in a very maintainable / testable way.

Polymorphism is the use of a single symbol to represent multiple different types. In other words, an interface.

The use case

I’d like to introduce to you the method, inside a class called Player, with the if/else chain:

fun checkAll(cellSelection: CellSelection, turn:Turn): Result<String, Unit> {
return if (cellSelection.size() < diceSelection.getEyeValue()) {
Result.failure("Not enough cells selected.")
} else if (cellSelection.size() > diceSelection.getEyeValue()) {
Result.failure("Too many cells selected.")
} else if (notAllColorsAreAllowed(cellSelection)) {
Result.failure("Colors picked that are not allowed.")
} else if (noneAreAlreadySelected(cellSelection, field)) {
Result.failure("Some cells are already marked.")
} else if (requiresCellH(turn) && hasCellH(cellSelection)) {
Result.failure("First selection must contain a cell in H.")
} else {
//valid, mark selection as checked
cellSelection.selections.forEach(field::checkCell)
Result.success(Unit)
}
}

With more validations to come this was becoming quite a big method and a bit complex. So how can Polymorphism help to make this better?

The plan

We’re going to follow a few steps to make it possible:

  1. Create an interface
  2. Create an aggregator that groups the if/else statements
  3. Implement the interface for each if/else statement and add them to the aggregator.

The interface

If you come across a situation like this defining the interface is quite simple. You can simply abstract the method to a new interface and give it a logical name:

interface CellSelectionRule {
fun validate(cellSelection: CellSelection, turn: Turn): Result<String, Unit>
}

The method got a new name, but the parameters and the return type are the same as the original method. Bang. There is your interface. Now any if/else chain can be written in their own implementation and fed the parameters to do its work.

The aggregator

Next we need something to aggregate a collection of CellSelectionRules instances to perform a composite validation of the selection.

class CellSelectionValidator(private val rules: Set<CellSelectionRule>) {
fun validate(cellSelection: CellSelection): Result<String, Unit> {
for (rule in rules) {
val result = rule.validate(cellSelection)
if (result is Failure) {
return result
}
}
return Result.success(Unit)
}
}

A simple collection of rules with a method that returns the first Failure result or a Succes if there are no failures. Very much similar to our initial method.

Replacing if/else statements

Ok so we got an interface (Polymorphism) and an aggregator at our disposal. Now we still have a long if/else chain to address. In this blog I’ll handle two of the statements, the others should be self explanatory afterwards. If not, hit me up in the comments!

The first branch:

if (cellSelection.size() < diceSelection.getEyeValue()) {
Result.failure("Not enough cells selected.")
}

Allright, so we’re checking if we have enough cells selected. Note that the diceSelection variable is not a parameter but an instance variable from the containing class. However it is mandatory for this check. Lets try to create a CellSelectionRule instance for this check:

class NotEnoughCellsRule(private val requiredEyes: Int) : CellSelectionRule {
override fun validate(cellSelection: CellSelection, turn: Turn): Result<String, Unit> {
return if (cellSelection.size() < requiredEyes)
Failure("Not enough cells selected.")
else
Success(Unit)
}
}

So the interface is clear, we receive a cellSelection and a turn parameter. The requiredEyes is a constructor parameter. Thus we have obtained all building blocks to make this check do its work.

We’ve also created a very easy way to test this branch without needing the setup of the previous Player class.

A more complex rule:

So the real power shines not with these simple checks but with a use case that I had not yet implemented at the time of the post from Uncle Bob. It was to check if all selected cells were adjacent. If I were to add this to the parent class (Player) in an if/else statement with yet another helper method. It would have blown up the class.

So to check if selections are adjacent we can use a depth first search algorithm to see if one cell can reach all the other cells. The implementation of said rule would look like this:

class AllCellsAreConnectedRule : CellSelectionRule {
override fun validate(cellSelection: CellSelection, turn: Turn): Result<String, Unit> {
return if (!areAllConnected(cellSelection.selections))
Failure("All selected cells must be adjacent to each other.")
else
Success(Unit)
}

private fun areAllConnected(selections: List<CellPosition>): Boolean {
if (selections.size <= 1) {
// If there's only one or zero selections, they are considered connected
return true
}
val visited = mutableSetOf<CellPosition>()
val start = selections.first()
fun dfs(current: CellPosition) {
visited.add(current)
current.neighbours()
.filter { it in selections && it !in visited }
.forEach { dfs(it) }
}
dfs(start)
return selections.all { it in visited }
}
}

I’m so glad I got to put this code in its own nice cohesive class. Not bloating my player class.

Replacing the if/else chain

So after turning all the if/else statements into their own implementations of the CellSelectionRule how can we update the method on the player class to utilize these new classes.

Because in my case I need up-to-date state from the player instance. I’ve chosen to instantiate a new aggregator on each method call with new instances of the rules using the latest state. Behold:

fun checkAll(cellSelection: CellSelection): Result<String, Unit> {
val cellSelectionValidator = CellSelectionValidator(getValidationRules())
val result = cellSelectionValidator.validate(cellSelection)
if (result is Success)
cellSelection.selections.forEach(field::checkCell)
return result
}

private fun getValidationRules(): Set<CellSelectionRule> {
return setOf(
TooManyCellsRule(diceSelection.getEyeValue()),
NotEnoughCellsRule(diceSelection.getEyeValue()),
FirstSelectionColumnRule(field),
UnmarkedCellsRule(field),
AdjacentToOtherCheckedRule(field),
AllCellsAreConnectedRule(),
ColorSelectionRule(field, diceSelection.getColorValue())
)
}

You might have noticed the Turn parameter has disappeared, it was no longer required.

If you do not need to work with up to date state you could instantiate the rules only once. Same goes for the aggregator.

You can also add more parameters to the interface to pass in the most up-to-date state to make the validations. You’d be willing to accept that not all parameters are going to be used for all rules though.

Conclusion

In conclusion, by embracing polymorphism, I tamed the if/else chain gerbils and fostered a cleaner, more modular design for my Encore! game. The use of interfaces and aggregators not only improved the structure of my code but also set the stage for future rule additions without the fear of unchecked proliferation.

--

--