Skip to content

Latest commit

 

History

History
665 lines (463 loc) · 20.6 KB

metaprogramming.livemd

File metadata and controls

665 lines (463 loc) · 20.6 KB

Metaprogramming

Mix.install([
  {:jason, "~> 1.4"},
  {:kino, "~> 0.9", override: true},
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"}
])

Navigation

Review Questions

Upon completing this lesson, a student should be able to answer the following questions.

  • What does the use keyword do?
  • What are the differences between a macro and a function?

Overview

Compile-time Vs Runtime

In programming, compile-time refers to the period of time during which a program is being compiled from source code into executable machine code, and runtime refers to the period of time during which a program is executing.

The Open Telecom Platform (OTP) is a collection of Erlang libraries and tools for building concurrent, fault-tolerant, and distributed systems in the Erlang programming language.

The BEAM is the runtime environment for the Erlang and Elixir. It is a virtual machine that runs on a wide variety of platforms, including Unix-like operating systems, Windows, and MacOS. We often use the terms Erlang Virtual Machine and BEAM interchangeably.

The Elixir compiler compiles Elixir source code into bytecode .beam files that are executed on the BEAM.

The BEAM is part of the Erlang Run-Time System (ERTS).

The Erlang Run-Time System (ERTS) is a component of the Open Telecom Platform (OTP) and is responsible for executing Erlang and Elixir programs on the Erlang Virtual Machine (VM). The BEAM virtual machine is part of the Erlang Run-Time System.

The Abstract Syntax Tree (AST) is a representation of the structure of a program's source code as a tree-like data structure. The Elixir AST is used by the Elixir compiler to transform Elixir code into bytecode that can be executed by the BEAM virtual machine. The Elixir compiler converts the Elixir code into the AST, and then uses the AST to generate the bytecode.

The AST provides a convenient intermediate representation of the source code that can be used to perform various optimizations and transformations on the code before it is translated into bytecode.

Metaprogramming

Metaprogramming is the process of writing code that generates code.

In Elixir, we use macros for code generation. Macros are a powerful tool for generating code. However, they can lead to additional complexity and should be used with care.

Even though Elixir attempts its best to provide a safe environment for macros, the major responsibility of writing clean code with macros falls on developers. Macros are harder to write than ordinary Elixir functions and it’s considered to be bad style to use them when they’re not necessary. So write macros responsibly.

We're going to learn how to leverage metaprogramming to extend the Elixir language and minimize boilerplate code.

While we're not likely to use metaprogramming in our day-to-day programming (depending on what you're building), we're going to use metaprogramming to gain a better understanding of the inner-workings of Elixir.

Quote

Under the hood, Elixir represents expressions as three-element tuples. We call this representation the AST (abstract syntax tree). Elixir lets us inspect the AST representation of expressions using the quote macro.

quote do
  2 + 2
end

The three-element tuple above is often called a quoted expression.

The first element in the tuple is the function name. The second element is a keyword list containing metadata, and the third element is a list of arguments.

{function, metadata, arguments}

So 2 + 2 as a quoted expression is

  • function: :+
  • metadata: [context: Elixir, import: Kernel]
  • arguments: [2, 2]

The function name is :+, which refers to the Kernel.+/2 function. + is simply a convenient syntax for calling this function.

Kernel.+(2, 2) == 2 + 2

The metadata includes information about the environment. By default, the :context is Elixir because we are in the top-level scope.

The context changes if we use quote in a module. Now the context will be the name of the module.

defmodule MyModule do
  def example do
    quote do
      2 + 2
    end
  end
end

MyModule.example()

We can also call quote on a single line.

quote do: 1 - 1

The AST represents primitive data types as themselves rather than three-element tuples.

quote do: 2

All other expressions will be three-element tuples—even non-primitive data types such as maps.

quote do: %{key: "value"}

Here's an anonymous function as a quoted expression.

sum = fn int1, int2, int3 -> int1 + int2 + int3 end

quote do: sum(1, 2, 3)

Here's a named function as a quoted expression.

defmodule Math do
  def sum(int1, int2, int3) do
    int1 + int2 + int3
  end
end

quote do: Math.sum(1, 2, 3)

Arguments in the three-element tuple can themselves be three-element tuples.

quote do: sum(1, 2, sum(1, 2, 3))

Your Turn

Use the quote macro to discover the AST representation of the following expression. You may also choose to experiment with quote with other Elixir expressions to see their quoted expression representation.

2 + 2 + 2

Unquote

unquote injects code into the quote macro.

We can use unquote to inject some computed value into a quote block.

For example, the following unquote(1 + 1) evaluates to 2 inside of the quote block.

quote do
  2 + unquote(1 + 1)
end

The above quote expression is equivalent to 2 + 2, because we injected the result of 1 + 1 into the quote expression using unquote.

quote do
  2 + 2
end

Notice this is not the same AST as 2 + 1 + 1, which breaks down into multiple three-element tuples, because it is actually two separate addition expressions.

quote do
  2 + 1 + 1
end

Variables outside the quote block will not be available within the quote block. So we can use unquote to inject their evaluated value.

my_variable = 5

quote do
  2 + unquote(my_variable)
end

This creates the same quoted expression as 2 + 5.

quote do
  2 + 5
end

Macro And Code Modules

The Elixir Code module is a module in the Elixir standard library that provides functions for working with code at runtime. The Code module provides several functions for evaluating code, such as Code.eval_string/2 and Code.eval_quoted/2, which allow you to dynamically generate and execute code based on runtime conditions.

The Code module also provides functions for working with the Abstract Syntax Tree (AST) of Elixir code, such as Code.string_to_quoted/1 which allow you to convert between Elixir code as a string and the AST representation of the code.

The Macro modules contains functions for manipulating the AST and implementing macros. The Macro module provides the Macro.to_string/1 function.

The Code module is often used in conjunction with metaprogramming techniques in Elixir, as it allows you to manipulate and execute code at runtime. However, it is important to use these functions carefully, as dynamically generating and executing code can be complex and difficult to understand, and can have unintended consequences if not used correctly.

Reading AST

We can use Macro.to_string/1 to convert a quoted expression into an Elixir expression (in a string).

quoted = quote do: 2 + 2

Macro.to_string(quoted)

We can provide the AST directly as a three-element tuple.

ast = {:+, [context: Elixir, imports: [{1, Kernel}, {2, Kernel}]], [2, 5]}

Macro.to_string(ast)

Evaluating Strings As Source Code

We can evaluate a string as Elixir code using the Code.eval_string/3 function.

Code.eval_string("2 + 5")

Variables in the string can be provided by providing a keyword list of bindings as the second argument to the function.

Code.eval_string("2 + a", a: 2)

Evaluating AST

We can use the Code.eval_quoted/2 function to evaluate the AST. Here, we take a quoted expression and evaluate it to find the result.

quoted =
  quote do
    2 + 2
  end

Code.eval_quoted(quoted)

Quoted expressions to not have access to bound variables. Below we'll see that a is not defined in the quoted expression.

a = 2

quoted =
  quote do
    a + 2
  end

Code.eval_quoted(quoted)

To gain access to the variable, we can use unquote to evalate it into the quoted expression.

a = 2

quoted =
  quote do
    unquote(a) + 2
  end

Code.eval_quoted(quoted)

Alternatively, we can provide the variable bindings in a keyword list as the second argument to the Code.eval_quoted/2 function.

quoted =
  quote do
    unquote(a) + 2
  end

Code.eval_quoted(quoted, a: 2)

Macros

We can use macros to extend the Elixir language or create DSLs (Domain-specific Languages). For example, every time you use test and assert in ExUnit, you use ExUnit macros.

ExUnit.start(auto_run: false)

defmodule Test do
  use ExUnit.Case

  test "example" do
    assert 1 == 2
  end
end

ExUnit.run()

It's often idiomatic to use macros without round brackets. However, there's nothing preventing you from using brackets with macros. do end blocks are actually just an alternative syntax for writing a keyword list as an argument.

ExUnit.start(auto_run: false)

defmodule BracketExample do
  use ExUnit.Case

  test("example", do: assert(1 == 1))
end

ExUnit.run()

Much of Elixir syntax is implemented using macros. Keywords such as def are actually just macros on the Kernel module, and do end blocks are actually just keyword lists being provided as an argument to the macro. 🤯

Kernel.defmodule(Mind, do: Kernel.def(blown, do: "🤯"))

Mind.blown()

Writing Our Own Macro

Unlike functions, macros are expanded at compile-time, so they retain knowledge of the values they are called with.

Notice that ExUnit can determine the operator and values used in the assertion assert 1 == 2 to provide test feedback.

"""
Assertion with `==` failed.

left: 1
right: 2
"""

To understand how ExUnit leverages the power of macros to provide better test feedback, we're going to create our own assert macro.

The assert macro will accept a truthy expression and print a message with feedback. Notice that we cannot accomplish this with a function. Functions accept the evaluated result of an expressions an argument. We lose the context about the operator and values.

inspect_argument = fn expression ->
  IO.inspect(expression, label: "Evaluated Result Of Expression")
end

inspect_argument.(1 == 2)

We use defmacro to define a macro.

The AST representation of an expression knows the function and the arguments the macro was called with.

defmodule ASTInspector do
  defmacro inspect(ast) do
    IO.inspect(ast, label: "ast")
  end
end

