Skip to content

jisaacstone/ex_rfn

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Recursive Anonymous Functions in Elixir

A Tale of Combinators and Macros

1. Recursion

Recursion is the core of Elixir and Erlang code. But it is missing from anonymous functions.

iex(9)> sum = fn [] -> 0; [h|t] -> h + sum.(t) end
** (CompileError) iex:9: undefined function sum/0
		(stdlib) lists.erl:1353: :lists.mapfoldl/3
		(stdlib) lists.erl:1354: :lists.mapfoldl/3

Why? In a match expression the right hand side is evaluated first, then matched to the left. So the variable sum has not yet been assigned a value when fn [] -> 0; [h|t] -> h + sum.(t) end is evaluated.

2. Erlang R17

Recursive anonymous functions were added to Erlang in R17. Elixir solved the match problem by including a name in the right hand side of the match expression.

From Joe Armstrong's Announcement:

F = fun Fact(0) -> 1; 
		Fact(N) -> N * Fact(N - 1) 
	end.

But elixir core team has not yet finalized if or how they will include the same functionality.

But there's no need to wait; using macros we can extend elixir syntax (almost) however we'd like.

3. Combinators

Mathematicians and computer scientists have solved the problem of implementing recursion: the fixed-point combinator

I am not a mathematician or a computer scientist but thanks to The Little Schemer I know a bit about combinators anyway. And fortunatly others have already written some in elixir. Here is a Y combinator, and here is a Z combinator

I will use the z combinator from exyz as a template. exyz is available on hex. It looks like this:

def z_combinator f do
  combinator = fn(x) ->
    f.(fn(y) -> x.(x).(y) end)
  end
  combinator.(combinator)
end

And works like this:

factorial = Exyz.z_combinator fn(f) ->
  fn
    (1) -> 1
    (n) -> n * f.(n - 1)
  end
end

factorial.(5) == 120

Beautiful. But it is limited: it can only handle functions with an arity of 1.

(This is not really a limitation. Using a list or tuple argument is natural and easy. But I wanted to try and improve it anyway)

4. Plan

I want to build a macro that can handle all functions, regardless of arity. This is the syntax I want:

f = rfn count, fn
  (_, [], c) -> c
  (x, [x|t], c) -> count.(x, t, c + 1)
  (x, [_|t], c) -> count.(x, t, c)
end

f.(:a, [:a, :b, :b, :a], 0) == 2

I will be building off of the Z combinator in exyz:

def z_combinator f do
	combinator = fn(x) ->
		f.(fn(y) -> x.(x).(y) end)
	end
	combinator.(combinator)
end

After staring at it for a half hour I determined I'd need to change all occurrences of y to y0, y1 ... yN where N == arity(f)

5. Quote

Lets take a look at the abstract syntax tree of an fn

iex> quote do fn(a, b) -> :ok end end
{:fn, [], [{:->, [], [[{:a, [], Elixir}, {:b, [], Elixir}], :ok]}]}

I can see where args a and b are. So I'll need 3 functions. On to count the number of args in a fn, one to generate n args, and one to create an abstract syntax tree with those args.

Generating args is simple. We can use Macro.var/2

def gen_args(0, args), do: args
def gen_args(n, args) do
	n_args(n - 1, [Macro.var(:"arg_#{n - 1}", __MODULE__) | args])
end

Testing it out:

iex> args = gen_args(2, [])
[{:arg0, [], Rfn}, {:arg1, [], Rfn}]

iex> ast = quote do fn unquote(args) -> :ok end end
{:fn, [], [{:->, [], [[[{:arg0, [], Rfn}, {:arg1, [], Rfn}]], :ok]}]}

iex> Macro.to_string(ast)
"fn [arg0, arg1] -> :ok end"

That didn't quite work. Instead of a fn with arity 2, I generated a function with arity 1 that matched against a 2 element list.

So here's where I get a bit tricky. While I would never use this in production, nothing is preventing me from hand-rolling an abstract syntax tree.

defp fn_ast(vars, meta, body) do
	{:fn, meta, [{:->, meta, [vars, body]}]}
end

Testing it out:

iex> args = gen_args(2, [])
[{:arg0, [], Rfn}, {:arg1, [], Rfn}]

iex> ast = fn_ast(args, [], :ok)
{:fn, [], [{:->, [], [[{:arg0, [], Rfn}, {:arg1, [], Rfn}], :ok]}]}

iex> Macro.to_string(ast)
"fn arg0, arg1 -> :ok end"

Much better.

Finally a function to count the number of args. Reverse the fn_ast code above and tweak it a bit.

voilà

defp num_args({:->, _, [args | _body]}) do
	length(args)
end

5. Putting it together

Now we have a way to genereate a fn with arity n we can improve the z combinator from before to handle fns of any arity!

defmacro rfn(var, {:fn, meta, [c|_clauses]} = f) do
	n = num_args(c)
	args = gen_args(n, [])
	namedf = quote do
		fn (unquote(var)) -> unquote(f) end
	end
	combinator_fun = combinator_ast(namedf, args, meta)
	quote do
		combinator = unquote(combinator_fun)
		combinator.(combinator)
	end
end

defp combinator_ast(namedf, args, meta) do
	xvar = Macro.var(:x, __MODULE__)
	# fn (args...) -> xvar.(xvar).(args...) end
	inner = fn_ast(args, {
		{ :., meta,
			[ { {:., meta, [xvar]}, meta,
					[xvar] } ] },
		meta, args }, meta )

	# fn (xvar) -> var.(inner) end
	quote do
		fn unquote(xvar) -> unquote(namedf).(unquote(inner)) end
	end
end

testing it out:

iex> import Rfn
nil
iex> f = rfn count, fn
...>   (_, [], c) -> c
...>   (x, [x|t], c) -> count.(x, t, c + 1)
...>   (x, [_|t], c) -> count.(x, t, c)
...> end
#Function<18.54118792/3 in :erl_eval.expr/5>
iex> f.(:a, [:a, :b, :b, :a], 0) == 2
true

It works!

6. Caution

Macros are hard. Even harder is manipulation of the elixir abstract syntax tree. There is no guarantee the ast will not change in different environments and different platforms. I already know many situations where the code given here will fail. For example it does not handle guard clauses.

I've already found and fixed a few limitations not covered in the code given above. Look at the source on github for more details and some working code. But I won't be publishing this to hex because I don't want anyone using it for anything important.

Feel free to hit me up with questions, comments or ways the code could be improved. In particular I'm wondering if there is a way to achieve what fn_ast does using quote and unquote.

About

recursive anonymous functions in elixir

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages