What do you think about early returns?

I’ve been primarily doing Elixir development for the past 6 years or so, and during that time whole heartedly committed to functional paradigms.

But recently I did some Go programming, and I hate to admit it, but working with early returns again was kinda nice.

I’m not a fan of lots of small little functions, and using with seems to necessitate that; a lot of the times I’ll just deal with case/if nesting.

So one day, I threw (pun intended) in the towel and refactored a plug that has many success cases as well as error cases that need to return early, to use throw/catch to emulate early returns… and I was really happy with the results.

But there is this nagging feeling that I’ll be excommunicated from the community if this code ever sees the light of day publicly. I kid, I kid… :joy:

So why are early returns bad? I’ve since wrapped up the throw/catch paradigm into a tiny library that let’s it be used like this:

v = returnable do
  if some_condition?()
    return "foo"
  end
  ...
end

I understand that early returns make mechanical “refactor into function” difficult, but I think wrapping it up in an expression like above negates the issue. Not sure.

I’ve seen some posts (on Reddit, not here) where people suggest you can use throw/catch to emulate early returns “but you need to be an expert to do it safely and properly.” Why is that? What pitfalls are they alluding to?

As always, thanks for the help and info!

P.S. A little further in that video, he describes a use block that he wishes existed, but doesn’t know any programming language that has something like. I’m nearly positive it can be accomplished with metaprogramming in Elixir… but probably a topic for a separate post.

3 Likes

Early returns are a remnant of goto statements from languages like cobol, they encourage break of flow. A classical example would be:

def first_id_or_null(list, id) do
  Enum.map(list, fn el -> if el.id == id, do: return el end)
  return null
end

Now you can notice here that while such a function would work without problems if early returns were a thing in elixir, it is introducing a notion that we can return data from everywhere, hence breaking the concept of what a map function should be able to do, introducing a new concept that you can break out of all contexts.

This example can be as well rewritten to:

def first_id_or_null([], id), do: null
def first_id_or_null([h | t], id) do
  if(h.id == id), do: h, else: first_id_or_null(t, id)
end

What is interesting is that there are cases where you would want to return early, a good example would be Enum.reduce_while/3 and it is easily possible to implement this without using throw/catch.

4 Likes

I guess it’s a testament to how deep I am into functional programming these days that I never even considered returning from an enumeration, ha. That’s a really good point.

How do you feel about it in the case of replacing a with statement, or generally unravelling heavily nested cast/if/cond?

I think that this is easily solvable by refactoring to small functions and match parameters instead of using case/cond.

1 Like

tl;dr in my experience, multiple function heads + guards > early returns

Coming from Ruby where I appreciated how early returns could un-nest code for readability, I remember searching for an equivalent when I began learning Elixir. My rule of thumb when using early returns in Ruby were to limit them to the beginning of a function body to avoid the need to “chase” down all the possible returns lurking within a function, which would reduce readability.

What I soon realized was that leveraging function arity aka multiple function heads and guards in Elixir accomplished much of what I wanted out of early returns in Ruby while pulling it out of the function body and into the function head – arguably improving readability. ¯\_(ツ)_/¯

9 Likes

That’s what I’ve been doing for the past many years and I guess why I’m struggling with this now. I prefer the early returns in some, maybe a lot of, cases now that I’ve just given in and started using them again.

I guess it comes to down to style. I find it massively difficult to trace through code that is broken up into tons of small functions. The constant jumping between files/modules/functions adds a lot of mental overhead compared to just reading a larger function top to bottom.

Side note, our Ruby code base adhered to the small methods, single responsibility object mantras and it’s one of the worst codebases to work in ever, imo. Literally everyone hates its. And it’s all because seemingly simple things (like generating an Elasticsearch query, executing it, massaging the results) are spread across dozens of files and classes.

I’m starting to feel that a tiny bit in our Elixir codebase, I think due to a lot of reasons discussed here about with: https://elixirforum.com/t/with-statement-else-index/56914 and I kinda feel like early returns helps in some cases.

1 Like

Ya, this was a big problem with a lot of Rubyists. At an old job there were literally single use private functions that had the same body as the function name just without underscores. Stuff like:

def raise_unless_complete(thing)
  raise unless complete(thing)
end

Agreed that this was a nightmare to work on.

Of course it all comes down to style, but I feel like there is a good middle ground. I’ve been writing larger functions since moving to Elixir and haven’t ever wished for early returns. Not like my code is particularly amazing or anything but I often strive for a large “primary” function that literally lays out everything (especially side effects) then have a few smaller functions for implementations. Though they are “smaller” in the Ruby sense—they could 10-20 lines each themselves depending on what they do.

The super small functions are only good in my estimation if they are very reusable everywhere and you get to know them. But single line private functions are generally the worst.

2 Likes

My general opinion on early return is that this is a optimization feature and in no way increases readability or correctness of the code, it is an unsafe feature that is used for code optimization in low-level languages. A simple example:

for (int i = 0; i < 5; i++) {
  if(i == 2) { return i; }
  printf("%d\n", i);
}

This can be easily refactored to:

bool found_number = false;
int number = -1;

for (int i = 0; i < 5 && !found_number; i++) {
  if(i == 2) { 
    number = n;
    found_number = true;
  }
  printf("%d\n", i);
}

We can notice that we have to allocate 2 variables and write more code, essentially the primitives and the programming style does not offer support to write code in a way where you would be encouraged to not use a break or return early.

