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
实际上将只被调用一次。此外,由于未对语境进行求值,因此当我们的宏被调用时,内部变量state
,action
和next_state
都不存在。
怎么可以让它工作?本质上,我们的宏将靠unquote来调用 - head
和body
将分别包含代表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
我们面临的另一个问题是,我们从宏传递到调用者上下文的内容是默认注入,而不是传输。所以,每当你做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
注意我们传递给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现在在调用者的上下文中成形,其中我们有action
和state
变量可用。第二个表达式将使用这些变量的内容。
这是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: true
的Macro.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.