Imperative cascading conditionals with short-circuit returns. Declarative approach?

I’m sorry for asking my 3rd question in a week, but I’m making so much progress I’d hate to slow down now.

I have a function (in PHP, sorry) that I need to convert the behavior of to Elixir. The function comment explains the purpose and behavior quite well:

  // Take a number of seconds and return a string describing the length of time
  // in the most appropriate way. If we're dealing with seconds on the order of
  // hours, then return hours. If we're dealing with seconds on the order of
  // years, then return years. You get the idea.
  // This function takes leap years into account by treating one year as one
  // year plus one forth of a day.
  function display_seconds($seconds) {
   if ($seconds > 94672800) {            // 94,672,800 seconds is 3 years.
      $seconds_in_one_year = 31557600;    // 31,557,600 seconds is 1 year.
      return number_format($seconds / $seconds_in_one_year) . ' years';
    } elseif ($seconds > 7889400) {       // 7,889,400 seconds is 3 months.
      $seconds_in_one_month = 2629800;    // 2,629,800 seconds is 1 month.
      return number_format($seconds / $seconds_in_one_month) . ' months';
    } elseif ($seconds > 1814400) {       // 1,814,400 seconds is 3 weeks.
      $seconds_in_one_week = 604800;      // 604,800 seconds is 1 week.
      return number_format($seconds / $seconds_in_one_week) . ' weeks';
    } elseif ($seconds > 259200) {        // 259,200 seconds is 3 days.
      $seconds_in_one_day = 86400;        // 86,400 seconds is 1 day.
      return number_format($seconds / $seconds_in_one_day) . ' days';
    } elseif ($seconds > 10800) {         // 10,800 seconds is 3 hours.
      $seconds_in_one_hour = 3600;        // 3,600 seconds is 1 hour.
      return number_format($seconds / $seconds_in_one_hour) . ' hours';
    } elseif ($seconds > 90) {            // 90 seconds is 3 minutes.
      $seconds_in_one_minute = 60;        // 60 seconds is 1 minute.
      return number_format($seconds / $seconds_in_one_minute) . ' minutes';
    } else {
      return number_format($seconds) . ' seconds';
    }
  }

If you followed along, you’ll see that the function takes an amount of seconds, and turns that into a human-friendly string, for example 6 weeks or 13 years. Id’ like to accomplish the same thing in Elixir. I’ve already got the number_format() part figured out. I can use Number.Delimit.number_to_delimited() for that. It’s the if/elseif/else logic and the short-circuit returns that are my main issue. No doubt there is an entirely different approach I should be taking here, I just don’t know what it is. Or maybe even this type of time/string formatting is built into the language?

I’d appreciate any help I can get. I think I could probably adapt the answer to my previous question to accomplish something like this, but I’m not confident there either…

1 Like

This is an excellent example of how useful pattern matching is…

You can solve it with case do end, but You can also use something like this.

defmodule Demo do
  @seconds_in_one_year 31_557_600
  @seconds_in_one_month 2_629_800
  
  def display_seconds(seconds) when seconds > 94_672_800, do: number_format(seconds / @seconds_in_one_year)
  def display_seconds(seconds) when seconds > 7_889_400, do: number_format(seconds / @seconds_in_one_month)
  
  defp number_format(number), do: "whatever #{number}"
end
1 Like

BTW, how can You get those values?
I mean… a year can have 366 days, and month may vary between 28, 29, 30 and 31 days.

@seconds_in_one_year 31_557_600
@seconds_in_one_month 2_629_800
1 Like

It’s been a while since I wrote this code originally, but I believe what I probably did was take the average number of days in a month for one year and used that. I certainly did that for the years, as is explained in the comment. I assume each year is 365.25 days long.
I initially did a few searches to just look up how many seconds were in a year, or a month, etc.
I found the answers were all over the place, so I did my own calculations taking into account why differences existed in the references, and was ultimately satisfied with my own answers.

This is used to ultimately display how long it will take (worst-case) to brute-force guess a password. We know the hashing algorithm we use, we know how many iterations we hash, we know how fast HashCat can guess passwords, etc. etc. etc.
So we put all that together, taking the year into account (GPUs get more powerful each year, we have a curve for this) and we can tell a user how long their password will take to be guessed.
If it doesn’t take long enough, we reject their password. We don’t explicitly require a password length, or which type of characters they need, or anything like that. This is the password ruleset I’ve always wished existed, and I’m happy I was able to implement it for our systems.