I write now a lot of Kotlin and the functions that are always the most unreadable and hardest to debug are functions that return early, especially since you can early return there from anywhere, be it callbacks, loops, you name it.

3 Likes

My 5 cents on one of the potential problems on having small functions and having a hard time to navigate to them is over-engineering.

I tend to write those small functions always in the same module, usually near the function as private functions, while others tend to extract them to a separate module, thinking that they will be able to use them in the future in other places, witch they will most definetly not use and if they do they can write the same function again there. I think that this optimization of code duplication is the main reason of such problems, and this problem is even bigger if we talk about typed code, classes, people there end up with some abomination inheritance trees and generic classes that you have to look at for 10 minutes to understand what it actually does.

3 Likes

FWIW, one of the few uses of throw in the Elixir stdlib is in Keyword.keys inside a :lists.map call - specifically for stacktrace-hygiene reasons:

Regarding OP’s code, I’m not a fan - in a function called by a function that said returnable, the return function will do a nonlocal return (!) which is unlikely to match the expectations of anybody whether they like early returns or not. IMO try/catch and throw aren’t complicated enough to be worth hiding behind a thin abstraction layer.

3 Likes

Our Ruby code base should be a case study on exactly this. Inheritance 5 levels deep, but then also composed of multiple other objects with similar inheritance levels. It’s absolute insanity.

I also find a lot of the times that inheritance is abused just to get rid of explicit branching logic (i.e. people don’t like using if statements for some reason).

Can you explain what you mean by this?

The problem of having many small functions is that then you need to name many small functions. As we all know, naming things is one of the two hard problems in computer science.

2 Likes

Then this should a better vector to focus on fixing instead of introducing early returns into the codebase, because while you will be able to use them correctly, someone else will come and create some abominations that nor you or your teammates will understand.

If you and your team have the time and resources, I would suggest to try to refine what means the clarity of the code at a cultural level in the company, and enforce some good practices to adhere to, much like credo does at the moment, a good place to start is this talk: Clarity | Saša Jurić | ElixirConf EU 2021 - YouTube

2 Likes

The inheritance problem doesn’t exist in Elixir, but the billions of tiny functions problem does (or can). I feel that with contributes to the latter in a lot of cases and early returns lets me simply have larger functions where I can do my business logic inline without factoring out into 1-use functions.

I feel like this is a style/opinion debate at this point, though I appreciate and agree with your notion of using return to break out of enumerations is not good in a functional language.

I’m still curious about the “nonlocal return” problem. I don’t understand that.

1 Like

Yes, but there are other things that exist, especially when coming from OOP and mutable languages, mainly defensive code style (witch can be a huge factor in this problem you are having).

In my 4 years of elixir development I never saw a need for early returns, expect when I was starting with elixir, I was always fighting credo and one of the things that is nice there is that its configured to not allow more than 3 nested conditionals in a function. As for small functions, I absolutely never had the problem of having too many of them, this is especially true when you create the functions locally for the module.

1 Like

Here’s a hypothetical example (hypothetical because I’m guessing how returnable works):

def some_function(arg1, arg2) do
  returnable do
    v1 = some_other_function(arg1)
    {:ok, v1}
  end
end

def some_other_function(arg1) do
  2 * yet_another_function(arg1)
end

def yet_another_function(arg1) do
  if arg1 < 123_456 do
    return :nope
  else
    arg1 - 123_456
  end
end

yet_another_function calls return (which I assume calls throw) so the stack is unwound all the way back to some_function, instead of doing an early-return from yet_another_function to some_other_function.


There’s an interesting parallel situation in Ruby with break and do blocks. It’s legal to do this:

some_array.map do |el|
  if el == "nope"
    break "nope"
  else
    el.upcase
  end
end

The result is that the break statement terminates the map and makes the whole expression return the supplied value ("nope" here), similar to how the returnable return behaves.

However, this code is not allowed by the parser:

def break_helper(arg)
  break arg
end

some_array.map do |el|
  if el == "nope"
    break_helper(el)
  else
    el.upcase
  end
end

Attempting to define break_helper produces a SyntaxError:

SyntaxError: (irb):26: Invalid break
	from (irb)

The rule is pretty much “you can’t say break unless the parser can see the block it breaks out of” - which would help make returnable clearer for users at the cost of a LOT of additional macro-juggling for the implementer.

2 Likes

Ohhh, I see. And ha, that’s exactly why it’s wrapped up in a little lib… you cannot call return from outside a returnable block.

Further, each returnable block has its own unique id, and a call to return inside that block will throw that unique id. I’m not sure if that’s overkill or not, but ehh, it was a fun little dive into metaprogramming.

1 Like

Came here to say exactly this

4 Likes

I’m surprised that there’s not any mention of function combinators such as map and bind/try/then, as is common in other functional languages. They can implement the same patterns as early returns but don’t require any special new language functionality, and they’re trivial for the programmer to extent. They work with pipes, or with a feature like Gleam’s use and OCaml’s let operators it can look very familiar too.

pub fn reply(message) {
  use <- guard(is_uppercase(message), return: Error(Rude))
  use <- guard(message == "", return: Error(Quiet))
  use name <- try(get_name())
  Ok("Hello, " <> name)
}
1 Like

Because in Elixir there are no such combinators. Well, technically there are, but their runtime overhead is too big for anything serious

1 Like