When should you use function overloading vs control structures?

While browsing through the source code to Plug, I can across this block of code:

defp weekday_name(1), do: "Mon"
defp weekday_name(2), do: "Tue"
defp weekday_name(3), do: "Wed"
defp weekday_name(4), do: "Thu"
... snip ...

This pattern would raise a lot of red flags in most languages but given the popularity of Plug, I assume this is a reasonable pattern in Elixir.

Is this pattern preferred over using more “traditional” control structures like cond and case? If so, why and what benefits does it provide?

In Plug, there is a function for each weekday and each month. How far should you take such a design pattern when matching on simple values?

1 Like

This is not function overloading at all. You can liken it to a multimethod maybe.

Using pattern matching in function heads does get compiled to case. It’s a readability improvement pattern that many have found useful because you can more cleanly isolate logic for separate input values.

There’s never an universal answer. That depends on whether you are reaping any readability benefits and/or if your team is happier with the code.

4 Likes

It’s the best pattern I came across and I love it.

Here are some of the benefits:

  1. No pyramid of doom.
  2. Each aspect belongs to its own function.
  3. Functions can be pipelined because of this approach and we can easily debug the things happening in a pipeline using dbg(), rather than putting print statements everywhere.
  4. Pattern Matching…

Here’s an example code I came across today, that is just awesome:


Here’s how I use it:

defmodule DerpyCoder.Photos.Policy do
  @moduledoc """
  Policy: Used to authorize user access
  """
  alias DerpyCoder.Accounts.User
  alias DerpyCoder.Photos.Photo

  @type entity :: struct()
  @type action :: :new | :index | :edit | :show | :delete

  @spec can?(User, action(), entity()) :: boolean()
  def can?(user, action, entity)

  # ==============================================================================
  # Super Admin - Can do anything
  # ==============================================================================
  def can?(%User{id: id, role: :super_admin}, _, _), do: DerpyCoder.Accounts.is_super_admin?(id)

  # ==============================================================================
  # Admin
  # ==============================================================================
  def can?(%User{role: :admin} = user, :new, _),
    do: FunWithFlags.enabled?(:new_photos, for: user)           # Admins must have the feature enabled

  def can?(%User{role: :admin} = user, :edit, _),
    do: FunWithFlags.enabled?(:edit_photos, for: user)          # By being part of photography group perhaps

  def can?(%User{role: :admin} = user, _, Photo), do: FunWithFlags.Group.in?(user, :photography)
  def can?(%User{role: :admin}, _, _), do: true

  # ==============================================================================
  # User
  # ==============================================================================
  def can?(%User{} = user, :new, Photo), do: FunWithFlags.enabled?(:new_photos, for: user)

  def can?(%User{id: id} = user, :edit, %Photo{user_id: id}),      # Can user edit photo? Yes, only if it's theirs
    do: FunWithFlags.enabled?(:edit_photos, for: user)             # And if feature is enabled

  def can?(%User{id: id} = user, :delete, %Photo{user_id: id}),    # Can user delete photo? Yes, only if it's their own
    do: FunWithFlags.enabled?(:delete_photos, for: user)           # And if the feature is enabled for the user

  def can?(_, _, _), do: false
end

defmodule DerpyCoder.Photos do
  @moduledoc """
  The Photos context.
  """

  defdelegate can?(user, action, entity), to: DerpyCoder.Photos.Policy
  ...
end

Seeing the above, can you tell what’s happening?

Is it more readable, perhaps not, but once you get used to it, it’s much more readable than an if-else-infested page.

And here’s the elegant usage of the above policy.

Photos.can?(@current_user, :new, Photo)      # Show new button
Photos.can?(@current_user, :delete, photo)   # Show delete button
Photos.can?(@current_user, :edit, photo)     # Show edit button

It’s because of this feature, handle_event, handle_info, plugs, and pipelines work the way they work.

4 Likes

Can you say more about this? Keeping the API surface small with pattern matching is one of my favorite parts about the language

1 Like

I’ll expand on this a little bit. One of my favorite parts of Elixir and functional programming is the ability to “say what you want - not what you don’t”

In other words, you write much less “defensive” code in Elixir. A typical imperative function with some super simple validation might look like:

if (!form.IsValid)
{ return; }

// now okay, continue with business logic

Elixir

def submit({is_valid: true}) do
  // now we know we’re good to go 
end

def submit(form) do
  // form is invalid, log, return error, etc;
end

