While - Elixir macro for ruby traditional while - looking for feedback

Hi Guys,
one more time I had this need for a simple while loop and so I played with this macro idea: https://hex.pm/packages/while

While on globals

  import While

  ref = :counters.new(1, [:atomics])
  while :counters.get(ref 1) < 10 do
    :counters.add(ref 1, 1)
  end

  IO.puts("Current value is #{:counters.get(ref, 1)}")

Now this only works when you’re working with globals, references or pids, which in fact in my case is useful sometimes. But to make it more useful in local scopes I created a more reduce like shortcut:

While on locals

When providing an additional parameter, this is interpreted as a variable name and imported into the scope of the while expression and the while body.

    import While

    cnt = 1
    cnt = while cnt, cnt < 10 do
      cnt + 1
    end

    IO.puts("Current value is #{cnt}")

Explanation:
The while/3 binds the variable name cnt to both the expression cnt < 10 and to the body. The body result (last line of body) always becomes the new value for the bound variable, like in a Enum.reduce.

   cnt - 1

Danger Area

To automate the assignment, there is another variant while_with that will automagically assign to the original variable. Consensus from this discussion seems to be: Don’t use this variant

    import While

    cnt = 1
    while_with cnt, cnt < 10 do
      cnt + 1
    end

    IO.puts("Current value is #{cnt}")

Explanation:
The while_with works exactly like while/3 but the final value, will be assigned to the bound variable of the outer scope, so that: IO.puts("Current value is #{cnt}") will print Current value is 10

Questions
The name while_with might be confusing, should this just be the same name, but a two parameter variant of while , or should it be called reduce_while or something? Curious about the communities thoughts here and whether this kind of macro has any reason to exist at all in the first place :slight_smile:

Installation
While can be installed by adding while to your list of dependencies in mix.exs:

def deps do
  [
    {:while, "~> 0.2.1"}
  ]
end

There’s already Enum.reduce_while, so I’m not sure how good of a name this one would be.

1 Like

Can you elaborate on these concrete use cases? I really can’t think of any situation where I wouldn’t prefer a recursive function, or the use of Enum.

    cnt = 1
    while_with cnt, cnt < 10 do
      cnt + 1
    end

    IO.puts("Current value is #{cnt}")

The hidden rebinding here is particularly alarming since it changes core expectations people have about how Elixir code works. It’s also presumably inconsistent right? What if you do:

i = 0
j = 0
    while_with i, i < 10 do
      i + 1
      j + 1
    end

Is i incremented to 10, but j not?

4 Likes

I don’t think this is a great idea.

There are more language appropriate ways of approaching the problem (see: Write while loop equivalent in elixir), and using this instead of them would actually add complexity for most Elixir programmers.

It’s also worth noting that for takes a reduce option now:

for <<x <- "AbCabCABc">>, x in ?a..?z, reduce: %{} do
  acc -> Map.update(acc, <<x>>, 1, & &1 + 1)
end
%{"a" => 1, "b" => 2, "c" => 1}

https://hexdocs.pm/elixir/Kernel.SpecialForms.html#for/1-the-reduce-option

1 Like

In my case I’m only using the global version of this while loop, and I use them in tests to start processes and wait for them to come up:

01 test "doing some stuff with workers" do
02   while workers_online() < target do
03      start_new_worker()
04   end
05   ...
06 end

I just prefer being able to write this in-line in the function that needs it than externalizing this every time into a recursive function, especially when it’s not reused:

01 test "doing some stuff with workers" do
02    ensure_workers(target)
03    ....
04 end
05 
06 def ensure_workers(target) do
07   if worker_online() < target do
08     start_new_worker()
09     ensure_workers(target)
10   end
11 end

That’s my reason to create it: Laziness of creating a recursive function for each while use case.

Totally agreed, the hidden rebinding is alarming. That’s why I added the _with postfix instead of just overloading the while macro with two parameters for this. Maybe a name like while_bind would indicate that binding easier. First I looked at i = while_with i, i < 10 do: i + 1 but felt it’s just one i too much…

Just for completeness the two, or more variable version would look like this:

i = 0
j = 0
while_with {i, j}, i < 10 do
  {i + 1, j + 1}
end

Normally I’d expect something like this for starting workers in tests:

list_of_workers |> Enum.each(&start_worker/1)

Your version seems to indicate that it does some kind of polling to see if workers are online, but a more idiomatic solution would be to just block until you receive a message indicating that the worker is online.

Yeah, it just appeared to me that a ‘conceptual while’ is basically a Enum.reduce_while without an enum, (or it could be at least):

defmodule Enum do
  def reduce_while(enumerable, acc, fun)
end

defmodule While do
  def reduce_while(acc, fun) do
    case fun.(acc) do
      {:halt, new} -> new
      {:continue, new} -> reduce_while(new, fun)
    end
  end
end

Then you could use it as a normal function:

    i = 1
    i =
      reduce_while(i, fn i ->
        if i < 10 do
          {:continue, i + 1}
        else
          {:halt, i}
        end
      end)

The presented while is just a shorthand / syntactic sugar for this to turn this into:

i = 1
i = while i, i < 10 do
  i + 1
end

Thanks all for the feedback. I’ve removed while_with from the examples and marked it deprecated - because the hidden assignment indeed seems too dangerous.

For those who still want to use while the way to go is the while/3 macro that returns the value, so the assignment is visible in the code:

    cnt = 1
    cnt = while cnt, cnt < 10 do
      cnt + 1
    end
2 Likes

You don’t need to write an explicit recursive function. The same can be achieved with e.g.:

Stream.repeatedly(fn -> worker_online() == target end)
|> Enum.find(& &1)
5 Likes

This is a pretty awesome solution @sasajuric thanks for that - I didn’t think of this combination. It’s not calling start_new_worker() but I guess it would do it like this:

Stream.repeatedly(&start_new_worker/1)
|> Enum.find(fn _ -> worker_online() < target end)

While this looks correct I personally find that the while construct much better transports the meaning, at the same effect.

while workers_online() < target do
  start_new_worker()
end

Cheers

Stream.repeatedly doesn’t take an arg, so it’d look more like:

Stream.repeatedly(&start_workers/1)
|> Enum.find(fn _ -> worker_online() == target end)

Regardless though, the root issue in this case is less about the chosen method of iteration and more about polling workers_online to determine if workers are in fact online. Message passing would be a lot more idiomatic here.

1 Like

I had been working with ruby for 10+ years and there was not a single occasion of me resorting to while loop. I even went to docs now to check if ruby indeed has while in a core.

Imperative loops are extremely counter-idiomatic even in ruby, in Elixir they are plain alien. Besides that Stream module provides several ways to accomplish the task, the plain recursion would perfectly suit here.

1 Like

Or, alternatively, GenServer.handle_continue/2 that blocks (returning {:noreply, _state, {:continue, :wait_for_workers}}) unless there are N workers. One might put this watchdog into a dedicated start_phase to precisely tune up the starting process.

1 Like

To make this happen, you could do something like:

Stream.repeatedly(&start_new_worker/0)
|> Enum.take(max(target - workers_online(), 0))
2 Likes