Skip to content

Instantly share code, notes, and snippets.

@mgwidmann
Last active November 20, 2022 20:03
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save mgwidmann/8238ead88ff57088ca0b to your computer and use it in GitHub Desktop.
Save mgwidmann/8238ead88ff57088ca0b to your computer and use it in GitHub Desktop.
An example of metaprogramming, extending the Elixir language, to add the while keyword. Taken from Chris McCord's example in his Metaprogramming Elixir book.
# The Elixir language is very extensible to allow for future additions or
# third party developers to take the language in directions that the original
# authors could not predict.
#
# Lets start with understanding what an Elixir macro is
iex> quote do
...> 1 + 1
...> end
{:+, [context: Elixir, import: Kernel], [1, 1]}
# All code in elixir can be transformed into the Abstract Syntax Tree (AST)
# which all languages have, but few expose. Calling the `quote/1` macro performs
# this transformation for the developer so that the code may be manipualted
# as data. The format is
# {function, metadata, arguments}
# Quoted code is executed in another context, so local variables do not apply
# unless we tell it to bring it in using the `unquote/1` function
iex> a = 1
1
iex> quote do
...> unquote(a) + 1
...> end
{:+, [context: Elixir, import: Kernel], [1, 1]}
# See how it evaluated `a` and the AST returned doesn't include any
# reference to it anymore? This is basically string interpolation,
# but for code!
# The keyword we want to add will take the form
while some_expression do
a_statement
another_statement
whatever
end
# To create a macro, simply define a module and put a defmacro call
defmodule While do
defmacro while(expression, do: block) do
quote do
IO.puts "Got: #{unquote(inspect expression)}\n#{unquote(inspect block)}"
end
end
end
# Macros must return a quoted expression, or it will fail to compile. Here
# we always transform our current `while/2` call into a print statement to
# test it out.
iex> import While
nil
iex> while a < b do
...> call_a_function(with: data) # Notice how this stuff doesnt need to exist?
...> end # Thats because this code is never actually executed!
Got: {:<, [line: 18], [{:a, [line: 18], nil}, {:b, [line: 18], nil}]}
{:call_a_function, [line: 19], [[with: {:data, [line: 19], nil}]]}
# Macros receive the quoted expression rather than the evaluated
# expression, and they are expected to return another quoted expression.
# Lets write our while macro!
defmodule While do
defmacro while(expression, do: block) do
quote do
for _ <- Stream.cycle([:ok]) do # Stream.cycle will create an infinite list to loop through
if unquote(expression) do # Whenever this is true we want to execute the block code
unquote(block)
else
# break out somehow
end
end
end
end
end
# This works except we cannot stop the loop ever, since we cannot break out.
# One (less than ideal, but functional) way of breaking out is to throw an
# exception. This isn't a great pattern, but you'll find that this example is
# contrived because a while loop isn't even necessary in the Elixir language.
# Throw an exception to break out
defmodule While do
defmacro while(expression, do: block) do
quote do
try do # Surround whole for loop with try, so that we can catch when they want to break out
for _ <- Stream.cycle([:ok]) do # Stream.cycle will create an infinite list to loop through
if unquote(expression) do # Whenever this is true we want to execute the block code
unquote(block)
else
throw :break
end
end
catch
:break -> :ok # We only catch the value `:break` if it was thrown, all else is ignored
end
end
end
end
# This works now, but attempting to try it makes it seem like it doesn't work.
# If you try something like:
iex> a = 1
1
iex> while a < 10 do
...> a = a + 1
...> end
# This spins forever and never exits. Thats because data is immutable in Elixir.
# The variable `a` is rebound, each loop of a for loop is effectively a new scope
# since variables created within cannot be referenced outside. Therefore, `a` in
# the while expression always refers to the outside `a` and the `a` created in the
# block is a new `a` which is immediately garbage collected.
# To actually test this we can rely on another process
# Spawn a proccess that will sleep for a minute
iex> pid = spawn fn -> :timer.sleep(60_000) end
#PID<0.183.0>
iex> while Process.alive?(pid) do
...> IO.puts "#{inspect :erlang.time} Still alive!"
...> end
# This prints out the time and the phrase "Still alive!" for less than a
# minute (or more if you're slow typer).
# Now to add the break feature, so users can exit when they choose.
# Simply replace the `throw :break` with `break` in the while macro
# and add this funciton in the same module:
def break, do: throw :break
# Now breaking is possible
iex> pid = spawn fn -> :timer.sleep(999_999_999) end
#PID<0.291.0>
iex> while Process.alive?(pid) do
...> if match?({_, _, 0}, :erlang.time) do
...> break
...> else
...> IO.puts "#{inspect :erlang.time} Waiting for the minute to end"
...> end
...> end
# This will print out the time and the phrase until it hits the 0 second,
# which will be when a new minute begins.
# You can see what something compiles to fairly easily.
iex> quote do
...> while true do
...> :ok
...> end
...> end |> Macro.expand(__ENV__) |> Macro.to_string |> IO.puts
# Printed to screen:
try() do
for(_ <- Stream.cycle([:ok])) do
if(true) do
:ok
else
break
end
end
catch
:break ->
:ok
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment