[elixir! #0008] [译] 理解Elixir中的宏——part.6 现场代码生成 by Saša Jurić

LustrousElixir 2019-06-20

这是宏系列的最后一篇。在开始之前,我想提一下Björn Rochel,他已经将他的Apex库中的deftraceable宏改进了。因为他发现博客版本的deftraceable不能正确处理默认参数(arg \ def_value), 于是实现了一个修复。

在此,让我们结束这个宏的传奇。今天的文章可能是整个系列中涉及最广的,我们将讨论现场代码生成的一些方面,以及它可能对宏的影响。

在模块中生成代码

正如我在第1章中提到的,宏不仅仅是Elixir中的元编程机制。它也可以直接在模块中生成代码。为了刷新的记忆,让我们看看例子:

defmodule Fsm do
  fsm = [
    running: {:pause, :paused},
    running: {:stop, :stopped},
    paused: {:resume, :running}
  ]

  # Dynamically generating functions directly in the module
  for {state, {action, next_state}} <- fsm do
    def unquote(action)(unquote(state)), do: unquote(next_state)
  end
  def initial, do: :running
end

Fsm.initial
# :running

Fsm.initial |> Fsm.pause
# :paused

Fsm.initial |> Fsm.pause |> Fsm.pause
# ** (FunctionClauseError) no function clause matching in Fsm.pause/1

这里,我们直接在模块中动态生成函数子句。这允许我们对一些输入(在这种情况下是关键字列表)进行元程序,并生成代码而不需要编写专用宏。

注意在上面的代码中我们如何使用unquote将变量注入到函数子句定义。这与宏的工作完美同步。请记住,def也是一个宏,而一个宏总是接收quoted的参数。因此,如果您希望宏接收某个变量的值,则必须在传递该变量时使用unquote。所以不能简单地调用def action,因为def宏接收到一个quoted的action引用,而不是变量action中的值。

你当然可以用这种动态的方式调用你自己的宏,同样的原则将成立。但有一个意想不到的事情 - 生成的顺序可能不是你期望的。

扩展的顺序

正如你所期望的,模块级代码(不是任何函数的一部分的代码)在扩展阶段被执行。有些令人惊讶的是,这将发生在所有宏(除了def)扩展之后。很容易证明这一点:

iex(1)> defmodule MyMacro do
          defmacro my_macro do
            IO.puts "my_macro called"
            nil
          end
        end

iex(2)> defmodule Test do
          import MyMacro

          IO.puts "module-level expression"
          my_macro
        end

# Output:
my_macro called
module-level expression

从输出看出在IO.puts之前调用了mymacro,即使相应的IO.puts调用在宏调用之前。这证明编译器首先解析所有“标准”宏。然后模块生成开始,也是在这个阶段,模块级代码,以及对def的调用正在执行。

模块级友好宏

这对我们自己的宏有一些重要的影响。例如,我们的deftraceable宏也可以动态调用。但是,现在它还不能工作:

iex(1)> defmodule Tracer do ... end

iex(2)> defmodule Test do
          import Tracer

          fsm = [
            running: {:pause, :paused},
            running: {:stop, :stopped},
            paused: {:resume, :running}
          ]

          for {state, {action, next_state}} <- fsm do
            # Using deftraceable dynamically
            deftraceable unquote(action)(unquote(state)), do: unquote(next_state)
          end
          deftraceable initial, do: :running
        end

** (MatchError) no match of right hand side value: :error
    expanding macro: Tracer.deftraceable/2
    iex:13: Test (module)

出现一个有点神秘,而且不是非常有帮助的错误提示。那么出了什么问题?如上一节所述,在现场模块执行开始之前,将扩展宏。对我们来说,这意味着deftraceable被调用之前,for 语境甚至还没有执行。

因此,即使它是从语境中调用,deftraceable实际上将只被调用一次。此外,由于未对语境进行求值,因此当我们的宏被调用时,内部变量stateactionnext_state都不存在。

怎么可以让它工作?本质上,我们的宏将靠unquote来调用 - headbody将分别包含代表unquote(action)(unquote(state))unquote(next_state)的AST。

现在,回想一下,在当前版本的deftraceable,我们对宏中的输入做了一些假设。这里是一个草图:

defmacro deftraceable(head, body) do
  # 这里,我们假设了输入的头会是什么样子,并基于此
  # 执行了一些AST变换。

  quote do
    ...
  end
end

这就是我们的问题。如果我们动态地调用deftraceable,同时在原地生成代码,那么这样的假设不再成立。

推迟代码生成

当涉及宏执行时,重要的是区分宏上下文和调用者的上下文:

defmacro my_macro do
  #宏上下文:这里的代码是宏的正常部分,并在
  #宏被调用时运行。

  quote do
    #调用者上下文:在调用了宏的地方生成的代码。
  end

这是事情有点棘手。如果我们想支持宏的模块级动态调用,我们不应该假设宏环境中的任何东西。相反,我们应该将代码生成推迟到调用者的上下文。

在代码这样写:

defmacro deftraceable(head, body)do
  #宏上下文:我们不应该在这里假设关于输入AST的任何东西

  quote do
    #调用者的上下文:我们应该把输入AST传递到这里,然后
    #再做假设。
  end
end

为什么我们可以在调用者的上下文中做假设?因为此代码将在所有宏扩展后运行。例如,请记住,即使我们的宏是从循环语境内部调用,它也只会被调用一次。但是,由我们的宏生成的代码将在循环语境中运行 - 每个元素一次。

所以这种方法相当于推迟最终的代码生成。我们不是立即生成目标代码,而是生成将生成最终代码的中间模块级语句。这些中间语句将在扩展阶段的最后时刻运行,在所有其他宏已完成之后:

defmodule Test do
  …

  for {state, {action, next_state}} < - fsm do
    #在deftraceable被扩展之后,这里我们将得到一个明码
    #生成目标函数。此代码将在每一次for循环中被调用一次
    #这时,我们处在
    #调用者的上下文,并且可以访问state,action和next_state
    #变量, 并可以正确生成相应的函数。
  end

  ...
end

在实施解决方案之前,需要注意的是,这不是一个通用的模式,你应该考虑你是否真的需要这种方法。

如果你的宏不是在模块级使用,那么你应该避免这种技术。否则,如果从函数定义内部调用宏,并将生成移动到调用者的上下文,则基本上将代码执行从编译时移动到运行时,这可能会影响性能。

此外,即使您的宏在模块级运行,只要您不对输入做任何假设,此技术就不是必需的。例如,在第2部分中,我们对Plug的get宏进行了模拟:

defmacro get(route, body) do
  quote do
    defp do_match(“GET”, unquote(route), var!(conn)) do
      unquote(body [:do])
    end
  end
end

即使这个宏在模块级别上工作,它不会假设任何关于AST的格式,只是在调用者的上下文中注入输入片段,围绕着一些样板。当然,我们期望这里的body将有一个:do选项,但我们不假定任何关于body [:do]的AST的具体形状和格式。

总而言之,如果你的宏将着在模块级调用,一般模式可能是这样的:

defmacro ...
  #宏上下文:
  #可以在这里做任何准备,只要你不假设任何
  #关于输入AST的形状

  quote do
    #调用者的上下文:
    #如果你正在分析和/或变换输入AST,你应该在这里执行。
  end

由于调用者上下文是模块级的,所以这种延迟的变换仍将在编译时发生,因此不会有运行时性能损失。

解决方案

鉴于这个讨论,解决方案相对简单,但解释它相当于调用。所以我要先向你展示最终结果(注意注释):

defmodule Tracer do
  defmacro deftraceable(head, body) do
    #这是最重要的更改,让我们能正确传递
    #输入AST到调用者的上下文。我会解释这是如何工作的
    quote bind_quoted: [
      head: Macro.escape(head, unquote: true),
      body: Macro.escape(body, unquote: true)
    ] do
      #调用者的上下文:我们将从这里生成代码

      #由于代码生成被推迟到调用者上下文,
      #我们现在可以对输入AST做出我们的假设。

      #此代码大部分与以前的版本相同
      ##
      #注意,这些变量现在在调用者的上下文中创建。
      {fun_name, args_ast} = Tracer.name_and_args(head)
      {arg_names, decorated_args} = Tracer.decorate_args(args_ast)

      #与以前的版本完全相同。
      head = Macro.postwalk(head,
        fn
          ({fun_ast, context, old_args}) when (
            fun_ast == fun_name and old_args == args_ast
          ) ->
            {fun_ast, context, decorated_args}
          (other) -> other
      end)

      #此代码与以前的版本完全相同
      #注意:但是,请注意,代码在相同的上下文中执行
      #,就像前三个表达式那样。
      ##
      #因此,unquote(head)这里引用head变量
      #在此上下文中计算,而不是宏上下文。这同样适用
      #于其它发生在函数体中的unquote。
      ##
      #重点是延迟代码生成。我们的宏产生
      #此代码,然后依次生成最终代码。
  
      def unquote(head) do
        file = __ENV__.file
        line = __ENV__.line
        module = __ENV__.module

        function_name = unquote(fun_name)
        passed_args = unquote(arg_names) |> Enum.map(&inspect/1) |> Enum.join(",")

        result = unquote(body[:do])

        loc = "#{file}(line #{line})"
        call = "#{module}.#{function_name}(#{passed_args}) = #{inspect result}"
        IO.puts "#{loc} #{call}"

        result
      end
    end
  end

  #与以前的版本相同,但是该函数是公共的,
  #因为它必须在调用者的上下文调用,。
  def name_and_args({:when, _, [short_head | _]}) do
    name_and_args(short_head)
  end

  def name_and_args(short_head) do
    Macro.decompose_call(short_head)
  end

  def decorate_args([]), do: {[],[]}
  def decorate_args(args_ast) do
    for {arg_ast, index} <- Enum.with_index(args_ast) do
      arg_name = Macro.var(:"arg#{index}", __MODULE__)

      full_arg = quote do
        unquote(arg_ast) = unquote(arg_name)
      end

      {arg_name, full_arg}
    end
    |> List.unzip
    |> List.to_tuple
  end
end

让我们尝试一下宏:

iex(1)> defmodule Tracer do ... end

iex(2)> defmodule Test do
          import Tracer

          fsm = [
            running: {:pause, :paused},
            running: {:stop, :stopped},
            paused: {:resume, :running}
          ]

          for {state, {action, next_state}} <- fsm do
            deftraceable unquote(action)(unquote(state)), do: unquote(next_state)
          end
          deftraceable initial, do: :running
        end

iex(3)> Test.initial |> Test.pause |> Test.resume |> Test.stop

iex(line 15) Elixir.Test.initial() = :running
iex(line 13) Elixir.Test.pause(:running) = :paused
iex(line 13) Elixir.Test.resume(:paused) = :running
iex(line 13) Elixir.Test.stop(:running) = :stopped

正如你可以看到,变化不是很复杂。我们设法保持大部分的代码完好无损,虽然我们不得不用一些技巧:bind_quoted:true和Macro.escape:

quote bind_quoted: [
  head: Macro.escape(head, unquote: true),
  body: Macro.escape(body, unquote: true)
] do
  ...
end

让我们仔细看看它是什么意思。

bind_quoted

记住,我们的宏生成一个代码,它将生成最终的代码。在第一级生成的代码(由我们的宏返回的代码)的某处,我们需要放置以下表达式:

def unquote(head) do ... end

这个表达式将在调用者的上下文(客户端模块)中被调用,它的任务是生成函数。如在注释中提到的,重要的是要理解unquote(head)在这里引用存在于调用者上下文中的head变量。我们不是从宏上下文注入一个变量,而是一个存在于调用者上下文中的变量。

但是,我们不能使用简单的quote生成这样的表达式:

quote do
  def unquote(head) do ... end
end

记住unquote如何工作。它往unquote调用里的head变量中注入了AST。这不是我们想要的。我们想要的是生成表示对unquote的调用的AST,然后在调用者的上下文中执行,并引用调用者的head变量。

这可以通过提供unquote:false选项来实现:

quote unquote: false do
  def unquote(head) do ... end
end

这里,我们将生成代表unquote调用的代码。如果这个代码被注入到正确的地方,其中变量head存在,我们将最终调用def宏,传递head变量中的任何值。

所以似乎unquote:false是我们需要的,但有一个缺点,我们不能从宏上下文访问任何变量:

foo = :bar
quote unquote: false do
  unquote(foo)    # <- won't work because of unquote: false
end

使用unquote:false有效地阻止立即AST注入,并将unquote当作任何其他函数调用。因此,我们不能将东西注入目标AST。这里bind_quoted派上用场。通过提供bind_quoted:bindings,我们可以禁用立即unquoting,同时仍然绑定我们想要传递到调用者上下文的任何数据:

quote bind_quoted: [
  foo: ...,
  bar: ...
] do
  unquote(whatever)  # <- 和unquote: false效果一样

  foo  # <- accessible due to bind_quoted
  bar  # <- accessible due to bind_quoted
end

注入代码vs传输数据

我们面临的另一个问题是,我们从宏传递到调用者上下文的内容是默认注入,而不是传输。所以,每当你做unquote(some_ast),你正在注入一个AST片段到另一个你正在用引号表达式构建的AST中。

偶尔,我们要传输数据,而不是注入它。让我们看一个例子。假设我们有一些三元组,我们想转移到调用者的上下文

iex(1)> data = {1, 2, 3}
{1, 2, 3}

现在,让我们尝试使用典型的unquote传输:

iex(2)> ast = quote do IO.inspect(unquote(data)) end
{{:., [], [{:__aliases__, [alias: false], [:IO]}, :inspect]}, [], [{1, 2, 3}]}

这似乎工作。让我们尝试并评估结果ast:

iex(3)> Code.eval_quoted(ast)
** (CompileError) nofile: invalid quoted expression: {1, 2, 3}

那么这里发生了什么?事情是,我们没有真正转移我们的{1,2,3}三元组。相反,我们将它注入目标AST。注入意味着{1,2,3}本身被视为一个AST片段,这显然是错误的。

在这种情况下我们真正想要的是数据传输。在代码生成上下文中,我们有一些数据要传递给调用者的上下文。这是Macro.escape能帮助的地方。通过逃避一个语句,我们可以确保它被转移而不是注入。当我们调用unquote(Macro.escape(term)),我们将注入描述数据的AST。

让我们试试这个:

iex(3)> ast = quote do IO.inspect(unquote(Macro.escape(data))) end
{{:., [], [{:__aliases__, [alias: false], [:IO]}, :inspect]}, [],
 [{:{}, [], [1, 2, 3]}]}

iex(4)> Code.eval_quoted(ast)
{1, 2, 3}

正如您所看到的,我们能够传输未经修改的数据。

回到我们的延迟代码生成,这正是我们需要的。不是注入到目标AST,我们想传输输入AST,并完全保留其形状:

defmacro deftraceable(head, body) do
  #这里我们有头部和身体AST
  quote do
    #我们在这里需要相同的头和身体AST,以便生成
    #最终代码。
  end
end

通过使用Macro.escape/1,我们可以确保输入的AST被传递到调用者的上下文,在那里我们将生成最终的代码。

正如上一节所讨论的,我们使用bind_quoted,但是同样的原则:

quote bind_quoted: [
  head: Macro.escape(head, unquote: true),
  body: Macro.escape(body, unquote: true)
] do
  # 这里我们有了从宏上下文中得到的
  # head和body的精确副本。
end

Escaping和 unquote: true

注意我们传递给Macro.escape的一个欺骗性的unquote: true选项。这是最难解释的。为了能够理解它,你必须清楚AST是如何传递给宏,并返回到调用者的上下文的。

首先,记住我们如何调用我们的宏:

deftraceable unquote(action)(unquote(state)) do ... end

现在,由于宏实际上接收到quoted的参数,head参数将等效于以下内容:

#这是宏上下文中的头参数实际包含的内容
quote unquote: false do
  unquote(action)(unquote(state))
end

请记住,Macro.escape保留数据,因此当您在某些其他AST中传输变量时,内容保持不变。给定上面的head的形状,这是在我们的宏扩展后的情况:

#调用者的上下文
for {state, {action, next_state}} <- fsm do
  #这里是我们生成函数的代码。由于bind_quoted,这里
  #我们有head和body变量可用。

  #变量head等效于
  #   quote unquote: false do
  #     unquote(action)(unquote(state))
  #   end

  #我们真正需要的是:
  #   quote do
  #     unquote(action)(unquote(state))
  #   end
end

为什么我们需要第二种形式的quoted head?因为这个AST现在在调用者的上下文中成形,其中我们有actionstate变量可用。第二个表达式将使用这些变量的内容。

这是unquote: true选项的作用。当我们调用Macro.escape(input_ast, unquote: true)时,我们仍然(主要)保留传输数据的形状,但输入AST中的unquote片段(例如unquote(action))将在调用者上下文执行。

所以总结一下,输入AST到调用者上下文的适当传输看起来像这样:

defmacro deftraceable(head, body) do
  quote bind_quoted: [
    head: Macro.escape(head, unquote: true),
    body: Macro.escape(body, unquote: true)
  ] do
    # Generate the code here
  end
  ...
end

这不是那么难,但它需要一些时间理解这里发生了什么。尝试确保你不是盲目地做escapes(和/或 unquote: true),而不理解这是你真正想要的。毕竟,这不是默认行为是有原因的。

当写宏时,想想你是否要注入一些AST,或者传输数据不变。在后一种情况下,您需要Macro.escape。如果要传输的数据是可能包含unquote片段的AST,那么您可能需要使用带有unquote: trueMacro.escape

回顾

Elixir宏的系列到此结束。我希望你觉得这些文章有趣并有教育作用,你已经获得了更多的信心和理解关于宏是如何工作的。

始终记住 - 宏是扩展阶段AST片段的平常组成。如果您了解调用者的上下文和宏输入,那么直接执行所需的转换或者在必要时进行延迟就不是很难。

本系列并不涵盖所有可能的方面和细微差别。如果你想了解更多,一个好的开始的地方是quote/2 special form的文档。你还可以在Macro和Code模块中找到一些有用的helper。

元编程快乐!

Copyright 2014, Saša Jurić. This article is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.
The article was first published on the old version of the Erlangelist site.
The source of the article can be found here.

相关推荐

LustrousElixir / 0评论 2013-02-25

探手摘星辰 / 0评论 2015-08-02