Refactoring early returns to pattern matching 🔀
For a long time, I’ve had a preference for expressions that result in a value over statements that cause a side effect. It goes hand in hand with using immutable data structures and using collection pipelines and all the other good stuff in functional foundations.
Pattern matching fits quite naturally into that picture, and it is not uncommon that I refactor code with regular conditionals (if
) into pattern matching expressions (when
in Kotlin).
Using pattern matching #
Let’s look at an (simplified) example function written in Kotlin.
fun f(n: Int): Int? {
if (n < 0) {
return null
} else if (n == 0) {
return 0
} else {
val x = n + 1
val y = n + 2
return x * y
}
}
Yes, I know this code is stupid (I did say it was simplified). But bear with me and try to look at the structure of the code, rather than the exact code.
The function is implemented using multiple return
statements inside a multi-branch if
statement. Since each branch ends with a return, it is easy to transform this to function with a single return and a multi-branch if
expression.
fun f(n: Int): Int? {
return if (n < 0) {
null
} else if (n == 0) {
0
} else {
val x = n + 1
val y = n + 2
x * y
}
}
From here, Kotlin allows us to get rid of the last return
statement as well.
fun f(n: Int): Int? =
if (n < 0) {
null
} else if (n == 0) {
0
} else {
val x = n + 1
val y = n + 2
x * y
}
Then the final step to a pattern matching expression is quite natural.
fun f(n: Int): Int? = when {
n < 0 -> null
n == 0 -> 0
else -> {
val x = n + 1
val y = n + 2
x * y
}
}
These refactoring steps are quite useful and I use them regularly.
Using early returns #
A different way to improve the original code example is to use guard statements with early returns. Doing so is a common in imperative programming to avoid nesting and to make the main path of a function clearer. The guard statements detect special cases and return early to allow the main part of the code to become clearer.
Looking again at the original example:
fun f(n: Int): Int? {
if (n < 0) {
return null
} else if (n == 0) {
return 0
} else {
val x = n + 1
val y = n + 2
return x * y
}
}
Improving on this code, we can easily extract the first few branches in the if
statement as guard statements that return early.
fun f(n: Int): Int? {
if (n < 0) return null
if (n == 0) return 0
val x = n + 1
val y = n + 2
return x * y
}
I liked extracting guard statements this way because it makes the flow easier to follow. But I also disliked it for a few reasons:
- It technically breaks the single return rule. While it is not an absolute law that must be followed, code with multiple returns is often harder to follow. Being strict about always keeping early returns before any other code helps, but that requires discipline which can erode over time.
- You have to read the code carefully to ensure that all guard statements actually return early. it is easy to have something which looks like a guard statement but actually falls through to the next statement (intentionally or not).
- It turns what could have been a single expression into a series of statements. That can make it harder to compose, refactor, or splitting it up into pieces without using mutable variables.
But in the end, I still consider it a win because it often makes code more readable.
Refactoring to pattern matching #
While I was already a fan of expressions over statements, a blog post called Thinking in Expressions by Matthias Endler triggered an idea that was new to me. The idea was that early returns could be seen as a form of pattern matching. While they are not identical, their structures are surprisingly similar.
Looking again at the example from above, refactored to use early returns.
fun f(n: Int): Int? {
if (n < 0) return null
if (n == 0) return 0
val x = n + 1
val y = n + 2
return x * y
}
From this, we can in fact turn this into a pattern matching expression with just a few changes.
fun f(n: Int): Int? = when {
n < 0 -> null
n == 0 -> 0
else -> {
val x = n + 1
val y = n + 2
x * y
}
}
What does this achieve? A number of things actually.
- It ensures we don’t mix our “early returns” inside with the main flow, keeping the function single return.
- It ensures “early returns” don’t accidentally fall through.
- It becomes an expression which can make it more easily composable.
As a final step, in some cases we can further improve readability by extracting the main flow into a separate function.
fun f(n: Int): Int? = when {
n < 0 -> null
n == 0 -> 0
else -> g(n)
}
fun g(n: Int): Int {
val x = n + 1
val y = n + 2
return x * y
}
What do you think? Would you replace early returns with pattern matching?