So accuracy isn’t hugely important in this display. Not only that, you’ll notice we leave off the remainder, so for sure accuracy is out the window. But we’re good within the order of magnitude…
I mean, I did try to get it absolutely accurate, and I do think it is, but only in the aggregate?

Yah, using my values for seconds in a day and seconds in a month, you’ll see I figure there are 30.4375 days in a month. Multiply that by 12 months in a year and you’ll see I end up with 365.25 days in each year. :smiley:

Heck, it’s probably easiest to get the values by working backward from the 365.25-day year. I didn’t do that all the way, as I’d have ended up with 7.024 days in a week. So for weeks and shorter, it’s as you’d expect. Once we move to months, that’s when things get averaged out.

Ok, I get it now :slight_smile:

I went with the pattern matching capability of functions as suggested, and ended up with this which works great. :smiley:

I’m not super happy with the do/end on the last line. Is that normally how people do the final function?

  @seconds_in_one_year      31557600    # 31,557,600 seconds    | Includes leap years.
  @seconds_in_one_month     2629800     # 2,629,800 seconds     | Includes leap years.
  @seconds_in_one_week      604800      # 604,800 seconds       | Does not include leap years.
  @seconds_in_one_day       86400       # 86,400 seconds        | Does not include leap years.
  @seconds_in_one_hour      3600        # 3,600 seconds         | Does not include leap years.
  @seconds_in_one_minute    60          # 60 seconds            | Does not include leap years.

  #
  # Take a number of seconds and return a string describing the length of time
  # in the most appropriate way. If we're dealing with seconds on the order of
  # hours, then return hours. If we're dealing with seconds on the order of
  # years, then return years. You get the idea.
  # This function takes leap years into account by treating one year as one
  # year plus one forth of a day.
  #
  defp display_seconds(seconds) when seconds > (3 * @seconds_in_one_year),   do: Number.Delimit.number_to_delimited(seconds / @seconds_in_one_year,   precision: 0) <> " years"
  defp display_seconds(seconds) when seconds > (3 * @seconds_in_one_month),  do: Number.Delimit.number_to_delimited(seconds / @seconds_in_one_month,  precision: 0) <> " months"
  defp display_seconds(seconds) when seconds > (3 * @seconds_in_one_week),   do: Number.Delimit.number_to_delimited(seconds / @seconds_in_one_week,   precision: 0) <> " weeks"
  defp display_seconds(seconds) when seconds > (3 * @seconds_in_one_day),    do: Number.Delimit.number_to_delimited(seconds / @seconds_in_one_day,    precision: 0) <> " days"
  defp display_seconds(seconds) when seconds > (3 * @seconds_in_one_hour),   do: Number.Delimit.number_to_delimited(seconds / @seconds_in_one_hour,   precision: 0) <> " hours"
  defp display_seconds(seconds) when seconds > (3 * @seconds_in_one_minute), do: Number.Delimit.number_to_delimited(seconds / @seconds_in_one_minute, precision: 0) <> " minutes"
  defp display_seconds(seconds)                                              do  Number.Delimit.number_to_delimited(seconds,                          precision: 0) <> " seconds" end
1 Like

Nice… just a little tip. When using large number you can use underscore for separation. It does not hurt calculation and increase readability.

31557600
# is equal to
31_557_600
1 Like

Oh, just like Ruby. I love that feature. Thanks! I can clean up some of my line comments then. =D

Another small enhancement You could do is change your comment to exdoc format, like this…

  #
  # Take a number of seconds and return a string describing the length of time
  # in the most appropriate way. If we're dealing with seconds on the order of
  # hours, then return hours. If we're dealing with seconds on the order of
  # years, then return years. You get the idea.
  # This function takes leap years into account by treating one year as one
  # year plus one forth of a day.

  @doc ~S"""
  Take a number of seconds and return a string describing the length of time
  in the most appropriate way. If we're dealing with seconds on the order of
  hours, then return hours. If we're dealing with seconds on the order of
  years, then return years. You get the idea.
  This function takes leap years into account by treating one year as one
  year plus one forth of a day.
  """ 

