What do you think about early returns?

Neither of these are true. The overhead of a function call is very small on the BEAM (if it wasn’t modules of nothing but combinators like Enum wouldn’t be usable), and there are multiple result combinator libraries for Elixir.

1 Like

No, it’s not. Compiler is able to inline and analyze types only when call’s context and the function which is called are in the same module. Plus, protocols require structure key access which also takes time. I will provide a benchmark in a minute

If functional combinators are too expensive to use why do we use the Erlang lists module or the Elixir Enum module? They are entirely made of higher order functions.

The BEAM is designed for functional programming. It would indicate a great incompetence on behalf of the maintainers if function calls were expensive on a functional virtual machine.

:lists module leverages special kind of inlining. But Elixir’s Enum module doesn’t, and that’s a problem I am solving in my optimizing Elixir compiler.

By the way, @sabiwara had a library called Enumancer which solved this exact problem, but with macros and in Elixir

Yes, but functional programming is not about algebraic types or even static typing, it is just about functions. The very first functional language ever existed had no idea about flatMaps or even functors.

It is not an incompetence, Erlang and Elixir are not top notch functional languages with most bleeding edge ideas around functions. They are not even built around lambda calculus.

They were just designed for different problems. Thus, function calls are expensive to provide ability to change code in runtime as fast as possible

I think this conversation is suffering from a lack of a definition about what counts as small or fast here. These are relative terms, and for people say, returning HTTP results, the overhead of a remote function call vs a local one is indeed small. For a hot loop processing a million records, maybe it isn’t small.

5 Likes

Here’s the benchmark

Mix.install [:benchee]

defmodule Either do
  def left({:ok, result}, func), do: func.(result)
  def left({:error, reason}, _func), do: {:error, reason}

  def right({:error, reason}, func), do: func.(reason)
  def right({:ok, reason}, _func), do: {:ok, reason}
end

defmodule X do
  import Either

  def monadic(x) do
    {:ok, x}
    |> monadic_inc()
    |> monadic_inc()
    |> monadic_inc()
    |> monadic_compare()
    |> monadic_inc()
  end

  def monadic_inc(x) do
    left(x, & {:ok, &1 + 1})
  end

  def monadic_compare(x) do
    left(x, fn
      x when x > 0 ->
        {:ok, x}

      _ ->
        {:error, :too_small}
    end)
  end

  def regular(x) do
    x = x + 1
    x = x + 1
    x = x + 1
    if x > 0 do
      x = x + 1
      {:ok, x}
    else
      {:error, :too_small}
    end
  end

end

X.monadic(-4) == X.regular(-4) || raise "Wrong"

Benchee.run(%{
  "monadic" => fn -> X.monadic(-4) end,
  "regular" => fn -> X.regular(-4) end
})

"""
Name              ips        average  deviation         median         99th %
regular        8.70 M      114.97 ns   ±875.76%          99 ns         265 ns
monadic        2.71 M      369.24 ns ±12527.05%         151 ns         632 ns

Comparison:
regular        8.70 M
monadic        2.71 M - 3.21x slower +254.27 ns
"""

And it is built around simplest either-style tuple. Libraries like Algae would be even slower

2 Likes

This isn’t a benchmark of functional combinators, this is a benchmark of integers inside tuples against bare integers.

Yes, and this benchmark shows that overhead of combinators is too big to use them for control flow, since it is much easier to just hand-roll all conditional branching

You misunderstand me, my suggestion was to use functional combinators, not to use functional combinators in additional boxing with tuples.

You can (and should) use functional combinators without boxing (see the lists and Enum module for example). I am in no way suggesting that a library like Algae will have comparable performance.

Functional combinators and Haskell style boxing + monads are not the same thing.

This is exactly how Either monad is represented in Elixir

At no point have I stated one should be using Either or monads in general.

I assure you, this was not my suggestion.

Then I have no idea what you’re suggesting and how your suggestions can be represented in Elixir :\

A good example is the lists module. The programmer has an existing data type (a list) and we often need to perform familiar operations on it, such as locating an element in it, or removing elements that have some property. We could write these each time using recursion, or we could use the combinators found in the lists such as search/2 and filter/2 instead. The overhead of a function call is very small, so we can reduce the size of our code by not writing these by hand each time. In languages that compile to native this reduction in code size can even make programs faster! Though I do not know if this holds true for the BEAM.

The important detail for practical and performant functional combinators is to have them operate on the data structure you actually would be using otherwise, rather than to add meaningless extra wrappers which you would have to frequently allocate and then discard.

1 Like

First of all, how does lists solve anything related to control flow or early returns? There are monadic types which can be used to control flow, and I’ve just provided an example, but you’ve said that this was not what you were talking about. Second, Enum is a bad example, since it makes the code slower (like 20% slower), while it could be more performant if compiler could perform inter-module optimizations.

