I’ve refactored a single complex “if” statement in various ways. I’ve discovered an interesting tendency. Using simple refactoring steps, when pursuing higher readability of the code, you can often go in different directions. Every small refactoring is an increment in readability, and sometimes a decrease, or increase, or neutral in complexity or abstraction.
TL;DR: From this experiment, I’ve learned that we shouldn’t be too hasty adding new abstraction level when refactoring. Maybe there is a way to improve readability within the bounds of the current abstraction.
And you don’t want your code to look like that:
Abstraction going wild! 🦌 source: pexels
Let me give you an example. I had the following implementation for the Rock-Paper-Scissors Coding Kata:
That “!=” is screwing with understandability. 😖
My first hunch was that these “if” statements are verifying whether the first throw beats the second one. So I thought that nice abstraction that will improve the readability would be to do:
That just moved a problem though. The code in the “beats” method is still not expressing the intent very well. You have almost to execute the code in your brain to understand how it relates to your business rules:
And we have already introduced one level of abstraction. The readability of the condition is not improved — just moved around. There is only a slight increase in readability of the “play” function.
My intuition proposed an immediate solution to the problem of readability in the “beats”: turn the enum class into an interface and have three concrete implementations for each type of throw:
Now, it becomes apparent that negated condition (like other != ROCK
) is not what we really need to talk about. Instead, we need to talk about which “other throws” the current one beats:
This refactoring path can go even further by instantiating SCISSORS, ROCK, and PAPER with a field “winsAgainst,” and using it in the default implementation of the “beats” method:
So, at the end of the day, we had to add two more layers of the abstraction to make the logic in “play” function more readable, and to make the “if” condition represent the intent better.
As we all know, more abstraction means that it is easier to understand every bit in isolation, but it is harder to grasp it as a whole.
And abstractions leak.
Let’s see if our current abstraction does. Let’s transform the classic Rock-Paper-Scissors problem into more culturally modern Rock-Paper-Scissors-Lizard-Spock. Here is the list of rules:
(By the way, this is actually a runnable test. Thank you Kotlin for your DSL goodness! See how I wrote this test with full TDD flow here.)
The first assertion error is “ROCK should beat LIZARD,” but if I just replace the value of “winsAgainst” field, it fails with “ROCK should beat SCISSORS.”
That means that we’ll have to break the API between Throw interface and its implementors. Now we need “winsAgainst” to be a list:
While the leak was quite well-contained in a single file, the abstraction still leaked. And the complexity of this abstraction is quite jarring if you compare it to the original “if” statement.
Let’s go back, and try to limit ourselves not to introduce any heavy abstraction. Can we make the original “if” statement in the “play” function a bit more readable?
Yes. Explanatory variables. And let’s remove that negation from “second != ROCK” and similar while we are at it, as we have already learned in our other refactoring path:
That is kind of cute. It is easier to read, and it resembles the business rules better.
Here I had a crazy question: can I make a DSL here to make the code look even closer to the human description of the rules?
And I can! (Not sure if I should have):
Wow, this looks cool. And reads well.
Except for the “rules(first, second)” part. And how does this work anyways?
“And here, you lost me, Oleksii!” — I would tell myself at this point. There is quite a lot of Kotlin DSL magic, like infix extension functions and lambda extension functions.
At that point though, I concentrated on the shape of the “rules(…) {…}” block. It looks kind of familiar.
Pattern matching!
I’m trying to match here the pair “first and second” to certain combinations like “SCISSORS and PAPER,” “PAPER and ROCK,” and “ROCK and SUCCESS.”
And that can be done with the “when” statement (Kotlin’s advanced version of a classic “switch” statement):
And now, since we are mentioning “true” and “false” directly, and using it later in the “if” statement, let’s simplify the “play” function by removing the boolean variable:
So we don’t have any more abstraction layers (except for little usage of Pair<A, B>
class and it’s to
infix extension function). Let’s see how hard it will be to introduce new rules:
That was easy!
And if you want to make it more expressive, you can create two aliases for the infix function “to:” vs and beats (absolutely optional):
When refactoring, our solution space seems to be a multi-dimensional function, and we are trying to find a good-enough local maximum.
It’s like teaching neural networks. And your refactoring steps is gradient descent.
Now, if you explore single descent only, you’ll have something OK. But that might be really sub-optimal local maximum. What if we’ve gone back and tried a few different refactoring steps (that are not necessarily better)?.
Well, it turns out, my first refactoring path produced readable code, but it has introduced 2 levels of abstraction (1 interface and few implementations).
When going back, and trying different options, for example trying to introduce only 1 or 0 levels of abstraction maximum, it yielded the code that was just as readable, but it had less abstraction in it; therefore it is easier to understand how it works, and fewer chances of abstraction leaks.
What I have to say, is that I had to make the code much worse in a few first steps, and then rapidly make it more straightforward in the next steps.
Don’t always go for your first gut feeling refactoring, explore alternatives, learn the landscape of this multi-dimensional function of readability, complexity, and abstraction.
You should consider exploring options on a toy code instead of a production one: Coding Katas are perfect for that. And it’s absolutely alright to back out from the refactoring path and move in a completely different direction.
With production code, that works too. You’re not wasting anything. The code is not worth much. The knowledge you gained, and can now apply — is the gold, and it stays.
Make sure to check out my 4-part e-book, 350 pages long “Ultimate Tutorial: Getting Started With Kotlin.” On top of just Kotlin, it is full of goodies like TDD, Clean Code, Software Architecture, Business Impacts, 5 WHYs, Acceptance Criteria, Personas, and more. Download it and learn Kotlin.
Thank you for reading! Also, if you like what you’ve just read, consider giving me a clap on Medium (up to 50) and sharing the article on social media. That’ll make me super happy! :)