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

这是宏系列的最后一篇。在开始之前,我想提一下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.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值