Metaprogramación

Esta traducción está actualizada.

La Metaprogramación es el proceso de usar código para escribir código. En Elixir esto nos da la habilidad de extender el lenguaje para que encaje con nuestras necesidades y poder cambiar el código dinámicamente. Vamos a empezar mirando como Elixir está representado por detrás, luego vamos a modificarlo y finalmente podemos usar este conocimiento para extenderlo.

Unas palabras de advertencia: La Metaprogramación es complicada y solo debería ser usada cuando sea realmente necesario. El abuso con seguridad acabará con código complejo que es difícil de mantener y depurar.

Tabla de contenidos

Quote

El primer paso en Metaprogramación es entender como las expresiones están representadas. En Elixir el árbol de sintaxis abstracto (AST), la representación interna de nuestro código, está compuesta de tuplas. Estas tuplas contienen tres partes: el nombre de la función, la metadata y los argumentos de la función.

Para ver estas estructuras internas, Elixir nos provee con la función quote/2. Usando quote/2 podemos convertir código Elixir a su representación subyacente.

iex> quote do: 42
42
iex> quote do: "Hello"
"Hello"
iex> quote do: :world
:world
iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}
iex> quote do: if value, do: "True", else: "False"
{:if, [context: Elixir, import: Kernel],
 [{:value, [], Elixir}, [do: "True", else: "False"]]}

¿Te das cuenta de que el primer árbol no retorna tuplas? Hay cinco literales que se retornan a si mismos cuando son quoted.

iex> :atom
:atom
iex> "string"
"string"
iex> 1 # All numbers
1
iex> [1, 2] # Lists
[1, 2]
iex> {"hello", :world} # 2 element tuples
{"hello", :world}

Unquote

Ahora podemos recuperar la representación interna de nuestro código, ¿cómo lo modificamos? Para inyectar nuevo código o valores podemos usar unquote/1. Cuando hacemos unquote a una expresión esta será evaluada e inyectada dentro del AST. Para demostrar unquote/1 vamos a mirar algunos ejemplos:

iex> denominator = 2
2
iex> quote do: divide(42, denominator)
{:divide, [], [42, {:denominator, [], Elixir}]}
iex> quote do: divide(42, unquote(denominator))
{:divide, [], [42, 2]}

En el primer ejemplo nuestra variable denominator es quoted entonces el AST resultante incluye una tupla para acceder a la variable. En cambio en el ejemplo con unquote/1 el código resultante incluye el valor de denominator.

Macros

Una vez que entendemos quote/2 y unquote/1 estamos listos para sumergirnos dentro de los macros. Es importante recordar que los macros, al igual que toda la Metaprogramación, debería ser usada escasamente.

En términos simples los macros son funciones especiales diseñadas para retornar una expresión quoted que será insertada dentro del código de nuestra aplicación. Imagina al macro siendo reemplazado con la expresión quoted en lugar de ser llamado como una función. Con macros tenemos todo lo necesario para extender Elixir y agregar código dinámicamente a nuestra aplicación.

Empecemos por definir un macro usando defmacro/2 el cual como mucho de Elixir es un macro en si mismo. Como ejemplo vamos a implementar unless como un macro. Recuerda que nuestro macro necesita retornar una expresión quoted:

defmodule OurMacro do
  defmacro unless(expr, do: block) do
    quote do
      if !unquote(expr), do: unquote(block)
    end
  end
end

Vamos a requerir nuestro módulo y darle a nuestro macro un giro:

iex> require OurMacro
nil
iex> OurMacro.unless true, do: "Hi"
nil
iex> OurMacro.unless false, do: "Hi"
"Hi"

Debido a que los macros reemplazan código de nuestra aplicación, podemos controlar cuando y que es compilado. Un ejemplo de esto puede encontrarse en el módulo Logger. Cuando el logging está deshabilitado ningún código es inyectado y la aplicación resultante no contiene referencias o funciones que llamen al logging. Esto es diferente de otros lenguajes donde hay incluso una sobrecarga de una función incluso cuando la implementación no fue provista.

Para demostrar esto vamos a hacer un logger simple que puede ser habilitado o deshabilitado:

defmodule Logger do
  defmacro log(msg) do
    if Application.get_env(:logger, :enabled) do
      quote do
        IO.puts("Logged message: #{unquote(msg)}")
      end
    end
  end
end

defmodule Example do
  require Logger

  def test do
    Logger.log("This is a log message")
  end
end

Con el logging habilitado nuestra función test resultaría en un código como este:

def test do
  IO.puts("Logged message: #{"This is a log message"}")
end

Si deshabilitamos el logging el resultado sería:

def test do
end

Depuración