The difference in this contrived example is admittedly small. However the principle is important. Each function head allows us to deal with exactly what it needs to be: we know that the first function head has a valid form and we don’t need to waste mental processing time worrying about what could go wrong. Likewise, in the second, we know the form isn’t valid and we can log / return an error / do whatever to tell the client the request needs to be retried

3 Likes

Exactly, and the great thing is that these principles can be applied to other languages too, in some easier and in some you might require some libraries.

Golang is one of the languages that also embraces this principle, errors as data, handle the errors you want and ignore the rest, the only sad thing about this in golang is the lack of support structure for this style of programming, people tend to write coroutines that can handle/ignore almost all errors as to avoid crashing the entire application, witch in turn creates defensive programming, that is brittle, hard to test, hard to debug.

In my short professional career with golang, I never saw anyone not writing defensive code, thousands of lines for null pointers check, this was one of the reasons at that point I decided to leave that company and golang behind and focus on elixir.

4 Likes

Which is kind of strange because Elixir has nil as well. :grin:

But I do get your point. Every time I write a non-trivial Golang program, a good chunk of the code is defensive programming. There has to be a better way for it, hope they are working on it. They recently made it possible to follow entire chains of errors – and you can link them manually – but it just makes error-checking code even bigger.

1 Like

Writing less defensive code is definitely my favorite.

Here’s one more, being able to use Guards next to function names.

def valid_answer?(answer) when answer in ~w(yes no), do: {:ok, answer}
def valid_answer?(_), do: {:error, "Please respond with yes/no"}

I forgot to add an example of usage in Pipeline:

utm_params
    |> cast(attrs, [:url, :utm_source, :utm_medium, :utm_campaign, :utm_content, :utm_term])
    |> validate_required([:url])
    |> validate_url(:url, message: "URL must be valid")
    |> validate_length(:utm_source, min: 2, max: 10, message: "at least 2")
    |> validate_length(:utm_campaign, min: 2, max: 10)
    |> validate_length(:utm_medium, min: 2, max: 10)
    |> validate_length(:utm_content, min: 2, max: 10)
    |> validate_length(:utm_term, min: 2, max: 10)

Validate length function is awesome!

Imagine having to write that in any other language!

function validateUTMParams(utm) {
    if(utm.url == null) return "URL must be valid";
    if(utm.source < 2 || utm.source > 10) return "UTM source must be valid length");
    if(utm.campaign <2 || utm.campaign > 10) return "UTM campaign must be valid length");
    ...
}

I would take the pipeline, any day!

1 Like

To the untrained Elixir eye, I wasn’t sure if this was a good example and use of Elixir’s pattern matching or if this was an example where perhaps it had been taken a bit too far. I can appreciate the power of pattern matching in Elixir, I just found it surprising to see it used when the pattern is so trivial, hence the inquiry.

In other languages, for a fixed set of possible values, I might just index into an Array or switch over a given value. Given that Elixir supports both strategies, I was curious if the implementation above offered specific advantages I wasn’t aware of.

(And if the answer is simply “This is how you do things in Elixir because Elixir isn’t like other languages.”, then that works for me.)

I think it’s a really fair question and in this case, I’d say either is okay. I personally think the way Plug has done it is very readable – while there’s a bit of duplication, it’s easy to skim past it and see where the match is occurring.

I don’t think this matters too much, but it also saves a few lines over case:

defp weekday_name(1), do: "Mon"
defp weekday_name(2), do: "Tue"
defp weekday_name(3), do: "Wed"
defp weekday_name(4), do: "Thu"
... snip ...

# vs

defp weekday_name(day_of_week) do
  case day_of_week do
    1 -> "Mon"
    2 -> "Tue"
    3 -> "Wed"
    4 -> "Thu"
    ... snip ...
  end
end

Edited to add:

As for other ways of defining the mapping – I think that makes the most sense when you have more metadata and it’s nice to keep it all together in one place. For instance:

@days_of_week [
  mon: [short_name: "Mon", long_name: "Monday"],
  tue: [short_name: "Tue", long_name: "Tuesday"],
  ...
]

and then you might munge that data structure to create various lookup functions by atom, or index, or whatever.

4 Likes

I have a feeling that the errors in golang were designed by mistake, or at the very least the community never understood them, because every time I return to golang and look at it from the point of view of modern language design, it is just abysmal, templating engine is horrible, peculiar way to organize functions that is like extension functions, lack of code generation tools in the language leading to some libraries that want to abstract doing some really shady things under the hood. I am lucky that this is elixirforum and not golang forum :joy: , but I think that language should be thrown into the garbage and replaced with a better language.

1 Like