[Elixir Macro #2]编译时和运行时

首先,先确定下本文中的这两个概念:

编译时:从 mix compile 命令开始到该命令结束,是解释源码,编译过程生成 beam 文件的过程

运行时:VM 运行 beam 中间代码的过程,和源码文件无关

在自己写代码或读别人代码的时候,弄明白一个变量或一个函数是编译时起作用的还是运行时起作用的,非常重要。

编译时变量和运行时变量

再次引入几个概念:

  1. L2 的 @a 定义为「模块属性」
  2. L3 的 x L15 的 y 定义为「模块变量」
  3. 「模块变量」和「模块属性」都定义为「编译时变量」

下面例子中的 @a x y 都是编译时的,特别注意下 L14 和 L17,「模块变量」和「模块属性」,在作用域方面的区别:

defmodule M do
  @a 1
  x = 2

  def f1 do
    IO.inspect @a
  end

  def f2 do
    IO.inspect unquote(x)
  end

  defmacro m1 do
    x = unquote(x)
    y = 3
    quote do
      IO.inspect {unquote(@a), unquote(x), unquote(y)}
    end
  end

end

defmodule N do
  require M
  M.f1
  M.f2
  M.m1
end

以上例子中,没有运行时变量,因为其中的函数都是无参函数,而「运行时变量」是指「函数的参数」。

简单的总结:

  1. 编译时变量与 module 绑定,生命周期从 defmodule 开始,到该 module 编译完成,生成 beam 文件结束。
  2. 编译时变量有两种写法,「模块变量」和「模块属性」。模块属性是整个 module 通用的,模块变量的作用域仅在当前 block。
  3. 编译时变量最终通过 unquote 内联编译到 module 中。
  4. 运行时变量是函数的参数,与业务逻辑和用户行为有关,编译过程拿不到变量值,更不会修改变量值。

编译时函数和运行时函数

这次的例子是从 elixir 的源码中抄了一段,实现的是 elixir 里的管道 |> ,源码在 这里 :

@doc """
Breaks a pipeline expression into a list.
Raises if the pipeline is ill-formed.
"""
@spec unpipe(Macro.t) :: [Macro.t]
def unpipe(expr) do
  :lists.reverse(unpipe(expr, []))
end

defp unpipe({:|>, _, [left, right]}, acc) do
  unpipe(right, unpipe(left, acc))
end

defp unpipe(other, acc) do
  [{other, 0}|acc]
end

@doc """
Pipes `expr` into the `call_args` at the given `position`.
"""
@spec pipe(Macro.t, Macro.t, integer) :: Macro.t | no_return
def pipe(expr, call_args, position)

def pipe(expr, {:&, _, _} = call_args, _integer) do
  raise ArgumentError, bad_pipe(expr, call_args)
end

def pipe(expr, {tuple_or_map, _, _} = call_args, _integer) when tuple_or_map in [:{}, :%{}] do
  raise ArgumentError, bad_pipe(expr, call_args)
end

def pipe(expr, {call, _, [_, _]} = call_args, _integer)
when call in unquote(@binary_ops) do
  raise ArgumentError, "cannot pipe #{to_string expr} into #{to_string call_args}, " <>
    "the #{to_string call} operator can only take two arguments"
end

def pipe(expr, {call, line, atom}, integer) when is_atom(atom) do
  {call, line, List.insert_at([], integer, expr)}
end

def pipe(expr, {call, line, args}, integer) when is_list(args) do
  {call, line, List.insert_at(args, integer, expr)}
end

def pipe(expr, call_args, _integer) do
  raise ArgumentError, bad_pipe(expr, call_args)
end

defp bad_pipe(expr, call_args) do
  "cannot pipe #{to_string expr} into #{to_string call_args}, " <>
    "can only pipe into local calls foo(), remote calls Foo.bar() or anonymous functions calls foo.()"
end

这里的重点还是放在了「编译时函数」,因为「运行时函数」就是普通的函数,处理业务逻辑。

在上面例子中,pipe 和 unpipe 函数的输入输出都是 AST,只不过输出的 AST 没有用 quote 来生成,是用的上一篇中所说的「手写」+「组合」的方式。

简单的总结:

  1. 编译时函数是处理 AST 的函数,输入输出都是 AST。
  2. 编译时函数在编译阶段执行,编译完成后函数依然存在,但不会在运行时被调用。
  3. 编译时函数通常在宏中被调用。之前说过宏的输入输出也都是 AST,如果宏处理 AST 的逻辑过于复杂,会引入单独的函数来处理。
  4. 运行时函数是处理业务逻辑的函数,不在编译过程中被调用。

编译时编程和运行时编程

编译时编程和运行时编程最大的区别:编译时编程不是函数式的,存在全局变量!

DSL 的编写逻辑,是定义全局变量更新全局变量处理全局变量的过程:

  1. 定义模块属性变量(上面提到的全局变量)。
  2. 编写相应的宏,修改模块属性。
  3. __before_compile__  中取出模块属性,格式化成对应的函数或语句。

这一条可能有点抽象,也没有举代码的例子,因为接下来两篇文章,是两个很有代表性的「宏」的使用场景和使用方式的例子,是对这一条的详细解释。

 

PS. 文中有些用词非常不妥,那些「定义」也并非通用定义,仅限在本系列文章中有效。比如「模块属性」,这里 有官方的详细教程,还有其它的定义和用途,本文有些以偏概全;再比如「全局变量」,这只是为了做一个容易理解的近似描述。请不要挑剔这些细节,领会精神即可,当然如果有更好的表达,欢迎拍砖。