Listo, ahora sabemos como usar quote/2, unquote/1 y escribir macros. ¿Pero qué pasa si tienes un enorme pedazo de código quoted y quieres entenderlo? En este caso puedes usarMacro.to_string/2. Échale un vistazo a este ejemplo:

iex> Macro.to_string(quote(do: foo.bar(1, 2, 3)))
"foo.bar(1, 2, 3)"

Y cuando quieras mirar en el código generado por un macro puedes combinar Macro.expand/2 y Macro.expand_once/2, estas funciones expanden macros a su código quoted. El primero puede expandirlo muchas veces, mientras que el último solo una vez. Por ejemplo vamos a modificar el ejemplo unless de la sección previa.

defmodule OurMacro do
  defmacro unless(expr, do: block) do
    quote do
      if !unquote(expr), do: unquote(block)
    end
  end
end

require OurMacro

quoted =
  quote do
    OurMacro.unless(true, do: "Hi")
  end
iex> quoted |> Macro.expand_once(__ENV__) |> Macro.to_string |> IO.puts
if(!true) do
  "Hi"
end

Si ejecutamos el mismo código con Macro.expand/2 es intrigante:

iex> quoted |> Macro.expand(__ENV__) |> Macro.to_string |> IO.puts
case(!true) do
  x when x in [false, nil] ->
    nil
  _ ->
    "Hi"
end

Puede que recuerdes que mencionamos que if es un macro en Elixir, aquí lo vemos expandido en su forma interna como un enunciado case.

Macros privados

No es tan común pero Elixir soporta macros privados. Un macro privado está definido con defmacrop y solo puede ser llamado dentro del módulo donde fue definido. Los macros privados deben ser definidos antes del código que los invoca.

Higiene de macro

La forma como los macros interactúan con el contexto de quien los llama es conocido como higiene de macros. Por defecto los macros en Elixir son higiénicos y no entrarán en conflicto con nuestro contexto.

defmodule Example do
  defmacro hygienic do
    quote do: val = -1
  end
end

iex> require Example
nil
iex> val = 42
42
iex> Example.hygienic
-1
iex> val
42

¿Qué pasa si queremos manipular el valor de val? Para marcar una variable como antihigiénica podemos usar var!/2. Vamos a actualizar nuestro ejemplo para incluir otro macro utilizando var!/2:

defmodule Example do
  defmacro hygienic do
    quote do: val = -1
  end

  defmacro unhygienic do
    quote do: var!(val) = -1
  end
end

Vamos a comparar como ellos interactuar con nuestro contexto:

iex> require Example
nil
iex> val = 42
42
iex> Example.hygienic
-1
iex> val
42
iex> Example.unhygienic
-1
iex> val
-1

Incluyendo var!/2 en nuestro macro manipulamos el valor de val sin pasarlo a nuestro macro. El uso de macros antihigiénicos debería ser mantenido al mínimo. Incluyendo var!/2 aumentamos el riesgo de tener un conflicto en la resolución de variables.

Enlazamiento

Ya hemos cubierto la utilidad de unquote/1 pero hay otra forma de inyectar valores en nuestro código: el enlazamiento(binding). Con binding variable somos capaces de incluir múltiples variables en nuestro macro y asegurar que ellos solo van a ser unquoted una vez evitando revaluaciones accidentales. Para usar variables enlazadas necesitamos pasar una lista de claves a la opción bind_quoted en quote/2.

Para ver el beneficio de bind_quote y demostrar el problema de la revaluación vamos a usar un ejemplo. Podemos empezar creando un macro que simplemente imprime la expresión dos veces:

defmodule Example do
  defmacro double_puts(expr) do
    quote do
      IO.puts(unquote(expr))
      IO.puts(unquote(expr))
    end
  end
end

Vamos a probar nuestro nuevo macro pasándole la hora actual del sistema. Deberíamos esperar verla impresa dos veces:

iex> Example.double_puts(:os.system_time)
1450475941851668000
1450475941851733000

¡Las horas son diferentes! ¿Qué pasó? Usando unquote/1 en la misma expresión muchas veces conlleva a una revaluación y eso puede tener consecuencias involuntarias. Vamos a actualizar el ejemplo para usar bind_quoted y veamos que obtenemos:

defmodule Example do
  defmacro double_puts(expr) do
    quote bind_quoted: [expr: expr] do
      IO.puts(expr)
      IO.puts(expr)
    end
  end
end

iex> require Example
nil
iex> Example.double_puts(:os.system_time)
1450476083466500000
1450476083466500000

Con bind_quoted obtenemos la salida esperada: la misma hora impresa dos veces.

Ahora que hemos cubierto quote/2, unquote/1 y defmacro/2 tenemos todas las herramientas necesarias para extender Elixir y adaptarlo a nuestras necesidades.

Caught a mistake or want to contribute to the lesson? Edit this page on GitHub!