To use a macro, we need to require it.

require ASTInspector

ASTInspector.inspect(2 == 1)

We have everything we need in this AST tuple to get the operator, the left side of the expression, and the right side of the expression.

defmodule ExpressionInspector do
  defmacro inspect({operator, _meta, [left, right]}) do
    IO.inspect(operator, label: "operator")
    IO.inspect(left, label: "left")
    IO.inspect(right, label: "right")
  end
end
require ExpressionInspector

ExpressionInspector.inspect(2 == 2)

We want to verify if the left and right values are equal. We'll make an Assertion.Test module that uses pattern matching to return a success message, or failed assertion message depending on if the left and right sides are equal.

defmodule Assertion.Test do
  def assert(:==, left, right) when left == right do
    "Success!"
  end

  def assert(:==, left, right) do
    """
    Assertion with == failed.
    left: #{left}
    right: #{right}
    """
  end
end

Assertion.Test.assert(:==, 1, 2) |> IO.puts()

Then we can use a Macro to get the operator, left, and right values to use with our Assertion.Test module.

A macro generates code, so generally it should return an AST expression, not a return value. We use quote to create the AST representation of our function call. We'll use the Assertion.Test.assert function inside our quoted expression. We also need to use unquote to use bound variables inside the quote block. The same is true for parameters. So we need to use unquote to inject their evaluated value into the quote block.

defmodule Assertion do
  defmacro assert({operator, _meta, [left, right]}) do
    quote do
      Assertion.Test.assert(unquote(operator), unquote(left), unquote(right))
    end
  end
end

When we call the assert macro below it compiles into Assertion.Test.assert(:==, 1, 2) which then evaluates during runtime.

require Assertion

Assertion.assert(1 == 2) |> IO.puts()

Alternatively, we can use bind_quoted to bind multiple values to the quoted expression without unquote. This is just a syntax sugar to avoid using unquoted multiple times.

defmodule AssertionWithBindQuoted do
  defmacro assert({operator, _meta, [left, right]}) do
    quote bind_quoted: [operator: operator, left: left, right: right] do
      Assertion.Test.assert(operator, left, right)
    end
  end
end

The macro continues to work as expected.

require AssertionWithBindQuoted

AssertionWithBindQuoted.assert(1 == 2) |> IO.puts()

Use And using

While you may not write macros often, you are likely to use them daily. For example, we have already relied on macros with the use keyword.

When we use GenServer, a macro generates the necessary boilerplate code to make a GenServer.

defmodule Server do
  use GenServer

  def init(state) do
    {:ok, state}
  end
end

We can use the __info__/2 function on the Server module to gain insight into code generated under the hood. Here we see it defines several functions.

Server.__info__(:functions)

The use keyword provides a clean and controlled interface for working with macros. Under the hood, the use keyword calls a __using__ macro in the specified module.

defmodule Template do
  defmacro __using__(_opts) do
    quote do
      def template_function do
        "hello"
      end
    end
  end
end

Conceptually, it may help think of modules that define macros as templates or common patterns that we can reuse throughout a program. For example, GenServer is a common pattern we want to extend and reuse.

defmodule ExtendedTemplate do
  use Template

  def extended_function() do
    template_function() <> " world"
  end
end

Once we define our pattern, we can reuse it throughout our program and extend its functionality.

ExtendedTemplate.template_function()
ExtendedTemplate.extended_function()

For a real-world example, it's common to create custom ExUnit cases for common test scenarios. The example below is only a small example of what's possible. Here we create an IOCase module which automatically imports the ExUnit.CaptureIO module that provides the capture_io/1 for testing if we print a message using IO.

defmodule IOCase do
  # Use the module
  defmacro __using__(_opts) do
    quote do
      use ExUnit.Case
      import ExUnit.CaptureIO
    end
  end
end

ExUnit.start(auto_run: false)

defmodule IOTest do
  use IOCase

  test "capure io" do
    capture_io(fn -> IO.puts("hello") end) =~ "hello"
  end
end

ExUnit.run()

Your Turn

Create a Greetings module with a __using__ macro. define a hello/0 function inside of the __using__ macro. You may choose to experiment with defining other functions or module attributes.

def hello do
  "hello"
end

Create a Usage module that uses the use keyword to call the __using__ macro in the Greetings module.

defmodule Greetings do
end

defmodule Usage do
end

Call Usage.hello() to ensure your solution works correctly.

Usage.hello()

Further Reading

Consider the following resource(s) to deepen your understanding of the topic.

Commit Your Progress

DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.

Run git status to ensure there are no undesirable changes. Then run the following in your command line from the curriculum folder to commit your progress.

$ git add .
$ git commit -m "finish Metaprogramming reading"
$ git push

We're proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.

We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.

Navigation