(譯) 理解 Elixir 中的宏 Macro, 第一部分:基礎

Elixir Macros 系列文章譯文

這是討論 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.txtSpecialCasing.txt 文件里對碼位 (codepoints) 的描述來生成的. 基于文件中的數(shù)據(jù), 各種函數(shù) (例如 upcase, downcase) 會被生成.

無論是宏還是代碼生成, 我們都在編譯的過程中對抽象語法樹做了某些變換. 為了理解它是如何工作的, 你需要學習一點Elixir 編譯過程和 AST 的知識.

編譯過程 (Compilation process)

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_exprsum_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 中嘗試, 但使用 mixelixirc 構建項目時也是一樣的.

我想這些內(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ā)布

?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末捧韵,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子稽煤,更是在濱河造成了極大的恐慌,老刑警劉巖恼蓬,帶你破解...
    沈念sama閱讀 216,651評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件倍靡,死亡現(xiàn)場離奇詭異,居然都是意外死亡秩贰,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,468評論 3 392
  • 文/潘曉璐 我一進店門柔吼,熙熙樓的掌柜王于貴愁眉苦臉地迎上來毒费,“玉大人,你說我怎么就攤上這事愈魏∶俨#” “怎么了?”我有些...
    開封第一講書人閱讀 162,931評論 0 353
  • 文/不壞的土叔 我叫張陵培漏,是天一觀的道長溪厘。 經(jīng)常有香客問我,道長牌柄,這世上最難降的妖魔是什么畸悬? 我笑而不...
    開封第一講書人閱讀 58,218評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮珊佣,結(jié)果婚禮上蹋宦,老公的妹妹穿的比我還像新娘闺骚。我一直安慰自己,他們只是感情好妆档,可當我...
    茶點故事閱讀 67,234評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著虫碉,像睡著了一般贾惦。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上敦捧,一...
    開封第一講書人閱讀 51,198評論 1 299
  • 那天须板,我揣著相機與錄音,去河邊找鬼兢卵。 笑死习瑰,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的秽荤。 我是一名探鬼主播甜奄,決...
    沈念sama閱讀 40,084評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼窃款!你這毒婦竟也來了课兄?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,926評論 0 274
  • 序言:老撾萬榮一對情侶失蹤晨继,失蹤者是張志新(化名)和其女友劉穎烟阐,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體紊扬,經(jīng)...
    沈念sama閱讀 45,341評論 1 311
  • 正文 獨居荒郊野嶺守林人離奇死亡蜒茄,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,563評論 2 333
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了餐屎。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片檀葛。...
    茶點故事閱讀 39,731評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖啤挎,靈堂內(nèi)的尸體忽然破棺而出驻谆,到底是詐尸還是另有隱情,我是刑警寧澤庆聘,帶...
    沈念sama閱讀 35,430評論 5 343
  • 正文 年R本政府宣布胜臊,位于F島的核電站,受9級特大地震影響伙判,放射性物質(zhì)發(fā)生泄漏象对。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,036評論 3 326
  • 文/蒙蒙 一宴抚、第九天 我趴在偏房一處隱蔽的房頂上張望勒魔。 院中可真熱鬧甫煞,春花似錦、人聲如沸冠绢。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,676評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽弟胀。三九已至楷力,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間孵户,已是汗流浹背萧朝。 一陣腳步聲響...
    開封第一講書人閱讀 32,829評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留夏哭,地道東北人检柬。 一個月前我還...
    沈念sama閱讀 47,743評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像竖配,于是被迫代替她去往敵國和親何址。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,629評論 2 354

推薦閱讀更多精彩內(nèi)容