This way You will be able to generate automatic doc for your code :wink:

1 Like

So that’s what I’ve been seeing in some auto-generated files.
Thanks again!
I’ll implement that for sure. :+1:

Why not just do , <lots of spaces> do: with no end? Personally I opt for putting ,do: as I like the alignment better.

Honestly I’d actually rather do this for the whole thing:

  @seconds_in_one_year      31557600    # 31,557,600 seconds    | Includes leap years.
  @seconds_in_one_month     2629800     # 2,629,800 seconds     | Includes leap years.
  @seconds_in_one_week      604800      # 604,800 seconds       | Does not include leap years.
  @seconds_in_one_day       86400       # 86,400 seconds        | Does not include leap years.
  @seconds_in_one_hour      3600        # 3,600 seconds         | Does not include leap years.
  @seconds_in_one_minute    60          # 60 seconds            | Does not include leap years.

  @doc ~S"""
  Take a number of seconds and return a string describing the length of time
  in the most appropriate way. If we're dealing with seconds on the order of
  hours, then return hours. If we're dealing with seconds on the order of
  years, then return years. You get the idea.
  This function takes leap years into account by treating one year as one
  year plus one forth of a day.
  """
  defp display_seconds(seconds), do: cond do
    seconds > (3 * @seconds_in_one_year) ->   {@seconds_in_one_year, "years"}
    seconds > (3 * @seconds_in_one_month) ->  {@seconds_in_one_month, "months"}
    seconds > (3 * @seconds_in_one_week) ->   {@seconds_in_one_week, "weeks"}
    seconds > (3 * @seconds_in_one_day) ->    {@seconds_in_one_day, "days"}
    seconds > (3 * @seconds_in_one_hour) ->   {@seconds_in_one_hour, "hours"}
    seconds > (3 * @seconds_in_one_minute) -> {@seconds_in_one_minutes, "minutes"}
    1 ->                                      {1, "second"}
    true ->                                   {1, "seconds"}
  end |> case do
    {o, s} -> "#{Number.Delimit.number_to_delimited(seconds / o, precision: 0)} #{s}"
  end

The formatter would of course destroy the formatting, so I’d break it up in to more functions, one that probably does the when guard checks and another to do the actual formatting via number_to_delimited, which is more readable anyway. :slight_smile:

2 Likes

I like pipe in case :slight_smile:

A lot of people don’t, but I adore it, and maybe overuse it… ^.^;

I like pipelines, the drive comes from my ML work. ^.^

As an example, a case piping in Elixir would be like this in OCaml:

something
|> (function value -> do_stuff_with value)

Which is definitely a bit more succinct, and I think more readable, but case is as close as we can get in Elixir without getting & ... .() around things. :slight_smile:

1 Like

I see I can avoid the do/end now. I was missing the comma that would make it work.

This new one-function approach is closer to the type of solution I was expecting to get, but has a good bit of complication for me as a newcomer, so I’m going to take some time to unpack it and maybe I’ll end up going that route. Either way, I have some workable solutions.

Thank you very much.

Edit: Okay I fully understand this other approach now. I really do get stuck on some of the simplest things. I can’t wait to be more proficient.

1 Like

Here’s a another approach. This removes the need to duplicate the constant names and comparison logic.

Note: I haven’t run this, and my names could do with a little more thought :slight_smile:

@periods [
  {31_557_600, "years"},
  {2_629_800, "months"},
  {604_800, "weeks"},
  {86400, "days"},
  {3600, "hours"},
  {60, "minutes"}
]

defp display_seconds(seconds) do
  {multiplier, unit} =
    @periods
    |> Enum.find(
      {1, "seconds"}, #This is the default if it can't find anything
      fn {seconds_in, _} -> seconds > 3 * seconds_in end
    )

  delimited = Number.Delimit.number_to_delimited(seconds / multiplier, precision: 0)
  "#{delimited} #{unit}"
end
2 Likes

That looks like a solution similar to my previous question (linked in the first post). I knew it could probably be done that way, but it is well beyond my ability right now to come up with. Nice job. :+1:

As for the naming, I had to come up with similar and I ended up with mole and units instead of multiplier and unit because the chemist in me came out of nowhere. :wink:

2 Likes

I love threads like this that show lots of ways to do things. ^.^

1 Like