Elixir Macros 系列文章譯文
- [1] (譯) Understanding Elixir Macros, Part 1 Basics
- [2] (譯) Understanding Elixir Macros, Part 2 - Micro Theory
- [3] (譯) Understanding Elixir Macros, Part 3 - Getting into the AST
- [4] (譯) Understanding Elixir Macros, Part 4 - Diving Deeper
- [5] (譯) Understanding Elixir Macros, Part 5 - Reshaping the AST
- [6] (譯) Understanding Elixir Macros, Part 6 - In-place Code Generation
原文 GitHub 倉庫, 作者: Sa?a Juri?.
這是討論 Elixir 宏 (Macros) 系列文章的第一篇. 我原本計劃在我即將出版的《Elixir in Action》一書中討論這個主題, 但最終決定不這么做, 因為這個主題不符合這本書的主題, 這本書更關注底層 VM 和 OTP 的關鍵部分.
就我個人而言, 我覺得宏的主題非常有趣, 在本系列文章中, 我將試圖解釋它們是如何工作的, 提供一些關于如何編寫宏的基本技巧和建議. 雖然我確信編寫宏不是很難, 但與普通的 Elixir 代碼相比, 它確實需要更高視角的關注. 因此, 我認為這了解 Elixir 編譯器的一些內(nèi)部細節(jié)是非常有幫助的. 了解事情在幕后是如何運行之后, 就可以更容易地理解元編程代碼.
這是篇中級水平的文章. 如果你很熟悉 Elixir 和 Erlang, 但對宏還感覺到困惑, 那么這些內(nèi)容很適合你. 如果你剛開始接觸 Elixir 和 Erlang, 那么最好從其它地方開始. 比如 Getting started guide, 或者一些可靠的書.
元編程 (Meta-programming)
或許你已經(jīng)對 Elixir 中的元編程有一點了解. 其主要的思想就是我們可以編寫一些代碼, 它們會根據(jù)某些輸入來生成代碼.
正因為有了宏 (Macros), 我們可以寫出如下這段來自于 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
在以上兩個例子中, 我們使用到了一些自定義的宏, 這些宏會在編譯時 (compile time) 都轉(zhuǎn)化成其它的代碼. 調(diào)用 Plug 的 get 和 match 會創(chuàng)建一個函數(shù), 而 ExActor 的 defcall 會生成兩個函數(shù)和將參數(shù)正確從客戶端進程傳播給服務端進程的代碼.
Elixir 本身的實現(xiàn)上就非常多地用到了宏. 例如 defmodule, def, if, unless, 甚至 defmacro 都是宏. 這使得語言的核心能保持最小化, 日后對語言的擴展就會更加簡單.
鮮為人知的是, 宏可以讓我們可以有動態(tài) (on the fly) 生成函數(shù)的可能性:
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 module, 同樣的, 它在編譯時會轉(zhuǎn)換成對應的多子句函數(shù) (multi-clause functions).
類似的技術被 Elixir 用于生成 String.Unicode
模塊. 本質(zhì)上講, 這個模塊是通過讀取 UnicodeData.txt
和SpecialCasing.txt
文件里對碼位 (codepoints) 的描述來生成的. 基于文件中的數(shù)據(jù), 各種函數(shù) (例如 upcase, downcase) 會被生成.
無論是宏還是代碼生成, 我們都在編譯的過程中對抽象語法樹做了某些變換. 為了理解它是如何工作的, 你需要學習一點Elixir 編譯過程和 AST 的知識.
編譯過程 (Compilation process)
輸入的源代碼被解析, 然后生成相應的抽象語法樹 (AST1). AST1 會以嵌套的 Elixir Terms 的形式來表述你的代碼. 然后進入展開階段. 在這個階段, 各種內(nèi)置的和自定義的宏被轉(zhuǎn)換成了最終版本. 一旦轉(zhuǎn)換結(jié)束, Elixir 就可以生成最后的字節(jié)碼, 即源程序的二進制表示.
這只是對整個編譯過程的概述. 例如, Elixir 編譯器還會生成 Erlang AST, 然后依賴 Erlang 函數(shù)將其轉(zhuǎn)換為字節(jié)碼, 但是我們還不需要知道這部分細節(jié). 不過, 我認為這幅圖對于理解元編程代碼是有幫助的.
理解元編程魔法的關鍵點在于理解在展開階段 (expansion phase) 發(fā)生了什么. 編譯器會基于原始 Elixir 代碼的 AST 展開為最終版本.
另外, 從這個圖中可以得到另一個重要結(jié)論, Elixir 在生成了二進制之后, 元編程就停止了. 你可以確定你的代碼不會被重新定義, 除非使用到了代碼升級或是一些動態(tài)的代碼插入技術 (這不在本文討論范圍). 元編程總是會引入一個隱形 (或不明顯)的層, 在 Elixir 中這只發(fā)生在編譯時, 并獨立于程序的各種執(zhí)行路徑.
代碼轉(zhuǎn)換發(fā)生在編譯時, 因此推導最終產(chǎn)品會相對簡單, 而且元編程不會干擾例如 dialyzer 這樣的靜態(tài)分析工具. 編譯時元編程 (Compile time meta-programming)也意味著我們不會有性能損失. 進入運行時 (run-time) 后, 代碼就已經(jīng)定型了, 代碼中不會有元編程結(jié)構在運行.
創(chuàng)建 AST 片段
什么是 Elixir AST? 它是一個 Elixir Term (譯注: Elixir 中的所有數(shù)據(jù)都可以看成是 term), 一個深度嵌套的層次結(jié)構, 用于表述一個語法正確的 Elixir 代碼. 為了說得更明白一些, 舉個例子. 要生成某段代碼的 AST, 可以使用 quote
:
iex(1)> quoted = quote do 1 + 2 end
{:+, [context: Elixir, import: Kernel], [1, 2]}
使用 quote
可以獲取任意一個復雜的 Elixir 表達式對應的 AST 片段.
在上面的例子中, 生成的 AST 片段用于描述一個簡單的求和操作 (1+2
). 這通常被稱為 quoted expression. 大多數(shù)時候你不需要去理解 quoted 結(jié)構的具體細節(jié), 讓我們來看一個簡單的例子. 在這種情況下, AST 片段是一個包含如下元素的三元組 (triplet):
- 一個原子 (atom) 表示所要進行的操作 (
:+
) - 表達式上下文 (context, 例如 imports 和 aliases). 通常你并不需要理解這個數(shù)據(jù)
- 操作參數(shù)
要點: 這個 quoted expression 是一個描述代碼的 Elixir term. 編譯器會使用它生成最終的字節(jié)碼.
雖然不常見, 但對一個 quoted expression 求值也是可以的:
iex(2)> Code.eval_quoted(quoted)
{3, []}
返回的元組中包含了表達式的結(jié)果, 以及一個列表, 其中包含了構成表達式的變量.
但是, 在 AST 被求值前 (通常由編譯器完成), quoted expression 并沒有進行語義上的驗證. 例如, 當我們書寫如下表達式時:
iex(3)> a + b
** (CompileError) iex:3: undefined function a/0 (there is no such import)
我們會得到錯誤, 因為這里沒有叫做一個叫做 a 的變量 (或函數(shù)).
相比而言, 如果 quote 這個表達式:
iex(3)> quote do a + b end
{:+, [context: Elixir, import: Kernel],
[{:a, [if_undefined: :apply], Elixir}, {:b, [if_undefined: :apply], Elixir}]}
這個沒有發(fā)生錯誤, 我們有了一個表達式 a+b 的 quoted 表現(xiàn)形式. 其意思是, 生成了一個描述該表達式 a+b
的 term, 不管表達式中的變量是否存在. 最終的代碼并沒有生成, 所以這里不會有錯誤拋出.
如果把該表述插入到某些 a 和 b 是有效標識符的 AST 中, 剛才發(fā)生錯誤的代碼 a+b, 才是正確的. 下面來試一下, 首先 quote 一個求和 (sum)表達式:
iex(4)> sum_expr = quote do a + b end
{:+, [context: Elixir, import: Kernel],
[{:a, [if_undefined: :apply], Elixir}, {:b, [if_undefined: :apply], Elixir}]}
然后創(chuàng)建一個 quoted 變量綁定表達式:
iex(5)> bind_expr = quote do
...(5)> a=1
...(5)> b=2
...(5)> end
{:__block__, [],
[
{:=, [], [{:a, [if_undefined: :apply], Elixir}, 1]},
{:=, [], [{:b, [if_undefined: :apply], Elixir}, 2]}
]}
記住, 它們只是 quoted 表達式. 它們只是在描述代碼的簡單數(shù)據(jù), 并沒有執(zhí)行. 這時, 變量 a 和 b 并不存在于當前 Elixir shell 會話 (session)中.
要使這些片段能夠一起工作, 必須把它們連接起來:
iex(6)> final_expr = quote do
...(6)> unquote(bind_expr)
...(6)> unquote(sum_expr)
...(6)> end
{:__block__, [],
[
{:__block__, [],
[
{:=, [], [{:a, [if_undefined: :apply], Elixir}, 1]},
{:=, [], [{:b, [if_undefined: :apply], Elixir}, 2]}
]},
{:+, [context: Elixir, import: Kernel],
[{:a, [if_undefined: :apply], Elixir}, {:b, [if_undefined: :apply], Elixir}]}
]}
這里我們生成了一個由 bind_expr
和 sum_expr
構成的新的 quoted expression -> final_expr
. 實際上, 我們生成了一個新的 AST 片段, 它結(jié)合了這兩個表達式. 暫不要關心 unquote
的部分 - 我稍后會解釋這一點.
與此同時, 我們可以進行求值計算這個 AST 片段 (fragment):
iex(7)> Code.eval_quoted(final_expr)
{3, [{{:b, Elixir}, 2}, {{:a, Elixir}, 1}]}
再次看到, 求值結(jié)果由一個表達式結(jié)果 (3
), 一個變量綁定列表構成. 形如:
{expression, [{{:variable, Elixir}, value},...]}
=========== ======== ======
| | |
表達式結(jié)果 變量名稱 變量的值
從這個綁定列表中我們可以看出, 該表達式綁定了兩個變量 a 和 b, 對應的值分別為 1 和 2.
這就是在 Elixir 中元編程方法的核心. 當我們進行元編程的時候, 我們實際上是把各種 AST 片段組合起來生成新的我們需要的 AST. 我們通常對輸入 AST 的內(nèi)容和結(jié)構不感興趣, 相反, 我們使用 quote
生成和組合輸入片段, 并生成經(jīng)過修飾后的代碼.
Unquoting
unquote
在這里出現(xiàn)了. 注意, 無論 quote 塊 (quote ... end
) 里有什么, 它都會變成 AST 片段. 這意味著我們不可以簡單地將外部的變量注入到我們的 quote 里. 例如, 這樣是不能達到效果的:
iex(8)> quote do
...(8)> bind_expr
...(8)> sum_expr
...(8)> end
{:__block__, [],
[
{:bind_expr, [if_undefined: :apply], Elixir},
{:sum_expr, [if_undefined: :apply], Elixir}
]}
在這個例子中, quote 僅僅是簡單的生成對 bind_expr 和 sum_expr 的變量引用, 它們必須存在于這個 AST 可以被理解的上下文環(huán)境里. 但這不是我們想要的結(jié)果. 我需要的效果是有一種方式能夠直接注入 bind_expr 和 sum_expr 的內(nèi)容到生成的 AST 的對應的位置.
這就是 unquote(...)
的目的 - 括號里的表達式會被立刻執(zhí)行, 然后就地插入到調(diào)用了 unquote
的地方. 這意味著 unquote
的結(jié)果必須是合法的 AST 片段.
理解 unquote
的另一種方式是, 可以把它看做是字符串的插值 (#{}
). 對于字符串你可以這樣寫:
"....#{some_expression}...."
類似的, 對于 quote 可以這樣寫:
quote do
...
unquote(some_expression)
...
end
對此兩種情況, 求值的表達式必須在當前上下文中是有效的, 并注入該結(jié)果到你構建的表達式中. (要么是 string, 或者是一個 AST 片段)
理解這一點很重要: unquote
并不是 quote
的反向過程. quote
將一段代碼轉(zhuǎn)換成 quoted 表達式 (quoted expression), unquote并沒有做逆向操作. 如果需要把一個 quoted expression 轉(zhuǎn)換為字符串, 可以使用 Macro.to_string/1
.
iex(9)> Macro.to_string(bind_expr)
"a = 1\nb = 2"
iex(10)> Macro.to_string(sum_expr)
"a + b"
iex(11)> Macro.to_string(final_expr)
"(\n a = 1\n b = 2\n)\n\na + b"
例子: tracing expression
理論結(jié)合實踐, 一個簡單例子, 我們將編寫一個幫助我們調(diào)試代碼的宏. 這個宏可以這樣用:
iex(1)> Tracer.trace(1 + 2)
Result of 1 + 2: 3
3
Tracer.trace
接受一個給定的表達式, 會打印其結(jié)果到屏幕上. 然后返回表達式的結(jié)果.
需要認識到這是一個宏, 它的輸入(1+2
)可以被轉(zhuǎn)換成更復雜的形式 — 打印表達式的結(jié)果并返回它. 這個變換會發(fā)生在宏展開階段, 產(chǎn)生的字節(jié)碼為輸入代碼經(jīng)過修飾的版本.
在查看它的實現(xiàn)之前, 想象一下最終的結(jié)果或許會很有幫助. 當我們調(diào)用 Tracer.trace(1+2)
, 對應產(chǎn)生的字節(jié)碼類似于這樣:
mangled_result = 1 + 2
Tracer.print("1+2", mangled_result)
mangled_result
mangled_result
表示 Elixir 編譯器會銷毀所有在宏里引用的臨時變量. 這也被稱為宏清洗 (macro hygiene), 讓宏保持干凈, 不會影響到使用宏的代碼, 我們會在本系列之后的內(nèi)容中討論它(不在本文).
該宏的定義是這樣的:
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
定義宏. 宏本質(zhì)上是特殊形式的函數(shù). 它的名字會被銷毀, 并且只能在展開期調(diào)用它(盡管理論上你仍然可以在運行時調(diào)用).
我們的宏接收到了一個 quoted expression. 這一點非常重要 — 無論你發(fā)送了什么參數(shù)給一個宏, 它們都已經(jīng)是 quoted 的. 所以, 當我們調(diào)用 Tracer.trace(1+2)
, 我們的宏(它是一個函數(shù))不會接收到 3. 相反, expression_ast
的內(nèi)容會是 quote(do: 1+2)
的結(jié)果.
在第三行, 我們使用 Macro.to_string/1
來求出我們所收到的 AST 片段的字符串表達形式. 這是你在運行時不能夠?qū)σ粋€普通函數(shù)做的事之一. 雖然我們能在運行時調(diào)用 Macro.to_string/1
, 但問題在于我們沒辦法再訪問 AST 了, 因此不能夠知道某些表達式的字符串形式了.
一旦我們擁有了字符串形式, 我們就可以生成并返回結(jié)果 AST 了, 這一步是在 quote do ... end
結(jié)構中完成的. 它的結(jié)果是用來替代原始的 Tracer.trace(...)
調(diào)用的 quoted expression.
讓我們進一步觀察這一部分:
如果你明白 unquote
的作用, 那么這個就很簡單了. 實際上, 我們是在把 expression_ast
(quoted 1+2
)代入到我們生成的片段(fragment)中, 將表達式的結(jié)果放入 result
變量. 然后我們使用某種格式來打印它們(借助 Macro.to_string/1), 最后返回結(jié)果.
展開一個 AST
在 Shell 觀察其是如何連接起來是很容易的. 啟動 iex
Shell, 復制粘貼上面定義的 Tracer 模塊:
iex(1)> defmodule Tracer do
...
end
然后, 必須 require Tracer
:
iex(2)> require Tracer
接下來, 對 trace
宏調(diào)用進行 quote 操作:
iex(3)> quoted = quote do Tracer.trace(1+2) end
{{:., [], [{:__aliases__, [alias: false], [:Tracer]}, :trace]}, [],
[{:+, [context: Elixir, import: Kernel], [1, 2]}]}
現(xiàn)在, 輸出看起來有點恐怖, 通常你不必需要理解它. 但是如果你仔細看, 在這個結(jié)構中你可以看到 Tracer 和 trace, 這證明了 AST 片段是何與源代碼相對應的, 但還未展開.
現(xiàn)在, 該開始展開這個 AST 了, 使用 Macro.expand/2
:
iex(4)> expanded = Macro.expand(quoted, __ENV__)
{:__block__, [],
[
{:=, [],
[
{:result, [counter: -576460752303423231, if_undefined: :apply], Tracer},
{:+, [context: Elixir, import: Kernel], [1, 2]}
]},
{{:., [],
[
{:__aliases__, [counter: -576460752303423231, alias: false], [:Tracer]},
:print
]}, [],
[
"1 + 2",
{:result, [counter: -576460752303423231, if_undefined: :apply], Tracer}
]},
{:result, [counter: -576460752303423231, if_undefined: :apply], Tracer}
]}
這是我們的代碼完全展開后的版本, 你可以看到其中提到了 result(由宏引入的臨時變量), 以及對 Tracer.print/2
的調(diào)用. 你甚至可以將這個表達式轉(zhuǎn)換成字符串:
iex(5)> Macro.to_string(expanded)
"result = 1 + 2\nTracer.print(\"1 + 2\", result)\nresult"
iex(6)> Macro.to_string(expanded) |> IO.puts
result = 1 + 2
Tracer.print("1 + 2", result)
result
:ok
這些說明了你對宏的調(diào)用已經(jīng)展開成了別的東西. 這就是宏工作的原理. 盡管我們只是在 shell 中嘗試, 但使用 mix
或elixirc
構建項目時也是一樣的.
我想這些內(nèi)容對于第一篇來說已經(jīng)夠了. 你已經(jīng)對編譯過程和 AST 有所了解, 也看過了一個簡單的宏的例子. 在下一篇 《(譯) Understanding Elixir Macros, Part 2 - Micro Theory》, 我們將更深入地討論宏的一些機制.
譯注
- codepoints: 通常是一個數(shù)字, 用于表示 Unicode 字符.
- Terms: 任何數(shù)據(jù)類型中的一段數(shù)據(jù)都被稱為 term.
原文:https://www.theerlangelist.com/article/macros_1
本文由博客群發(fā)一文多發(fā)等運營工具平臺 OpenWrite 發(fā)布