原文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的get
和match
會建立一個函數,而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.txt
和SpecialCasing.txt
文件裏表述的代碼點來生成的。基於文件中的數據,各類函數(例如upcase
, downcase
) 被生成了。
不管是宏仍是代碼生成,咱們都在編譯的過程當中對抽象語法樹作了某些變換。爲了理解它是如何工做的,你須要學習一點編譯過程和AST的知識。
歸納地說,Elixir代碼的編譯有三個階段:
輸入的源代碼被解析,而後生成相應的抽象語法樹(AST)。AST會以嵌套的Elixir語句的形式來表現你的代碼。而後展開階段開始。在這個階段,各類內置的和自定義的宏被轉換成了最終版本。一旦轉換結束,Elixir就能夠生成最後的字節碼,即源程序的二進制表示。
這只是一個概述。例如,Elixir編譯器會生成Erlang AST,而後依賴Erlang函數將其轉換爲字節碼,可是咱們不須要知道細節。不過,這幅圖對於理解元編程代碼確實有幫助。
首先咱們要知道,元編程的魔法發生在展開階段。編譯器先以一個相似於你的原始Elixir代碼的AST開始,而後展開爲最終版本。
另外,在生成了二進制以後,元編程就中止了。你能夠肯定你的代碼不會被從新定義,除非代碼升級或是一些動態的代碼載入技巧(在本文內容以外)。由於元編程老是會引入一個隱形(或不明顯)的層,在Elixir中這隻發生在編譯時,並獨立於程序的各類執行路徑。
代碼轉換髮生在編譯時,所以推導最終產品會相對簡單,並且元編程不會干擾例如dialyzer的靜態分析工具。編譯時元編程也意味着咱們不會有性能損失。進入運行時後,代碼中就沒有元編程結構了。
那麼什麼是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
的描述,而不用管這些變量是否存在。最終代碼尚未發出,因此不會報錯。
若是咱們將其加入到某個a
和b
爲合法變量的AST中,則代碼能夠正確運行。
讓咱們來試一下。首先,quote求和表達式:
iex(4)> sum_expr = quote do a + b end
而後建立一個quote了的綁定表達式:
iex(5)> bind_expr = quote do a=1 b=2 end
記住,它們只是quoted表達式。它們只是在描述代碼,並無執行。這時,變量a
和b
並不存在於當前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
)和綁定列表組成,能夠看出咱們的表達式將兩個變量a
和b
綁定到了值1
和2
。
這就是Elixir中元編程方法的核心。當進行元編程時,咱們本質上是在構建各類AST片斷,以生成一些表明着咱們想要獲得的代碼的AST。在這過程當中,咱們一般對輸入的AST片斷(咱們所結合的)的確切內容和結構不感興趣。相反,咱們使用quote
來生成並結合輸入片斷,以生成一些修飾好了的代碼。
unquote
在這裏出現了。注意,不管quote
塊裏有什麼,它都會變成AST片斷。這意味着咱們不能夠簡單地將外部的變量注入到咱們的quote裏。例如,這樣是不能達到效果的:
quote do bind_expr sum_expr end
在這裏,quote
只是簡單地生成了對於bind_expr
和sum_expr
變量的quoted標記,它們必須存在於這個AST能夠被理解的環境裏。然而,這不是咱們想要的。咱們想要的是直接注入bind_expr
和sum_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
),最後返回結果。
在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]}]}
這個輸出有點嚇人,但你一般不用理解它。若是仔細觀察,你能夠看到這個結構裏有提到Tracer
和trace
,證實了這個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中嘗試,但使用mix
或elixirc
構建項目時也是同樣的。
我想這些內容對於第一章來講已經夠了。你已經對編譯過程和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.