The benefits of early returns
Should you use early returns, or avoid them? Summary: I recommend using early returns to reduce nested branching, and Guard clauses to state preconditions and separate the happy path. Before that, though, it is best to first think about whether there is a way to eliminate the branching itself.
Criteria for “good”
If discussion of what is “good” turns into a discussion of the aesthetics of code, it can become overly subjective or speculative, so let us think about it based on objective indicators. Two criteria come to mind.
- Understandability: which code is easier for humans to read and understand?
- Testability: which code is easier to test?
Half a century ago (the 1960s), performance was also an important issue in discussions like this (for example, when discussion was in full swing about eliminating GOTO and introducing structured programming, there was an argument that in certain situations where one needed to break out of nested loops all at once, using GOTO was the only way to do so most efficiently), but things have changed a great deal since then, so I will not consider performance.
Cyclomatic complexity
As a metric for quantifying testability and understandability, cyclomatic complexity was proposed in 1976, and it is fairly widely used. If you use JavaScript, you can limit cyclomatic complexity with ESLint’s “complexity” rule. (However, the default is set to 20, which is far too loose.)
Cyclomatic complexity quantifies how many linearly independent paths there are through the code. The lowest possible score is 1 (if there is no branching at all, there is 1 path).
For example, the following code has a cyclomatic complexity of 3:
function doSomething(x, y) { // + 1 (default)
if(x) { // + 1
if(y) { // + 1
return 1
} else {
return 2
}
} else {
return 3
}
}
What if we use early returns to remove nesting?
function doSomething(x, y) { // +1 (default)
if(!x) return 3 // +1
if(y) return 1 // +1
return 2
}
It has become much easier to read, but it is still 3. Contrary to a widespread misunderstanding, early returns or Guard clauses do not affect cyclomatic complexity.
As in this case, there are quite a few instances where the correlation between the cyclomatic complexity score and actual understandability is weak. This is one of the shortcomings of cyclomatic complexity.
Cognitive complexity
One proposal intended to address this shortcoming is cognitive complexity. Since it was first proposed in 2016,1 it has been updated steadily.2 If you use ESLint, you can install eslint-plugin-sonarjs and set the “sonarjs/cognitive-complexity” rule.
Unlike cyclomatic complexity, cognitive complexity gives extra weight to nested branches. As the nesting level increases, it adds 1 more point each time. For example, the code below had a cyclomatic complexity of 4, but its cognitive complexity is 6. (Cognitive complexity starts from a base score of 0, so it is 6, not 7.)
function doSomething(x, y, z) {
if(x) { // + 1
if(y) { // + 2
if(z) { // + 3
return true
}
}
}
return false
}
If we rewrite the code above as follows, the cognitive complexity drops to 3. (Meanwhile, cyclomatic complexity is still 4.)
function doSomething(x, y, z) {
if(!x) return false; // +1
if(!y) return false; // +1
if(!z) return false; // +1
return true
}
The code above can be reduced further as follows, and if we do that, the cognitive complexity becomes 1. (Cyclomatic complexity is still 4.)
function doSomething(x, y, z) {
if(!x || !y || !z) return false; // +1
return true
}
The reason it is 1 is that no additional score is assigned when the same logical operator is used consecutively in the condition of a branch statement. In the expression above, the logical OR operator (||) is used repeatedly, so the entire conditional is counted as 1 point. This is because cognitive complexity was designed with actual understandability in mind. By contrast, cyclomatic complexity is still 4, because the x, y, and z contained in the condition are each counted as 1 point.
Meanwhile, the code above can be reduced even further as follows. (Assume x, y, and z are boolean.)
function doSomething(x, y, z) {
return x && y && z; // +1
}
Interim summary:
- Early returns can improve understandability because they flatten nested branches.
- However, there is no difference under the cyclomatic complexity metric, and it can be quantified only if you use a metric that gives extra weight to nested branches, such as cognitive complexity.
- If the context is to quantify understandability rather than branch coverage, it is better to use cognitive complexity rather than cyclomatic complexity.
Testability
Now let us talk about testability. Testability indicates how easy a given module is to test, and “easy” can mean several things.
- If a function has many parameters or creating each argument is tricky, testing that function becomes harder. In such cases people use things like Mock objects, but that can often be a design problem. See also: Mock object smell
- If a function has many branches, more test cases are needed to raise coverage.
- If a function’s happy path and its other paths are explicitly distinguished, writing test cases becomes easier.
Since we are talking in the context of early returns, what concerns us here is branching and paths.
First, let us consider branch coverage, the criterion for evaluating whether every branch has been covered by tests. From the perspective of branch coverage, the number of test cases required generally matches the cyclomatic complexity score.
In the case of the doSomething() example above, the CC was 4, and the following four test cases are needed:
- when all are
true, check whether it returnstrue - when only
xisfalse, check whether it returnsfalse - when only
yisfalse, check whether it returnsfalse - when only
zisfalse, check whether it returnsfalse
But as we saw earlier, early returns do not affect the cyclomatic complexity score. Therefore, using early returns does not reduce the number of test cases needed to increase branch coverage, either.
If you want to improve the testability of individual functions while keeping the number of test cases the same, you have to split functions into smaller pieces (Extract function). Breaking functions up into smaller pieces helps lower path coverage more than branch coverage. Path coverage means coverage not of individual branches, but of combinations of branches (that is, the function’s overall execution paths).
Eliminating the branches themselves by generalizing the logic is also a good method, but it is not directly related to early returns.
Next, let us think about the testability of functions whose happy path is clearly distinguished. Using Guard clauses to check preconditions and clearly separate exceptional paths from the happy path has a definite advantage in that, although it does not affect cyclomatic complexity, it helps with writing test cases and can make the correspondence between the actual code and the test cases more explicit.
Eliminating the branching itself
Early returns necessarily presuppose branching. It is meaningful to leave the branching in place and discuss how to handle it, but the best thing is to find a way to eliminate the branching altogether.
If you do object-oriented programming, you can refactor by replacing conditionals with polymorphism, and you may also consider the Null object pattern (in fact, the Null object pattern is a special case of the refactoring replacing conditionals with polymorphism).
Another method is to simplify the logic through mathematical or logical generalization. As a simple example, code like this…
dir++;
if(dir >= 360) dir = 0;
…can be changed like this (expressing the concept of cycling with the remainder operator):
dir++;
dir %= 360
In more cases than one might think, it is possible to improve code so that branching disappears entirely from a function, or so that a function can be expressed as a single formula.
Only one return per function?
Meanwhile, there has long been a claim that it is better for a function to have only one return, but Kent Beck says this claim is no longer valid.3
The “rule” about having a single return for a routine came from the days of FORTRAN, where a single routine could have multiple entry and exit points. It was nearly impossible to debug such code. You couldn’t tell what statements were executed.
The “rule” about having a single return for a routine came from the days of FORTRAN, where a single routine could have multiple entry and exit points. It was nearly impossible to debug such code. You couldn’t tell what statements were executed.
Conclusion
From the standpoint of understandability:
- Early returns can improve understandability because they flatten nested branches.
- However, there is no difference under the cyclomatic complexity metric, and it can be quantified only if you use a metric that gives extra weight to nested branches, such as cognitive complexity.
- If the context is to quantify understandability rather than branch coverage, it is better to use cognitive complexity rather than cyclomatic complexity.
From the standpoint of testability:
- From the perspective of branch coverage, early returns do not improve testability.
- If you use Guard clauses to separate the happy path and flatten nested branches, the code’s execution paths are revealed more easily, so it can help with writing tests.
For these reasons, I recommend early returns for flattening nested branches, and Guard clauses for clarifying preconditions and neatly separating the happy path. Before that, though, it is best to first think about whether there is a way to eliminate the branching itself.