这是宏系列的最后一篇。在开始之前,我想提一下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
注入代码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现在在调用者的上下文中成形,其中我们有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.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。