Early Return vs Single Return
Last time I’ve already mentioned the early return approach, so let’s dive a little bit deeper and try to see if it always fits our needs and what is the other take on function flow and returns.
First of all, early return means (no surprise) a function returns as early as possible:
std::string ToItalian(std::string const& word)
{
if (word == "one")
return "uno";
if (word == "two")
return "due";
return "no idea";
}
The opposite approach, single return, implies your function has only one return and therefore it is the latest statement:
std::string ToGerman(std::string const& word)
{
std::string result = "no idea";
if (word == "aeroplane")
result = "flugzeug";
if (word == "surprise")
result = "überraschung";
if (word = "butterfly")
result = "schmetterling";
if (word == "pen")
result = "kugelschreiber";
if (word == "science")
result = "naturwissenschaften";
return result;
}
The main quality of production code (given it is technically correct) is its readability: the measure of how easy it is to read and comprehend it. High readability makes it faster to understand what is going on, to spot and fix bugs, to develop new code around, and (to some extent) to refactor the code.
Developers spend time and brainpower to make sense of each given piece of code. The more complex the code is, the more resources it’ll take you to read it. That’s why everyone hates long functions: to make your way through them, you have to be very careful and keep lots of things in mind (like what each variable is for, what condition that if 100 lines ago checked, and so on). And every developer knows: if somehow you got distracted in the middle of things, then bam!—all memory registers are clear, and you’ll probably have to start from scratch.
And that is one of the main reasons to use early return. It allows the reader to ignore a part of the function when they know what it does in their case. Basically, the reader may split a function into several parts and read only the relevant one. It becomes way easier to understand this is the final value the function returns, in contrast to the second approach where the value might be modified later.
Let’s take a look at another example:
int SomeCoolFunction(int arg)
{
int result = 0;
auto err = DoSomething(arg);
if (!err)
{
DoAnotherThing();
AndOneMore();
result = NowPrepareResult();
}
else
{
HandleError(err);
}
return result;
}
Here’s your single return function with ‘meaningful’ code and error handling mixed up. Not only it has excessive indentation that may slow down the reader, it also features an if/else block that may confuse them even more. If your ‘happy path’ gets to 40-ish lines, it might get difficult to understand what this ‘else’ is connected to.
One may say that the condition should have been reversed, with error handling done first to separate it from ‘meaningful’ code, and I would totally agree. It would look like this:
if (err)
{
HandleError(err);
}
else
{
DoAnotherThing();
AndOneMore();
result = NowPrepareResult();
}
But I’d also like to make a step further and return early:
int SomeCoolFunction(int arg)
{
auto err = DoSomething(arg);
if (err)
{
HandleError(err);
return 0;
}
DoAnotherThing();
AndOneMore();
return NowPrepareResult();
}
Looks cleaner now. Not only we’ve unindented the ‘body’ of the function to the first level, we’ve also got rid of a variable (not a big deal here, though).
Does that mean we have a silver bullet now? Should everyone use early return on every occasion? Well, no. Like almost everything, the approach has its flaws. Suppose a developer modifies the function like this:
int SomeCoolFunction(int arg)
{
int *newData = new int[16]; // <== new line
auto err = DoSomething(arg);
if (err)
{
HandleError(err);
return 0;
}
DoAnotherThing();
AndOneMore();
delete[] newData; // <== and here
return NowPrepareResult();
}
Yikes! Why would one use new in modern C++? That’s a good question, but let’s save it for another article. Given that we are using a language that has defers/destructors/etc. to make sure an object is always destroyed, we could use them to keep up with early returns. Unique pointer will do the job in this case:
int SomeCoolFunction(int arg)
{
std::unique_ptr<int[]> newData = std::make_unique<int[]>(16);
// no delete[] is required now, we can proceed with the same jazz
But what if our language has no instruments to make sure some code is executed every time an object goes out of the scope—like in C? How do we use early returns there?
Well, it won’t be as pretty as it in higher-level languages. I can think of two approaches. The first one is ‘early goto with single return’:
int func(int arg)
{
int result = 0;
char* str = malloc(16);
use_the_string(str);
if (arg == 1) {
result = 17;
goto fini;
}
use_the_string_again(str);
// ...
// ...
fini:
free(str);
return result;
}
(Yeah, yeah, I’m used to different coding styles in C and C++.)
The Evil Goto is here! Well, not that evil—that’s a somewhat standard way of cleaning up in C. It might look a little bit odd if you are not used to it, but other than that it is fine.
At first glance one may think that every advantage given by an early return is lost here, but that’s not quite true. The thing is, you usually have the same name for this ‘cleanup’ label throughout the whole codebase, so after reading a couple of functions the reader knows goto fini effectively means ‘cleanup everything and return’, and may even skip everything past this label. This effectively makes this goto equal to return in terms of readability. Of course, this requires your codebase to be consistent and not to, for example, modify the result after fini.
Now, the next option is clearly not mainstream, and I would even call it paranoid, but it makes all the alloc
-free
pairs obvious, almost guaranteeing everything is processed and freed. Here’s an example:
int func()
{
char* temp = malloc(16);
object* another_temp = malloc(sizeof(object));
int result = func_impl(temp, another_temp);
free(temp);
free(another_temp);
return result;
}
int func_impl(char* temp_var, object* another_temp_var)
{
int error = use_one_var(temp_var);
if (error) {
handle(error);
return 0;
}
if (use_another_var(another_temp_var))
return 1;
return 100;
}
And now func_impl
uses the discussed ‘early return’.
This approach splits resource management and actual work into separate functions, which, while being perfectly fine, might seem clumsy to some developers. In addition to that, it requires an extra step to find out what func
really does, because one needs to jump to the definition of func_impl
.
Despite being fairly common, adapter functions (those that only convert the arguments for another function) might annoy some developers. Finally, if one needs to add yet another resource to be managed without adding another couple of functions, they either make another argument for func_impl
, or move all the arguments into a structure and add a new field there. That’s definitely not fun, but that might be a decent price for such guarantees and making ‘meaningful’ code easier to read.
Conclusion time? While early returns are not a silver bullet, it’s difficult to overestimate their value in day-to-day work. I strongly encourage you to use them every time you have no decent excuses not to… and I bet you’ll soon see there’s rarely any.