Search⌘ K
AI Features

Custom Language Constructs

Explore how to build custom language constructs in Elixir by recreating core macros like if and designing new features such as a while loop. Understand the use of macros, AST transformation, and control flow techniques to extend Elixir's capabilities and create flexible, reusable code.

We’ve learned that macros allow us to effectively create our own keywords in the language, but they also allow Elixir to be flexible against future requirements.

For example, instead of waiting for the language to add a parallel for comprehension, we could extend the built-in for macro with a new para macro that spawns processes to run the comprehensions in parallel.

It could look something like this:

Shell
para(for i <- 1..10 do: i * 10)

If implemented, para would transform the for AST into code that runs the comprehension in parallel. The original code would gain just one natural para invocation while executing the built-in comprehension in an entirely new way.

José, the creator of Elixir, gives us a solid language foundation that we can craft to meet our needs.

Re-creating the if macro

Consider the if macro from our unless example in the lesson Macros: The Building Blocks of Chapter 2. The if macro might appear special, but we know it’s a macro like any other.

Let’s recreate Elixir’s if macro to learn how easy it is to implement features using the building blocks of the language.

Consider the following example:

defmodule ControlFlow do

  defmacro my_if(expr, do: if_block), do: if(expr, do: if_block, else: nil)
  defmacro my_if(expr, do: if_block, else: else_block) do
    quote do
      case unquote(expr) do
        result when result in [false, nil] -> unquote(else_block)
        _ -> unquote(if_block)
      end
    end
  end
end
A `ControlFlow` module with `my_if` macro.

Note: Run the project and enter the following expressions in the iex terminal:

iex> c "if_recreated.exs"
# Output: [MyIf]

iex> require ControlFlow
# Output: nil
iex> ControlFlow.my_if 1 == 1 do
...>   "correct"
...> else
...>   "incorrect"
...> end
# Output: "correct"

In fewer than ten lines of code, we recreated an essential construct in Elixir using case to handle control flow.

Now that we’ve seen first-class macros, let’s make things more interesting by creating an entirely new language feature. We’ll use the same technique, so existing macros will serve as building blocks of our implementation.

Adding a while loop to Elixir

Elixir lacks the familiar while loop found in most languages. It’s not an essential feature, but sometimes it would be convenient to have around.

Remember that Elixir was designed to be extensible. The language is small because it doesn’t have to include all common features. If we need a while loop, we can create it in Elixir.

We’ll extend Elixir with a new while macro that loops repeatedly with the ability to break out of its own execution. Here’s an example of the feature we will create:

Shell
while Process.alive?(pid) do
send pid, {self, :ping}
receive do
{^pid, :pong} -> IO.puts "Got pong"
after 2000 -> break
end
end

When we create feature like this, it’s best to start by choosing which Elixir building blocks will be required to accomplish our high-level goals. Our main issue is that Elixir has no built-in way to loop infinitely. So how will we handle a repetitive loop without such a feature? We cheat. We’ll get creative by consuming an infinite stream with for to achieve the same effect as an infinite loop.

We’ll start by defining a while macro within a Loop module:

defmodule Loop do

  defmacro while(expression, do: block) do
    quote do
      for _ <- Stream.cycle([:ok]) do
        if unquote(expression) do
          unquote(block)
        else
          # break out of loop
        end
      end
    end
  end
end
A Loop module with step1 of while macro.

Note: Run the project and execute the following commands in the iex terminal:

iex(1)> c "while_step1.exs"
# Output: [Loop]
iex(2)> import Loop
# Output: nil
iex(3)> while true do
...(3)>   IO.puts "looping!"
...(3)> end
# Output:
#looping!
#looping!
#looping!
#looping!
#looping!
#looping!
# ...
# ^C^C

Note: Be ready to trigger Control-C to break out of the infinite loop we’ve created.

We began by pattern matching directly on the provided expression and block of code. We needed to produce an AST for the caller, so we started a quoted expression like all macros. Next, we effectively created an infinite loop by consuming the infinite stream, Stream.cycle([:ok]).

Within our for block, we injected the expression into an if/else clause to execute the provided block of code conditionally. We haven’t yet provided a way to break out of execution, but let’s experiment with our infinite loop in iex to make sure we’re on the right track.

Our first step is complete. We were able to execute a block of code repeatedly given an expression. Next, we need the ability to break out of execution once the expression is no longer true. Elixir’s for comprehension has no built-in way to terminate early, but with a careful try/catch block, we can throw a value to stop execution. Let’s throw and catch a :break value to halt the infinite loop.

We’ll update our Loop module in the following way:

defmodule Loop do

  defmacro while(expression, do: block) do
    quote do
      try do                            
        for _ <- Stream.cycle([:ok]) do
          if unquote(expression) do
            unquote(block)
          else
            throw :break                
          end
        end
      catch
        :break -> :ok                   
      end
    end
  end
end
A Loop module with step2 of while macro.

On line 5, we wrap our entire for comprehension within a try/catch block. Next, we simply throw a :break value on line 10 and catch the value on line 14 to break out of the infinite loop.

Let’s see it in action in iex by executing the following commands:

iex> c "while_step2.exs"
# Output: [Loop]
iex> import Loop

iex> run_loop = fn ->
...>   pid = spawn(fn -> :timer.sleep(4000) end)
...>   while Process.alive?(pid) do
...>     IO.puts "#{inspect :erlang.time} Stayin' alive!"
...>     :timer.sleep 1000
...>   end
...> end
# Output:
#Function<20.90072148/0 in :erl_eval.expr/5>

iex> run_loop.()
# Output:
# {8, 11, 15} Stayin' alive!
# {8, 11, 16} Stayin' alive!
# {8, 11, 17} Stayin' alive!
# {8, 11, 18} Stayin' alive!
# :ok
iex>

We now have a functioning while loop. Careful use of throw allows us to break out of execution whenever the while expression is no longer true.

Let’s provide a break function to allow the caller to explicitly terminate execution:

defmodule Loop do

  defmacro while(expression, do: block) do
    quote do
      try do
        for _ <- Stream.cycle([:ok]) do
          if unquote(expression) do
            unquote(block)
          else
            Loop.break
          end
        end
      catch
        :break -> :ok
      end
    end
  end

  def break, do: throw :break              
end
A Loop module with final while macro.

On line 19, we defined a break function for the caller that throws the :break value. The caller could throw the value, but providing a high-level break function abstracts the internal implementation and unifies the termination with the while macro.

Let’s run the project and head over to iex terminal to experiment with our final implementation by the following commands:

Elixir
iex> c "while.exs"
# Output: [Loop]
iex> import Loop
# Output: nil
iex>
pid = spawn fn ->
while true do
receive do
:stop ->
IO.puts "Stopping..."
break
message ->
IO.puts "Got #{inspect message}"
end
end
end
# Output: #PID<0.93.0>
iex> send pid, :hello
# Output:
# Got :hello
:# hello
iex> send pid, :ping
# Output:
# Got :ping
# :ping
iex> send pid, :stop
# Output:
# Stopping...
# :stop
iex> Process.alive? pid
# Output: false

Congrats! You’ve just learned how to create an entirely new addition to the language!

We used the same technique that Elixir uses internally by leveraging existing macros as building blocks. Step by step, we transformed the expression and code block into an infinite loop with conditional termination.

This kind of extension is what Elixir is all about. Next, we’ll use AST introspection for smarter assertions and create a mini testing framework.