Having only One Return statement is Better and More Safe
Functions in programming languages are incredibly useful: They are designed to take input parameters, do something with them, and output a result. This conforms to the well-known IPO model (Input, Process, Output). However some developers tend to interrupt that process, making it harder to follow and thus understand a function, and risking evil and nasty bugs.
Let's see why not always all roads lead to Rome.
Understanding functions of other developers
Imagine you need to understand the source code of someone else's function. This is totally not rare, because whenever you add something to a project, or even use an external library, you have to understand what you are going to do (except for some conscience-lacking StackOverflow copy & paste engineers, of course).
With external libraries, documentation is what you usually consult. But in projects, you rather read source code of other people (that includes yourself when the source is older than 2-4 weeks, because that's literally the same).
Now, there are several things that a developer can consider to make source code readable and thus understandable. Some are well-known and -adopted, like properly naming functions.
But function control flow is not so often being discussed, albeit being a fundamental concept.
The two types of function control flows
Broadly speaking, functions can follow two types of control flow: They can either have one exit, or have multiple exits.
Choosing multiple exits can have two reasons: Either the developer wants to leave the function early because a condition is met that does not require to continue executing the function (referred to as »early returning«), or the return value of the function depends on a condition and is being returned in the appropriate branch.
Let's look at a couple of examples to make it more clear.
Simple function with single exit
The first example we look at is a very simple function that has no conditional branches:
function fetchTransactions(accountNo: string): Transaction[] { const response = restApi.getList("transactions", { accountNo }) const transactions = response.results return transactions }
The function is easy to read for two reasons: It's short and thus simple, and it can be read from top to bottom, as well as bottom to top.
What about error checking?
The function makes a request to a REST API, and making requests can of course fail. In a way that can't be foreseen.
The solution is to use your language's default
exception/error handling: If getList()
throws an
exception, you can let it bubble up to your function's caller, or wrap it
in a custom error object.
»Isn't this an early exit?«, you might ask. Yes, it is!
It's an early exit that can't be avoided: If the request fails, then
fetchTransactions()
– by design – can't fulfill its purpose.
Thus the caller has to be informed about that fact, and continue running
the function is not possible.
In some programming languages, there's no concept of throwing exceptions. For instance, the Go language does not have it. In that case returning early with an error object is your only option. But understand that the reasoning for this is very different to just return early.
Do not misuse early exiting for programming errors
You have to be very careful to not misuse early exiting for handling programming errors.
Programming errors are mistakes done by users of functions, thus developers. If they use a function wrong, then it can't run properly and that'll lead to a problem/error.
Handling those cases should not yield a proper result, ever. Let's extend the example:
function fetchTransactions(accountNo: string): Transaction[] { if (accountNo.length < 1) { return [] } ... }
That's clearly a misuse of early returning: A valid value
[]
is returned for an invalid set of input parameters, in
this case an empty account number ""
.
Instead, make it clear in the documentation of your function that an account number must not be empty, and if available, make use of your language's features to enforce such conditions.
Your part is to make sure that the function can't be used wrongly. If it is, make it fail hard, with whatever your language provides:
if (accountNo.length < 1) { throw new Error("accountNo must not be empty") }
Conditional exits
Another form of exiting early is when the return type depends on a condition. Let's have a look at the modified example:
function fetchTransactions(accountNo: string): Transaction[] { const response = restApi.getList("transactions", { accountNo }) if (response.results !== undefined) { return response.results; } else { return []; } }
The API might send a response without a results
property,
meaning that there are simply no results. Given that this is not a
programming error, by conducting the API's documentation, we have to make
sure to return a valid value.
The presented solution looks simple and is easy to understand, yet it uses multiple exits. Let's rewrite it utilizing a single exit only:
function fetchTransactions(accountNo: string): Transaction[] { const response = restApi.getList("transactions", { accountNo }) const transactions = response.results !== undefined ? response.results : [] return transactions }
This allows the reader to follow the control flow very easily, and even read the function from bottom to top!
At every line of code, it's guaranteed that the state of data is
valid, which allows avoiding any further if
conditions that
check whether there are results or not.
Such a code style is also very useful when there's more logic to be added after finding out about what to be returned.
Let's say we want to report empty results to a logging service:
function fetchTransactions(accountNo: string): Transaction[] { const response = restApi.getList("transactions", { accountNo }) const transactions = response.results !== undefined ? response.results : [] if (transactions.length < 1) { console.log("No transactions received.") } return transactions }
By using multiple exits, you'll instantly violate the DRY principle: Don't repeat yourself. Most likely, you'll refactor the multiple exit into a single exit anyway. In that case: Why not do the single exit from the beginning?
Single exits lead to nested spaghetti code!
An argument made by developers liking multiple exits is that single control paths often lead to deeply nested spaghetti code. Here's an example of a truly awkward implementation:
function notifyGroupMembers(group: Group): number { let notifiedMemberCount = 0 if (group.isActive() === true) { if (group.getMemberCount() >= 1) { for (const member of group.getMembers()) { if (member.isActive() === true) { notifyMember(member) notifiedMemberCount += 1 } } } } return notifiedMemberCount }
The function iterates over each member of a group and notifies it. The returned value is the total amount of members that got notified. A member is only notified when the group and member is active.
Using multiple exits, this can be easily refactored into something nice:
function notifyGroupMembers(group: Group): number { if (group.isActive() !== true) { return 0; } if (group.getMemberCount() < 1) { return 0; } let notifiedMemberCount = 0 for (const member of group.getMembers()) { if (member.isActive() === true) { notifyMember(member) notifiedMemberCount += 1 } } return notifiedMemberCount }
This is great: The nesting level is very shallow, and the function is quite easy to understand. There are issues though:
-
If we need to do something after having the final
notifiedMemberCount
value (even0
!), we will be fighting with DRY again. -
The returning of
0
is already redundant. (The first twoif
conditions can be combined to avoid this, but that's just a matter of finding a proper example where it's not possible.) -
Any resources that are allocated before or between
return
statements must be freed. This can be super dangerous and error-prone when dealing with open file handles and similar.
It would actually be smarter to refactor the
notifyGroupMembers
function into something that has a valid
data state all the time, so that no ugly preliminary checks are even
required.
function notifyGroupMembers(group: Group): number { let members = [] if ( group.isActive() === true && group.getMemberCount() >= 1 ) { members = group.getMembers().filter( member => member.isActive() === true ) } for (const member of members) { notifyMember(member) } return members.length }
The function is now basically split into two parts: Determining the members that shall be notified, and the actual notifying process itself.
We can do even better! Having identified the two parts, let's refactor them further:
function notifyGroupMembers(group: Group): number { const members = group.isActive() === true ? getActiveGroupMembers(group) : [] for (const member of members) { notifyMember(member) } return members.length } function getActiveGroupMembers(group: Group): Member[] { const members = group.getMemberCount() >= 1 ? group.getMembers() : [] return members.filter(member => member.isActive() === true) }
At every moment in the example above, the state is well-defined and valid, eliminating the need to even have preliminary checks. Both functions can be read from top to bottom and bottom to top, and become very easily readable and do not violate DRY in any case.
Resource and memory management
I already mentioned it as an issue above: When you are dealing with
resources that have to be initialized and released by you, then using
multiple return
statements can (and will!) lead to serious
and hardly detectable bugs and leaks.
A function should only do initialization once, and more importantly, do cleaning up at one place, also just once. Making mistakes is much harder that way, and readability again increases a lot.
Examples for such resources are open file handles, sockets and streams. Languages that allow for RAII (»resource acquisition is initialization«), like C++, do not suffer as much from that issue, but I'd always vote for the safe solution: Don't even allow a small chance to build dangerous code!
Conclusion
Single returns lead to a well-defined, non-interrupted control flow in functions, allowing the reader and developer to follow its steps from top to bottom and bottom to top, resulting in easy understandable code.
They also make sure that resource initialization and cleanup is done once and can't be forgotten by leaving a function early, which is especially important when editing other people's code.
Code nesting can be avoided by refactoring code to provide an always valid state, eliminating the need to do state checks preliminary, which is often the reason for code nesting. Another popular reason for deeply nested code is when functions just do too much.
Thank you for reading. I'm happy to hear about your opinion!