Skip to content

Instantly share code, notes, and snippets.

@zabirauf
Created March 26, 2015 07:48
Show Gist options
  • Star 29 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save zabirauf/17ced02bdf9829b6956e to your computer and use it in GitHub Desktop.
Save zabirauf/17ced02bdf9829b6956e to your computer and use it in GitHub Desktop.
Railway Oriented Programming macros in Elixir
defmodule ROP do
defmacro try_catch(args, func) do
quote do
(fn ->
try do
unquote(args) |> unquote(func)
rescue
e -> {:error, e}
end
end).()
end
end
defmacro tee(args, func) do
quote do
(fn ->
unquote(args) |> unquote(func)
{:ok, unquote(args)}
end).()
end
end
defmacro bind(args, func) do
quote do
(fn ->
result = unquote(args) |> unquote(func)
{:ok, result}
end).()
end
end
defmacro left >>> right do
quote do
(fn ->
case unquote(left) do
{:ok, x} -> x |> unquote(right)
{:error, _} = expr -> expr
end
end).()
end
end
end
@pel-daniel
Copy link

You unquote args twice in the tee macro. I know elixir is immutable but I wonder if it can cause troubles later because the code is executing twice.

@phanimahesh
Copy link

Yes, it will cause the code to execute twice. Try that by sneaking in a side effect causing statement in args. A better method is to use

defmacro tee(args, func) do
  quote bind_quoted: [args: args, func: func] do
    (fn ->
      args |> func
      {:ok, args}
    end).()
  end
end

@lkuty
Copy link

lkuty commented Oct 22, 2020

I wanted to be able to have a group of computations (successive function calls) which depends on each other. If I write f1 ~>> f2 I want f1 to try something and if it is successful I do not try f2, but if f1 cannot do its job (not because of an error) then f2 tries to do something else. So I added a {:skip, _} tuple to bypass others successives functions called through ~>>.

Note that, as implemented below, if the last call returns {:ok, _}, it is considered an ok value (atom ok means ok which is good) but it should mean that the call was not successful as it is the case in the previous calls of the ~>> chain. When we return {:skip, _} it is considered successful. Thus the semantic is inversed. I did that to allow injecting {:ok, _} in the first step of ~>> chain and let it go through evaluation. But it might probably be better to do something else. I am thinking about it. Currently, the last function call in the ~>> chain has to know it is the last to behave correctly.

  defmacro left >>> right do
    quote do
      (fn ->
        case unquote(left) do
          {:ok, x}           -> x |> unquote(right)
          {:skip, x}         -> x |> unquote(right)
          {:error, _} = expr -> expr
        end
      end).()
    end
  end

  defmacro left ~>> right do
    quote do
      (fn ->
        case unquote(left) do
          {:ok, x}           -> x |> unquote(right)
          {:skip, _}  = expr -> expr
          {:error, _} = expr -> expr
        end
      end).()
    end
  end

A test module might be:

defmodule Test do
  import ROP

  def f1(input) do
    cond do
      input <  100 -> {:skip, 1}   # f1 did the job, skip the remaining steps
      true         -> {:ok, input} # f1 could not process the job, try with next dtep

    end
  end

  def f2(input) do
    cond do
      input <  150 -> {:skip, 2}   # f2 did the job, skip the remaining steps
      true         -> {:ok, input} # f2 could not process the job, try with next step
    end
  end

  def f3(input) do
    cond do
      input < 250 -> {:skip, 3}  # f3 did the job, skip the remaining steps. we could return {:ok, _}
      true        -> {:error, "#{input} >= 250"} # job could not be done. this is an error
    end
  end

  def f4(input) do
    {:ok, input + 4}
  end

  def process(input) do
    {:ok, input}
    ~>> f1
    ~>> f2
    ~>> f3
    >>> f4
  end
end

And an execution might be:

iex> Test.process(10) 
{:ok, 5}
iex> Test.process(120)
{:ok, 6}
iex> Test.process(220)
{:ok, 7}
iex> Test.process(320)
{:error, "320 >= 250"}

@lkuty
Copy link

lkuty commented Oct 22, 2020

Yes, it will cause the code to execute twice. Try that by sneaking in a side effect causing statement in args. A better method is to use

defmacro tee(args, func) do
  quote bind_quoted: [args: args, func: func] do
    (fn ->
      args |> func
      {:ok, args}
    end).()
  end
end

It does not work and I get the following error:

warning: variable "display" does not exist and is being expanded to "display()"
...
warning: undefined function func/1
...
== Compilation error in file lib/hydro/fish_test.ex ==
** (CompileError) lib/hydro/fish_test.ex:40: undefined function display/0
    (elixir 1.11.1) src/elixir_locals.erl:114: anonymous fn/3 in :elixir_locals.ensure_no_undef
ined_local/3
    (stdlib 3.13.2) erl_eval.erl:680: :erl_eval.do_apply/6

with the following test code:

  def display(x) do
    IO.inspect(x)
  end

  def process(input) do
    {:ok, input}
    ~>> f1
    ~>> f2
    ~>> f3
    >>> f4
    >>> (tee display)
  end

When I change the macro and rewrite it as below, it works.

  defmacro tee(args, func) do
    quote bind_quoted: [args: args], unquote: true do
      (fn ->
        args |> unquote(func)
        {:ok, args}
      end).()
    end
  end

Any idea ? Maybe Elixir tries to invoke the function in the process of unquoting in the bind_quoted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment