[elixir! #0003] [譯] 理解Elixir中的宏——part.1 基礎 by Saša Jurić

原文html

這是講解macro(宏)的系列文章中的第一篇。我原本計劃將這個話題放在個人書《Elixir in Action》裏,但仍是放棄了,由於那本書的主要關注的是底層VM和OTP中重要的部分。git

因此,我決定在這裏講解宏。我發現關於宏的話題十分有趣,在這一系列的文章中,我將試圖解釋宏是如何運做的,並以供一些編寫宏的基本技巧和建議。儘管我認爲編寫宏並非很難,但相較於普通Elixir代碼,它確實須要更高一層的視角。所以,我認爲這對於理解Elixir編譯器的內部細節頗有幫助。知道了事物內部是如何運做的以後,就能更容易地理解元編程代碼。github

這是中等難度的內容。若是你很熟悉Elixir和Erlang,但對宏還感受困惑,那麼這很適合你。若是你剛開始接觸Elixir和Erlang,那麼最好從其它地方開始。shell

元編程

也許你已經據說過Elixir中的元編程。主要的思想就是咱們能夠編寫一些代碼,它們會根據某些輸入來生成代碼。express

歸功於宏,咱們能夠寫出像Plug裏這樣的結構:編程

get "/hello" do
  send_resp(conn, 200, "world")
end

match _ do
  send_resp(conn, 404, "oops")
end

或者是ExActor中的:服務器

defmodule SumServer do
  use ExActor.GenServer

  defcall sum(x, y), do: reply(x+y)
end

在兩個例子中,咱們在編譯時都會將這些自定義的宏轉化成其它的代碼。調用Plug的getmatch會建立一個函數,而ExActor的defcall會生成用於從客戶端傳遞參數到服務器的兩個函數和代碼。less

Elixir自己就很是多地用到了宏。許多結構,例如defmodule, def, if, unless, 甚至defmacro都是宏。這使得語言的核心能保持迷你,往後對語言的展開就會更簡單。函數

還有比較冷門的,就是能利用宏批量成生函數:工具

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

  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

在這裏,咱們將一個對FSM的類型聲明轉換(在編譯時)成了對應的多從句函數。

相似的技術被Elixir用於生成String.Unicode模塊。這個模塊基本上是經過讀取UnicodeData.txtSpecialCasing.txt文件裏表述的代碼點來生成的。基於文件中的數據,各類函數(例如upcase, downcase) 被生成了。

不管是宏仍是代碼生成,咱們都在編譯的過程當中對抽象語法樹作了某些變換。爲了理解它是如何工做的,你須要學習一點編譯過程和AST的知識。

編譯過程

歸納地說,Elixir代碼的編譯有三個階段:

圖片描述

輸入的源代碼被解析,而後生成相應的抽象語法樹(AST)。AST會以嵌套的Elixir語句的形式來表現你的代碼。而後展開階段開始。在這個階段,各類內置的和自定義的宏被轉換成了最終版本。一旦轉換結束,Elixir就能夠生成最後的字節碼,即源程序的二進制表示。

這只是一個概述。例如,Elixir編譯器會生成Erlang AST,而後依賴Erlang函數將其轉換爲字節碼,可是咱們不須要知道細節。不過,這幅圖對於理解元編程代碼確實有幫助。

首先咱們要知道,元編程的魔法發生在展開階段。編譯器先以一個相似於你的原始Elixir代碼的AST開始,而後展開爲最終版本。

另外,在生成了二進制以後,元編程就中止了。你能夠肯定你的代碼不會被從新定義,除非代碼升級或是一些動態的代碼載入技巧(在本文內容以外)。由於元編程老是會引入一個隱形(或不明顯)的層,在Elixir中這隻發生在編譯時,並獨立於程序的各類執行路徑。

代碼轉換髮生在編譯時,所以推導最終產品會相對簡單,並且元編程不會干擾例如dialyzer的靜態分析工具。編譯時元編程也意味着咱們不會有性能損失。進入運行時後,代碼中就沒有元編程結構了。

建立AST片斷

那麼什麼是Elixir AST。它是Elixir代碼所對應的深嵌套格式。讓咱們來看一些例子。你可使用quote特殊形式來生成代碼的AST:

iex(1)> quoted = quote do 1 + 2 end
{:+, [context: Elixir, import: Kernel], [1, 2]}

quote獲取任意的Elixir表達式,並返回對應的AST片斷。

在這裏,AST片斷表達了簡單的加法操做(1+2)。它一般被稱爲一個 quoted 表達式。

大可能是時候你不須要明白quoted結構中的細節,可是讓咱們來觀察一下這個簡單的例子。在這裏,咱們的AST片斷能夠分爲三個部分:

  • 一個原子表示所要調用的操做(:+

  • 表達式的環境(例如 imports和aliases)。一般你不須要理解這個數據

  • 參數

簡而言之,在Elixir中,quoted表達式是用來描述代碼的。編譯器會用它來生成最後的字節碼。

咱們也能夠對quoted表達式求值,儘管不多用到:

iex(2)> Code.eval_quoted(quoted)
{3, []}

返回的元組中包含了表達式的結果,以及一個列表,其中包含了表達式中產生的變量綁定。

然而,在AST求值以前(一般由編譯器來作),quoted表達式尚未接受語義驗證。例如,當咱們這樣寫:

iex(3)> a + b
** (RuntimeError) undefined function: a/0

咱們會獲得錯誤,由於這裏沒有叫作a的變量(或函數)。

若是咱們quote了表達式:

iex(3)> quote do a + b end
{:+, [context: Elixir, import: Kernel], [{:a, [], Elixir}, {:b, [], Elixir}]}

這裏就不會報錯。咱們獲得了a+b的quoted表示,這意味着咱們生成了對錶達式a+b的描述,而不用管這些變量是否存在。最終代碼尚未發出,因此不會報錯。

若是咱們將其加入到某個ab爲合法變量的AST中,則代碼能夠正確運行。

讓咱們來試一下。首先,quote求和表達式:

iex(4)> sum_expr = quote do a + b end

而後建立一個quote了的綁定表達式:

iex(5)> bind_expr = quote do
          a=1
          b=2
        end

記住,它們只是quoted表達式。它們只是在描述代碼,並無執行。這時,變量ab並不存在於當前shell會話中。

爲了讓這些片斷一塊兒工做,咱們必須鏈接它們:

iex(6)> final_expr = quote do
          unquote(bind_expr)
          unquote(sum_expr)
        end

在這裏,咱們生成了一個新的quoted表達式,由bind_expr的內容和sum_expr的內容組成。事實上,咱們生成了一個包含着兩個表達式的新的AST片斷。不要擔憂unquote,我會在稍後解釋它。

與此同時,咱們能夠執行這個最後的AST片斷:

iex(7)> Code.eval_quoted(final_expr)
{3, [{{:a, Elixir}, 1}, {{:b, Elixir}, 2}]}

結果又一次由表達式的結果(3)和綁定列表組成,能夠看出咱們的表達式將兩個變量ab綁定到了值12

這就是Elixir中元編程方法的核心。當進行元編程時,咱們本質上是在構建各類AST片斷,以生成一些表明着咱們想要獲得的代碼的AST。在這過程當中,咱們一般對輸入的AST片斷(咱們所結合的)的確切內容和結構不感興趣。相反,咱們使用quote來生成並結合輸入片斷,以生成一些修飾好了的代碼。

Unquoting

unquote在這裏出現了。注意,不管quote塊裏有什麼,它都會變成AST片斷。這意味着咱們不能夠簡單地將外部的變量注入到咱們的quote裏。例如,這樣是不能達到效果的:

quote do
  bind_expr
  sum_expr
end

在這裏,quote只是簡單地生成了對於bind_exprsum_expr變量的quoted標記,它們必須存在於這個AST能夠被理解的環境裏。然而,這不是咱們想要的。咱們想要的是直接注入bind_exprsum_expr的內容到咱們所生成的AST片斷中對應的地方。

這就是unquote(…)的目的——括號裏的表達式會被馬上執行,而後插入到調用了unquote的地方。這意味着unquote的結果必須是合法的AST片斷。

咱們也可將unquote類比於字符串插值(#{})。對字符串你能夠這樣作:

"... #{some_expression} ... "

相似地,quote時你能夠這樣作:

quote do
  ...
  unquote(some_expression)
  ...
end

兩種情形下,你都要執行一個在當前環境中合法的表達式,並將結果注入到你正在構建的表達式中(或字符串,AST片斷)。

這很重要,由於unquote並非quote的逆操做。quote將一段代碼轉換成quoted表達式,unquote並無作逆向操做。若是你想將一個quoted表達式轉換成一個字符串,你可使用Macro.to_string/1

例子:跟蹤表達式

讓咱們來實踐一下。咱們將編寫一個幫助咱們排除故障的宏。這個宏能夠這樣用:

iex(1)> Tracer.trace(1 + 2)
Result of 1 + 2: 3
3

Tracer.trace接受了一個表達式,並打印出了它的結果。而後返回的是表達式的結果。

須要認識到這是一個宏,它的輸入(1+2)能夠被轉換成更復雜的形式——打印表達式的結果並返回它。這個變換會發生在展開期,而結果的字節碼會包含一些修飾過的輸入代碼。

在查看它的實現以前,想象一下或許會頗有幫助。當咱們調用Tracer.trace(1+2),結果的字節碼會對應這些:

mangled_result = 1+2
Tracer.print("1+2", mangled_result)
mangled_result

名稱mangled_result代表Elixir編譯器會損毀全部在宏裏引用的臨時變量。這也被稱爲宏清洗,咱們會在本系列以後的內容中討論它(不在本文)。

所以,這個宏的定義能夠是這樣的:

defmodule Tracer do
  defmacro trace(expression_ast) do
    string_representation = Macro.to_string(expression_ast)

    quote do
      result = unquote(expression_ast)
      Tracer.print(unquote(string_representation), result)
      result
    end
  end

  def print(string_representation, result) do
    IO.puts "Result of #{string_representation}: #{inspect result}"
  end
end

讓咱們來逐步分析這段代碼。

首先,咱們用defmacro定義宏。宏本質上是特殊形式的函數。它的名字會被損毀,而且只能在展開期調用它(儘管理論上你仍然能夠在運行時調用)。

咱們的宏接收到了一個quoted表達式。這一點很是重要——不管你發送了什麼參數給一個宏,它們都已是quoted的。因此,當咱們調用Tracer.trace(1+2),咱們的宏(它是一個函數)不會接收到3。相反,expression_ast的內容會是quote(do: 1+2)的結果。

在第三行,咱們使用Macro.to_string/1來求出咱們所收到的AST片斷的字符串形式。這是你在運行時不可以對一個普通函數作的事之一。雖然咱們能在運行時調用Macro.to_string/1,但問題在於咱們沒辦法再訪問AST了,所以不可以知道某些表達式的字符串形式了。

一旦咱們擁有了字符串形式,咱們就能夠生成並返回結果AST了,這一步是在quote do … end 結構中完成的。它的結果是用來替代原始的Tracer.trace(…)調用的quoted表達式。

讓咱們進一步觀察這一部分:

quote do
  result = unquote(expression_ast)
  Tracer.print(unquote(string_representation), result)
  result
end

若是你明白unquote的做用,那麼就很簡單了。實際上,咱們是在把expression_ast(quoted 1+2)代入到咱們生成的片斷中,將表達式的結果放入result變量。而後咱們使用某種格式來打印它們(藉助Macro.to_string/1),最後返回結果。

展開一個AST

在shell裏能夠很容易地觀察它。啓動iex,而後複製粘貼Tracer模塊的定義:

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

而後,你必須requireTracer模塊:

iex(2)> require Tracer

接下來,quote一個對trace宏的調用:

iex(3)> quoted = quote do Tracer.trace(1+2) end
{{:., [], [{:__aliases__, [alias: false], [:Tracer]}, :trace]}, [],
 [{:+, [context: Elixir, import: Kernel], [1, 2]}]}

這個輸出有點嚇人,但你一般不用理解它。若是仔細觀察,你能夠看到這個結構裏有提到Tracertrace,證實了這個AST片斷對應着咱們的原始代碼,它尚未被展開。

如今,該開始展開這個AST了,使用Macro.expand/2

iex(4)> expanded = Macro.expand(quoted, __ENV__)
{:__block__, [],
 [{:=, [],
   [{:result, [counter: 5], Tracer},
    {:+, [context: Elixir, import: Kernel], [1, 2]}]},
  {{:., [], [{:__aliases__, [alias: false, counter: 5], [:Tracer]}, :print]},
   [], ["1 + 2", {:result, [counter: 5], Tracer}]},
  {:result, [counter: 5], Tracer}]}

這是咱們的代碼徹底展開後的版本,你能夠看到其中提到了result(由宏引入的臨時變量),以及對Tracer.print/2的調用。你甚至能夠將這個表達式轉換成字符串:

iex(5)> Macro.to_string(expanded) |> IO.puts
(
  result = 1 + 2
  Tracer.print("1 + 2", result)
  result
)

這些說明了你對宏的調用已經展開成了別的東西。這就是宏工做的原理。儘管咱們只是在shell中嘗試,但使用mixelixirc構建項目時也是同樣的。

我想這些內容對於第一章來講已經夠了。你已經對編譯過程和AST有所瞭解,也看過了一個簡單的宏的例子。後續,咱們將更深刻地討論宏的一些機制。

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.

相關文章
相關標籤/搜索