So, for types like Either or Maybe or Option (which are used for control flow in languages like Rust, Haskell, OCaml, etc) Elixir would be a very bad runtime.

The reason I am writing this is that I’ve seen this discussion many-many-many times, I’ve seen this idea being implemented in a few projects and every time it turned a disaster for performance and everybody just learned a lesson that these abstract ideas are inapplicable to dynamically typed languages without heavy compiler optimizations.

1 Like

What are we missing there that can’t be done with cond, case, with and/or multiple function heads? My experience with things like bind is you had to read articles like this to understand them, which you may or may not after reading that, and that, in my experience in F#, you need a library to wrap it to make it ergonomic. It can end up being pretty clean with the computation expressions but if I translate this F#…

let almQueryProps objHandle =
    result {
        let! utc   = getProperty objHandle "DateTime"
        let! ms    = getProperty objHandle "MSeconds"
        let! value = getProperty objHandle "Value"
        return Int64.Parse(utc), Int32.Parse(ms), Int32.Parse(value)
    }

to Elixir…

with {:ok, utc} <- get_property(obj_handle, "DateTime"),
     {:ok, ms} <- get_property(obj_handle, "MSeconds"),
     {:ok, value} <- get_property(obj_handle, "Value") do
  {String.to_integer(utc), String.to_integer(ms), String.to_integer(value)}
end

It is essentially the same but built into the language. Having all those extra combinators and operators adds significant mental overhead. For me, smashing out plain old boring functions and control flow is a lot easier to read and write.

3 Likes

Nothing in terms of early returns! In that respect they are equivalent. Elixir’s with and monadic interfaces are equivalent and can replace early returns here.

In the same way Elixir’s for is equivalent to certain list combinators such as filter, map, flat_map, and fold.

One thing that’s really nice about the combinator approach is that it can be used to implement functionality that other languages require keywords for. Take for example Go’s defer statement, which causes a function to be called after the scope has ended.

func main() {
        file := file.Open(path)
        defer file.Close()

        // Use the file...
        doSomething(file)

        // file.Close() is called here
}

There’s no syntax for this in Elixir or Erlang, but we could write a function for it!

def defer(first, then) do
  first()
after
  then()
end

And its usage:

def main() do
  file = File.open(path)
  defer(fn -> File.close(file) end, fn -> 
    do_something(file)
  end)
end

It’s a little less pretty in Elixir due to the syntax, but in other languages have syntax where they can use a combinator like this without the extra indentation.

Gleam:

pub fn main() {
  let file = file.open(path)
  use <- defer(fn() { file.close(file) })
  do_something(file)
}

Haskell, Elm, etc:

main =
  let file = File.open path in
  defer (\-> file.close file) \->
  doSomething file

Roc:

main =
  file = File.open path
  _ <- defer (\ -> file.close file)
  doSomething file

This is really cool! With combinators functional languages can implement things that other languages might need special language support for, including those that may be awkward to represent with our usual trusty tools of case, cond, and with. More power for us! :sparkles:

3 Likes

I can write such a function in any language that supports higher order functions (including golang), and actually if we talk in terms of implementations they are not equivalent, as you execute that code from context of a lambda, whereas in the golang this is a compiler eye candy that swaps those lines for you.

1 Like

Just curious, why would defer be preferable to a a higher order function, lambda, or block? Maybe it’s the file handling example that’s tripping me up as it’s a well-known problem. Are there scenarios where it would be better?

That’s right! Higher order functions are the single requirement for functional combinators.

Go specifically struggles with them though as it only supports generic data structures, not generic methods.

They won’t generate exactly the same machine code or bytecode (assuming a non-optimising compiler), but they are equivalent implementations. The only cost is a function call and a stack frame, very cheap! A good price for that much expressive power.

I personally don’t think defer preferable to any of those, I picked it as a random language core feature that isn’t present in any of the functional languages I was using in my examples. Other examples could be:

async/await

pub fn main() {
  use url <- await(file.read(path))
  use response <- await(http.get(url))
}

throw/catch

pub fn main() {
  run(my_program, catch: io.debug)
}

pub fn my_program() {
  case something() {
    Ok(x) -> return(x)
    Error(x) -> throw(x)
  }
}

for comprehensions

pub fn main() {
  use x <- list.flat_map(xs)
  use y <- list.map(ys)
  #(x, y)
}

generators

pub fn my_generator() {
  use <- yield(1)
  use <- yield(2)
  use <- yield(3)
  done()
}

Or anything else! Higher order functions are really cool and powerful in Elixir, Gleam, etc :purple_heart:

3 Likes

What I love about functional programming is that it’s essentially just graph reduction (“everything is expression”), which makes reasoning about code much more simple. Early returns (as well as exceptions) throw this out of the window. If they are rather simulated with an elegant abstraction (e.g. Haskell’s do-notation for certain types, or Elixir’s with) then that’s fine because it’s still graph reduction.

5 Likes