Elixir元編程-第一章 宏語言

Elixir元編程-第一章 宏語言

注:本章內容來自《Metaprogramming Elixir》一書,寫的很是好,強烈推薦。內容不是原文照翻,部分文字採起意譯,主要內容都基本保留,加上本身的一些理解描述。爲更好理解,建議參考原文。程序員

是時候來探索元編程了。學習了Elixir的基礎知識,或許你想寫出更好的產品庫,或者構建一門 dsl,或者優化運行性能。或許你只是想簡單體會一下 Elixir 超強能力給你帶來的樂趣。若是這就是你想要的,那麼咱們開始吧!web

如今,我假定你已經熟悉了 Elixir;你已經體驗過這門語言,或許還發布過一兩個庫。咱們要進入新的階段,開始學習經過宏來編寫生成代碼的代碼。Elixir 宏是用來改變遊戲規則的。由它開啓的元編程會讓咱們編寫強大程序時信手拈來。express

生成代碼的代碼,聽起來有點拗口,但你不久就會看到它是如何組織起 Elixir 語言自己的基礎架構。宏開啓了在其餘語言中徹底不可能的一扇大門。使用恰當的話,元編程能夠編寫清晰、簡潔的程序,咱們能夠塑造代碼,而非教條地運用指令。編程

咱們會講述 Elixir 所需的一切知識,而後放手去幹吧。安全

讓咱們開始吧。數據結構

整個世界都是你的遊樂場

Elixir 中的元編程所有都是關於擴展能力的。你是否曾但願你喜歡的語言具有某種小巧優雅的特性?若是你走運的話,幾年後這種特性也許會添加到語言中。事實上這種事基本未發生過。在 Elixir 中,只要你願意你能夠任意引入新特性。好比在不少語言中你都很熟悉的 while 循環。在 Elixir 中是沒有這個的,但你又想用它,好比:架構

while Process.alive?(pid) do
  send pid, {self, :ping}
  receive do
    {^pid, :pong} -> IO.puts "Got pong"
  after 2000 -> break
  end
end

下一章,咱們就來編寫這個 while 循環。不止於此,使用 Elixir,咱們能夠用語言定義語言,好比使用天然語法表達某些問題。下面這是一段有效的 Elixir 程序哦:框架

div do
  h1 class: "title" do
    text "Hello"
  end
  p do
    text "Metaprogramming Elixir"
  end
end
"<div><h1 class=\"title\">Hello</h1><p>Metaprogramming Elixir</p></div>"

Elixir 使這種相似編寫 HTML 的 dsl 爲可能。事實上,咱們只須要幾章的學習就能夠編寫這個程序了。你如今還不須要理解這是怎麼幹的,咱們會學到的。如今你只須要知道,宏使得這一切成爲可能。編寫代碼的代碼。Elixir將這個理念貫徹的如此之深,遠超你的想象。less

正如一個遊樂場,你老是從一小塊地方開始,而後以你的方式不斷探索新的領域。元編程會是較難理解掌握的,對它的運用也須要考慮更高階的問題。貫穿本書,咱們會經過大量的簡單練習,初步揭開神祕面紗,最終掌握高階的代碼生成技術。在開始編寫代碼前,咱們先回顧下 Elixir 元編程中很是重要的兩個基本原則,以及他們是如何協做的。編輯器

抽象語法樹:AST

要掌握元編程,首先你須要理解 Elixir 是如何使用 AST 在內部表示 Elixir 代碼的。你接觸到的絕大多數的語言都會使用 AST,但基本上你也會無視它。當你的程序被編譯或解釋執行時,源代碼會被轉換成樹結構,而後編譯成字節碼或機器碼。這個過程通常都是不可見的,你也歷來不會注意到它。

José Valim,Elixir語言的發明者,選擇了不一樣的處理方式。他用 Elixir 本身的數據結構來保存 AST 格式,並將其暴露出來,而後提供天然的語法來同其交互。使用普通 Elixir 代碼就能訪問 AST,這讓你得到了編譯器或者語言設計者才擁有的訪問底層能力,你就能作一些很是強大的事情。在元編程的每一個階段,你都在同 Elixir 的 AST 進行交互,那麼就讓咱們深刻探索下它究竟是什麼。

Elixir 中的元編程涉及分析和修改 AST。你可使用 quote 宏來訪問任意 Elixir 表達式的 AST 結構。代碼生成極度依賴於 quote,貫穿本書的全部練習都離不開他。咱們研究下用它獲取的一些基本表達式的 AST 結構。

輸入如下代碼,觀察返回結果:

iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}

iex> quote do: div(10, 2)
{:div, [context: Elixir, import: Kernel], [10, 2]}

咱們能夠看到 1 + 2 和 div 表達式的 AST 結構,就是用 Elixir 自身的簡單的數據結構來表示。讓咱們沉思片刻,你能夠訪問用 Elixir 數據結構保存的你寫的任意代碼的的表達(譯註:實際上就是代碼即數據,數據即代碼)。quote 表達式所能帶給你的東西你見所未見:可以審視你所編寫代碼的內部展示,並且是用你徹底知道和理解的數據結構。這讓你在Elixir高階語法層面更好的理解代碼,優化性能,以及擴展功能。(譯註:這裏的高階就是指普通Elixir語法,相比 AST 它確實是高階;就比如 C 語言之於彙編)

擁有了 AST 的所有訪問能力,咱們就可以在編譯階段耍一些優雅的小把戲。好比,Elixir 標準庫中的 Logger 模塊,能夠經過從 AST 中完全刪除對應表達式來優化日誌功能(譯註:即開發調試時運行日誌,最終發佈版本時自動刪除全部日誌,並且是從 AST 刪除,對發佈版來講,該日誌從未存在過)。好比說,咱們在寫入一個文件時,但願在開發階段打印文件路徑,但在產品發佈階段則徹底忽略這個動做。咱們可能寫出以下代碼:

def write(path, contents) do
  Logger.debug "Writing contents to file #{path}"
  File.write!(path, contents)
end

在產品發佈階段,Logger.debug 表達式會完全從程序中刪除。這是由於咱們在編譯時能夠徹底操做 AST,從而跳過同開發階段相關的代碼。大多數語言不得不調用 debug 函數,檢測運行時忽略的 log 等級,純屬浪費 CPU 時間,由於這些語言根本沒法操縱 AST。

探究 Logger.debug 是如何作到這一點的,這就把咱們引領到元編程的一個重要概念面前:宏(macros)。

宏就是編寫代碼的代碼。終其一輩子其做用就是用 Elixir 的高階語法同 AST 交互。這也是爲何 Logger.debug 看起來像普通的 Elixir 代碼,但卻能完成高超的優化技巧。

宏無處不在,既能夠用來構建 Elixir 標準庫,也能夠用來構建 web 框架的核心架構。無論哪一種狀況,使用的都是相同的元編程規則。你無須在複雜性,性能快慢,API 的簡潔優雅上妥協。Elixr 宏能讓你編寫簡單又高效的代碼。它讓你--程序員,從單純的語言使用者,變成語言的建立者。只要你用這門語言,要不了多久,你就會使用到 José 用來構建這門語言的標準庫的全部工具和威力。他開放了這門語言,容許你本身擴展。一旦你體驗過這種威力,食髓知味,你就很難回頭了。

你可能會想直到目前你都在儘可能避免使用宏,但其實這些宏一直都在,靜靜的隱藏在幕後。看下下面這段簡單代碼:

defmodule Notifier do
  def ping(pid) do
    if Process.alive?(pid) do
      Logger.debug "Sending ping!"
      send pid, :ping
    end
  end
end

看上去平平無奇,但咱們已經發現了四個宏。在語言內部,defmodule,def,if,甚至 Logger.debug 都是用宏實現的,Elixir 大多數的頂層結構也基本如此。你能夠本身在 iex 裏面查看下文檔:

iex> h if

	defmacro if(condition, clauses)
	
Provides an if macro. This macro expects the first argument to be a condition
and the rest are keyword arguments.

你可能會好奇 Elixir 在本身的架構中使用宏有什麼優點,大多數其餘語言沒有這玩意兒不也挺好的嗎。宏最強大的一個功能就是你能夠本身定義語言的關鍵字,就基於現有的宏做爲構建基石就行。

要理解 Elixir 中的元編程,就要拋棄那些封閉式語言以及死板僵化的保留字那套陳腐觀念。Elixir 被設計成能夠隨意擴展。這門語言是開放的,能夠任意探索,任意定製。這也是爲什麼在 Elixir 實現元編程是如此的天然舒服。

知識彙總一下

咱們已經見識過了 Elixir 自身是如何由宏構建的,以及使用 quote 如何返回任意表達式的 AST 格式。如今咱們把知識彙總一下。最重要的一點要知道宏接受 AST 做爲參數,而後返回值必定也是一個 AST。所謂編寫宏,就是用 Elixir 的高階語法構建 AST。

要了解這套機制如何運做,咱們先編寫一個宏用來輸出一個 Elixir 數學表達式在計算結果時產生的可讀格式,好比 5 + 2。在大多數語言當中,咱們只能解析表達式的字符串,將其轉化成程序可以識別的格式。在 Elixir 中,咱們可以直接使用宏訪問表達式的內部展示形式。

咱們第一步是分析咱們的宏要接受的表達式的 AST 結構。咱們使用 iex 而後 quote 一些表達式。本身去嘗試下,好好體會下 AST 的結構。

iex> quote do: 5 + 2
{:+, [context: Elixir, import: Kernel], [5, 2]}

iex)> quote do: 1 * 2 + 3
{:+, [context: Elixir, import: Kernel],
 [{:*, [context: Elixir, import: Kernel], [1, 2]}, 3]}

5 + 2 跟 1 * 2 + 3 表達式的 AST 直接就是個元組。:+:* 兩個 atom 表明操做符,左右參數放在最後一個元素當中。三元組結構就是 Elixir 的高階表達形式。

如今咱們知道表達式是如何表示的了,讓咱們定義第一個宏來看看 AST 是如何配合的。咱們會定義一個 Math 模塊,包含一個 say 宏,可以以天然語言形式在任意數學表達式求值時將其輸出。

建立一個 math.exs 文件,添加以下代碼:

macros/math.exs

defmodule Math do

  # {:+, [context: Elixir, import: Kernel], [5, 2]}
  defmacro say({:+, _, [lhs, rhs]}) do 
    quote do
      lhs = unquote(lhs)
      rhs = unquote(rhs)
      result = lhs + rhs
      IO.puts "#{lhs} plus #{rhs} is #{result}"
      result
    end
  end

  # {:*, [context: Elixir, import: Kernel], [8, 3]}
  defmacro say({:*, _, [lhs, rhs]}) do 
    quote do
      lhs = unquote(lhs)
      rhs = unquote(rhs)
      result = lhs * rhs
      IO.puts "#{lhs} times #{rhs} is #{result}"
      result
    end
  end
end

在 iex 里加載測試:

iex> c "math.exs"
[Math]

iex> require Math
nil

iex> Math.say 5 + 2
5 plus 2 is 7
7

iex> Math.say 18 * 4
18 times 4 is 72
72

分解下程序。咱們知道宏接受 AST 格式的參數,所以咱們直接使用模式匹配,來肯定該調用哪個 say。第4到15行,是宏定義,跟函數相似,能夠有多個簽名。知道告終果 quoted 後的格式,所以咱們能夠很容易地將左右兩邊的值綁定到變量上,而後輸出對應信息。

要完成宏功能,咱們還要經過 quote 返回一個 AST 給調用者,用來替換掉 Math.say 調用。這裏咱們第一次用到 unquote。咱們後面會詳述 quote 跟 unquote。如今,你只須要知道這兩個宏協同工做來幫助你建立 AST,他們會幫助你跟蹤代碼的執行空間。

先把那些條條框框放一邊,咱們如今已經深刻到了 Elixir 元編程體系的細節中。你已經見識到宏跟 AST 協同工做,如今讓咱們研究它是如何運做的。但首先,咱們要討論一些東西。

宏的規則

在開始編寫更復雜的宏以前,咱們須要強調一些規則,以便更準確調整預期。宏給咱們帶來神奇的力量,但能力越大,責任越大。

規則1:不要編寫宏

當你同其餘人談論元編程時,可能已經早就被警告過了。儘管這是毫無道理的,但在咱們陷入狂熱前,咱們仍是要牢記編寫生成代碼的代碼須要格外當心。若是魯莽行事,咱們很容易陷入困境。若是走得太遠,宏會使得程序難以調試,難以分析。固然元編程確定有某種顯著的優勢的。但通常來講,若是不必生成代碼,那咱們就用標準函數定義好了。

規則2:隨便用宏

有人說元編程有時是複雜而脆弱的。咱們會經過利用一小段必要代碼來生成健壯,清晰的程序來駁斥這種說法。不要被 Elixir 宏系統可能帶來的一點點晦澀所嚇倒,而放棄對宏系統的深刻探索。學習元編程的最好方式就是開放思想,放棄成見,保持好奇心。學習時甚至能夠有點小小的不負責任(意爲大膽嘗試)。

編寫宏的時候能夠秉持以上雙重標準。在你的元編程之旅,你會看到如何可靠地運用你的熟練技巧,同時學會如何有效地避開常見陷阱。優秀的代碼本身會說話,咱們就是要充分挖掘它。

抽象語法樹--揭開神祕面紗

是時候深刻探索 AST 了,咱們來學習源碼展示的不一樣形式。你可能急於如今就一頭跳進去,立刻開始編寫宏,但真正理解 AST 是後面學習元編程的重中之重。一旦你深刻理解了它的精微奧妙,你會發現 Elixir 代碼遠比你想象得更接近 AST。後面的內容會顛覆你對解決問題的思考方式,並驅使你的宏能力不斷進步。學習了優雅的 AST 後,咱們將能夠開始元編程練習了。有點耐心。你會在真正瞭解全部這些技術以前就建立了新的語言特性。

AST 的結構

你所編寫的每個 Elixir 表達式都會分解成一個三元組格式的 AST。你會常用這種統一格式來進行模式匹配,分解參數。在前面的 Math.say 的定義中,咱們已經用到了這種技術。

defmacro say({:+, _, [lhs, rhs]}) do

既然咱們已經知道了表達式 5 + 2 會轉化成 {:+, [...], [5, 2]} 元組,咱們就能夠直接模式匹配 AST,獲取計算的含義。讓咱們 quote 一些更復雜的表達式,來看看 Elixir 程序是如何完整地用 AST 表示。

iex> quote do: (5 * 2) - 1 + 7
{:+, [context: Elixir, import: Kernel],
 [{:-, [context: Elixir, import: Kernel],
  [{:*, [context: Elixir, import: Kernel], [5, 2]}, 1]}, 7]}
  
iex> quote do
...>   defmodule MyModule do
...>     def hello, do: "World"
...>   end
...> end

{:defmodule, [context: Elixir, import: Kernel],
 [{:__aliases__, [alias: false], [:MyModule]},
  [do: {:def, [context: Elixir, import: Kernel],
   [{:hello, [context: Elixir], Elixir}, [do: "World"]]}]]}

你能夠看到每個 quoted 的表達式造成了一個堆棧結構的元組。第一個例子同 Math.say 宏的基本結構是相似的,不過是有更多的元組嵌套在一塊兒組成樹狀結構用來表達一個完整的表達式。第二個例子展現了一個完整的 Elixir 模塊是若是用一個簡單的 AST 結構來表示的。

其實一直以來,你所編寫 Elixir 代碼都是用這種簡單一致的結構來展示的。理解這種結構,只須要了解幾條簡單規則就好了。全部的 Elixir 代碼都表示爲一系列的三元組,其格式以下:

  • 第一個元素是一個 atom,表示函數調用,或者是另外一個元組,表示 AST 中嵌套的節點。
  • 第二個元素是表達式的元數據。
  • 第三個元素是一個參數列表,用於函數調用。

咱們用這個規則來分解下上面例子中 (5 * 2) - 1 + 7這個表達式的 AST:

iex(1)> quote do: (5 * 2) - 1 + 7
{:+, [context: Elixir, import: Kernel],
 [{:-, [context: Elixir, import: Kernel],
  [{:*, [context: Elixir, import: Kernel], [5, 2]}, 1]}, 7]}

咱們看到 AST 格式就是一棵函數和其參數構成的樹。咱們對輸出結構美化下,把這棵樹看得更清楚些:

讓咱們從 AST 的終點向下遍歷,AST 的 root 節點是 + 操做符,參數是數字 7 和另外一個嵌入節點。咱們看到嵌入節點包含 (5*2)表達式,它的計算結果又用於 - 1 這條分支。你應該還記得 5 * 2在 Elixir 中不過是 Kernel.*(5,2)調用的語法糖。這樣咱們的表達式更容易解碼。原子 :*,就是個函數調用,元數據告訴咱們它是從 Kernel import 過來的。後面的元素 [5,2] 就是 Kernel.*/2函數的參數列表。所有的程序都是這樣經過一個簡單 Elixir 元組構成的樹來表示的。

高階語法 vs. 低階 AST

要理解 Elixir 語法跟 AST 背後的設計哲學,最好的辦法莫過於拿來同其餘語言比較一下,看看 AST 處於什麼位置。在某些語言當中,好比頗有個性的 Lisp,它直接用 AST 編寫,用括號組織表達式。若是你看的仔細,會發現 Elixir 某種程度上也是這種格式。

Lisp: (+ (* 2 3) 1)

Elixir(這裏去掉了元數據)

quote do: 2 * 3 + 1
{:+, _, [{:*, _, [2, 3]}, 1]}

若是你比較 Elixir AST 跟 Lisp 的源碼,將括號都換成圓括號,就會發現他們的結構基本上都是同樣的。Elixir 乾的漂亮的地方在於從高階的源碼轉換到低階的 AST 只須要一個簡單的 quote 調用。而對於 Lisp,你確實擁有了可編程的 AST 的所有威力,可代價是不夠天然不夠靈活的語法。José 革命性的創新就在於將語法同 AST 分離。在 Elixir 中,你能夠同時擁有這兩樣最好的東西:可編程的 AST,以及可經過高階語法進行訪問。

AST 字面量

當你開始探索 Elixir 源碼是如何用 AST 表達時,有時會發現 quoted 的表達式看上去使人困惑,彷佛也不大規範。要破解這個困惑,你須要知道 Elixir 中的一些字面量在 AST 中和高階源碼中的表現形式是同樣的。這包括 atom,整數,浮點數,list,字符串,還有任意的包含 former types 的二元組。例如,下面這些字面量在 quoted 時直接返回自身:

iex> quote do: :atom
:atom
iex> quote do: 123
123
iex> quote do: 3.14
3.14
iex> quote do: [1, 2, 3]
[1, 2, 3]
iex> quote do: "string"
"string"
iex> quote do: {:ok, 1}
{:ok, 1}
iex> quote do: {:ok, [1, 2, 3]}
{:ok, [1, 2, 3]}

若是咱們將上述例子傳遞給一個宏,那麼宏接受的也只會是參數的字面量形式,而不是抽象表達形式。若是 quote 其餘的數據類型,咱們就會看到,獲得的是抽象形式:

iex> quote do: %{a: 1, b: 2}
{:%{}, [], [a: 1, b: 2]}

iex> quote do: Enum
{:__aliases__, [alias: false], [:Enum]}

上述 quoted 的演示告訴咱們 Elixir 的數據類型在 AST 裏面有兩種不一樣的表現形式。一些值會直接傳遞,而一些複雜的數據類型會轉換成 quoted 表達式。編寫宏時牢記這些字面量規則是頗有好處的,咱們也就不會爲參數究竟是不是抽象格式而困惑了。

如今咱們已經爲理解 AST 結構打好了基礎,是時候開始進行代碼生成練習了,也能夠驗證下新知識。下一步,咱們會探索如何利用 Elixir 宏系統來轉換 AST。

宏:Elixir 的基本構建部件(Building Blocks)

該乾乾髒活了,咱們看看宏究竟是什麼。我向你許諾過能夠定製語言特性,如今咱們就從重建一個 Elixir 特性開始吧。經過這個練習,咱們會揭示宏的基本特性,同時看到 AST 是如何融入其中的。

重建 Elixir 的 unless 宏

咱們如今假設 Elixir 語言根本沒有內建 unless 結構。在大多數語言當中,咱們不得不退而求其次,使用 if !表達式來替代它,並且只能無奈地接受。

對咱們很幸運,Elixir 不是大多數語言。讓咱們定義本身的 unless 宏,利用已有的 if 做爲咱們實現的基礎部件。宏必須定義在模塊內部,咱們定義一個 ControlFlow 模塊。打開編輯器,建立 unless.exs 文件:

macros/unless.exs

defmodule ControlFlow do
  defmacro unless(expression, do: block) do 
    quote do
      if !unquote(expression), do: unquote(block)
    end
  end
end

在同一目錄下打開 iex,測試一下:

iex> c "unless.exs"
[ControlFlow]

iex> require ControlFlow
nil

iex> ControlFlow.unless 2 == 5, do: "block entered"
"block entered"

iex> ControlFlow.unless 5 == 5 do
...>   "block entered"
...> end
nil

咱們必需要在模塊未被 imported 時,在調用以前 require ControlFlow。由於宏接受 AST 形式的參數,咱們能夠接受任何有效的 Elixir 表達式做爲 unless 的第一個參數。第二個參數,咱們直接經過模式匹配獲取 do/end 塊,將 AST 綁定到一個變量上。必定要記住,一個宏其生命期的職責就是獲取一個 AST 形式,而後返回一個 AST 形式,所以咱們立刻用 quote 返回了一個 AST。在 quote 內部,咱們作了一個單行的代碼生成,將 unless 關鍵字轉換成了 if !表達式:

quote do
  if !unquote(expression), do: unquote(block)
end

這種轉換咱們稱之爲宏展開(macro expansion)。unless 最終返回的 AST 將會於編譯時,在調用者的上下文(context)中展開。在 unless 使用的任何地方,產生的代碼將會包含一個 if !表達式。這裏咱們還使用了前面在 Math.say 中用到的 unquote 宏。

unquote

unquote 宏容許將值就地注入到 AST 中。你能夠把 quote/unquote 想象成字符串中的插值。若是你建立了一個字符串,而後要將一個變量的值注入到字符串中,你會對其作插值的操做。構建 AST 也是相似的。咱們用 quote 生成一個 AST(存入變量-譯註),而後用 unquote 將(變量值-譯註)值注入到一個外部的上下文。這樣就容許外部的綁定變量,表達式或者是 block,可以直接注入到咱們的 if ! 變體中。

咱們來測試一下。咱們使用 Code.eval_quote 來直接運行一個 AST 而後返回結果。在 iex 中輸入下面這一系列表達式,而後分析每一個變量在求值時有何不一樣:

iex> number = 5
5

iex> ast = quote do 
...>   number * 10
...> end 
{:*, [context: Elixir, import: Kernel], [{:number, [], Elixir}, 10]} 

iex> Code.eval_quoted ast
** (CompileError) nofile:1: undefined function number/0 

iex> ast = quote do 
...>   unquote(number) * 10
...> end 
{:*, [context: Elixir, import: Kernel], [5, 10]}

iex> Code.eval_quoted ast
{50, []}

在第7行咱們看到第一次 quoted 的結果並無被注入到返回的 AST 中。相反,產生了一個本地 number 引用的 AST,所以運行時拋出一個 undefined 錯誤。咱們在第13行使用 unquote 正確地將 number 值注入到 quoted 上下文中,修復了這個問題。對最終的 AST 求值也返回了正確結果。

使用 unquote,咱們的百寶箱裏又多了一件元編程的工具。有了 quote 跟 unquote 的成對使用,構建 AST 時,咱們就不須要再笨手笨腳的手工處理 AST 了。

宏展開

讓我深刻 Elixir 內部,去探尋在編譯時宏到底發生了什麼。當編譯器碰見一個宏,就會遞歸地展開它,直到代碼再也不包含任何宏。下面這幅圖描述了一個簡單的 ControlFlow.unless 表達式的高階處理流程。

這幅圖片顯示了編譯器在遇到 AST 宏時的處理策略,就是將它展開。若是展開的代碼依然包含宏,那就所有展開。這種展開遞歸地進行直到全部的宏都已經所有展開成他們最終的生成代碼形式。如今咱們想象一下編譯器遇到下面這個代碼塊時:

ControlFlow.unless 2 == 5 do
  "block entered"
end

How Elixir Expands Macros

咱們知道 ControlFlow.unless 宏會生成一個 if ! 表達式,所以編譯器會將代碼展開成下面的樣子:

if !(2 == 5) do
  "block entered"
end

如今編譯器又看到了一個 if 宏,而後繼續展開代碼。可能你還不知道,但是 Elixir 的 if 是在內部經過 case 表達式實現的一個宏。所以最終展開的代碼變成了一個基本的 case 代碼塊:

case !(2 == 5) do
  x when x in [false, nil] ->
    nil
  _ ->
    "block entered"
end

如今代碼再也不包含任何可展開的宏了,編譯器完成它的工做而後繼續編譯其它代碼去了。case 宏屬於一個最小規模宏集合的一員,它位於 Kernel.SpecialForms 中。這些宏屬於 Elixir 的基礎構建部分(building blocks),絕對不可以覆蓋篡改。它們也是宏擴展的盡頭。

讓咱們打開 iex 跟隨前面的流程,看下 AST 是如何一步步展開的。咱們使用 Macro.expand_once 在每一步捕獲結果後展開一次。注意要在與 unless.exs 文件相同目錄中打開 iex,輸入下面表達式:

iex> c "macros/unless.exs"
[ControlFlow]

iex> require ControlFlow
nil

iex> ast = quote do
...>   ControlFlow.unless 2 == 5, do: "block entered"
...> end
{{:., [], [{:__aliases__, [alias: false], [:ControlFlow]}, :unless]}, [],
 [{:==, [context: Elixir, import: Kernel], [2, 5]}, [do: "block entered"]]}

iex> expanded_once = Macro.expand_once(ast,__ENV__)
{:if, [context: ControlFlow, import: Kernel],
 [
   {:!, [context: ControlFlow, import: Kernel],
    [{:==, [context: Elixir, import: Kernel], [2, 5]}]},
   [do: "block entered"]
 ]}

iex> expanded_fuly = Macro.expand_once(expanded_once,__ENV__)
{:case, [optimize_boolean: true],
 [
   {:!, [context: ControlFlow, import: Kernel],
    [{:==, [context: Elixir, import: Kernel], [2, 5]}]},
   [
     do: [
       {:->, [],
        [
          [
            {:when, [],
             [
               {:x, [counter: -576460752303423452], Kernel},
               {{:., [], [Kernel, :in]}, [],
                [{:x, [counter: -576460752303423452], Kernel}, [false, nil]]}
             ]}
          ],
          nil
        ]},
       {:->, [], [[{:_, [], Kernel}], "block entered"]}
     ]
   ]
 ]}
iex>

第7行,咱們 quote 了一個簡單的 unless 宏調用。接下來,咱們第13行使用 Macro.expand_once 來展開宏一次。咱們能夠看到 expanded_once AST 被轉換成了 if ! 表達式,正如咱們在 unless 中定義的。最終,在第18行咱們徹底將宏展開。expanded_fully AST 顯示 Elixir 中的 if 宏最終徹底被分解爲最基礎的 case 表達式。

這個練習只爲展現 Elixir 宏系統構建的本質。咱們三次進入代碼構造,而後依賴簡單的 AST 轉換生成了最終結果。Elixir 中的宏一以貫之。這些宏讓這門語言可以構建自身,咱們本身的庫也徹底能夠利用。

代碼的多層次展開聽上去不大安全,但不必擔憂。Elixir 有辦法保證宏運行時的安全。咱們看下是如何作到的。

代碼注入和調用者上下文

宏不光是爲調用者生成代碼,還要注入他。咱們將代碼注入的地方稱之爲上下文(context)。一個 context 就是調用者的 bindings,imports,還有 aliases 能看到的做用域。對於宏的調用者,context 很是寶貴。它可以保持你眼中世界的樣貌,並且是不可變的,你可不會但願你的變量,imports,aliases 在你不知道的狀況下偷偷改變吧。

Elixir 的宏在保持 context 安全性跟必要時容許直接訪問二者間保持了優秀的平衡。讓咱們看看如何安全地注入代碼,以及有何手段能夠訪問調用者的 context。

注入代碼

由於宏所有都是關於注入代碼的,所以你必須得理解宏運行時的兩個 context,不然代碼極可能在錯誤的地方運行。一個 context 是宏定義的地方,另一個是調用者調用宏的地方。讓咱們實戰一下,定義一個 definfo 宏,這個宏會以友好格式輸出模塊信息,用於顯示代碼執行時所在的 context。建立 callers_context.exs 文件,輸入代碼:

macros/callers_context.exs

defmodule Mod do
  defmacro definfo do
    IO.puts "In macro's context (#{__MODULE__})." 

    quote do
      IO.puts "In caller's context (#{__MODULE__})." 

      def friendly_info do
        IO.puts """
        My name is #{__MODULE__}
        My functions are #{inspect __info__(:functions)}
        """
      end
    end
  end
end

defmodule MyModule do
  require Mod
  Mod.definfo
end

進入 iex,加載文件:

iex> c "callers_context.exs"
In macro's context (Elixir.Mod).
In caller's context (Elixir.MyModule).
[MyModule, Mod]

iex> MyModule.friendly_info
My name is Elixir.MyModule
My functions are [friendly_info: 0]

:ok

咱們能夠從標準輸出看到,當模塊編譯時咱們分別進入了宏和調用者的 context。第3行在宏展開前,咱們進入了 definfo 的 context。而後第6行在 MyModule 調用者內部生成了展開的 AST,在這裏 IO.puts 被直接注入到模塊內部,同時還單獨定義了一個 friendly_info 函數。

若是你搞不清楚你的代碼當前運行在什麼 context 下,那就說明你的代碼過於複雜了。要避免混亂的惟一辦法就是保持宏定義儘量的簡短直白。

保護調用者 Context 的衛生

(衛生宏:這個名稱真難聽,當初是誰第一個翻譯的) Elixir 的宏有個原則要保持衛生。衛生的含義就是你在宏裏面定義的變量,imports,aliases 等等根本不會泄露到調用者的空間中。在展開代碼時咱們必須格外注意宏的衛生,由於有時候咱們萬不得已仍是要採用一些不那麼幹淨的手法來直接訪問調用者的空間。

當我第一次瞭解到衛生這個詞,感受聽上去很是的尷尬和困惑--這真是用來描述代碼的詞嗎。但若干介紹以後,這個關乎乾淨,無污染的執行環境的想法就徹底可以理解了。這個安全機制不但可以阻止災難性的名字空間衝突,還能迫使咱們進入調用者的 context 時必須交代的清楚明白。

咱們已經見識過了代碼注入如何工做,但咱們尚未在兩個不一樣 contexts 間定義或是訪問過變量。讓我探索幾個例子看看宏衛生如何運做。咱們將再次使用 Code.eval_quoted 來執行一段 AST。在 iex 中輸入以下代碼:

iex> ast = quote do
...>   if meaning_to_life == 42 do
...>     "it's true"
...>   else
...>     "it remains to be seen"
...>   end
...> end

{:if, [context: Elixir, import: Kernel],
 [
   {:=, [], [{:meaning_to_life, [], Elixir}, 42]},
   [do: "it's true", else: "it remains to be seen"]
 ]}

iex> Code.eval_quoted ast, meaning_to_life: 42
** (CompileError) nofile:1: undefined function meaning_to_life/0

meaning_to_life 這個變量在咱們表達式的視野中徹底找不到,即使咱們將綁定傳給 Code.eval_quoted 也不行。Elixir 的安全策略是你必須直白地聲明,容許宏在調用者的 context 中定義綁定。這種設計會強制你思考破壞宏衛生是否必要。

破壞衛生

咱們能夠用 var! 宏來直接聲明在 quoted 表達式中須要破壞宏衛生。讓咱們重寫以前 iex 中的例子,使用 var! 來進入到調用者的 context:

iex> ast = quote do
...>   if var!(meaning_to_life) == 42 do
...>     "it's true"
...>   else
...>     "it remains to be seen"
...>   end
...> end

{:if, [context: Elixir, import: Kernel],
 [
   {:==, [context: Elixir, import: Kernel],
    [
      {:var!, [context: Elixir, import: Kernel],
       [{:meaning_to_life, [], Elixir}]},
      42
    ]},
   [do: "it's true", else: "it remains to be seen"]
 ]}

iex> Code.eval_quoted ast, meaning_to_life: 42
{"it's true", [meaning_to_life: 42]}

iex> Code.eval_quoted ast, meaning_to_life: 100
{"it remains to be seen", [meaning_to_life: 100]}

讓我建立一個模塊,在其中篡改在調用者中定義的變量,看看宏的表現。在 iex 中輸入以下:

macros/setter1.exs

iex> defmodule Setter do
...>   defmacro bind_name(string) do
...>     quote do
...>       name = unquote(string)
...>     end
...>   end
...> end
{:module, Setter, ...

iex> require Setter
nil

iex> name = "Chris"
"Chris"

iex> Setter.bind_name("Max")
"Max"

iex> name
"Chris"

咱們能夠看到因爲衛生機制保護着調用者的做用域,name 變量並無被篡改。咱們再試一次,使用 var! 容許咱們的宏生成一段 AST,在展開時能夠直接訪問調用者的綁定: macros/setter2.exs

iex> defmodule Setter do
...>   defmacro bind_name(string) do
...>     quote do
...>       var!(name) = unquote(string)
...>     end
...>   end
...> end
{:module, Setter, ...

iex> require Setter
nil
iex> name = "Chris"
"Chris"
iex> Setter.bind_name("Max")
"Max"
iex> name
"Max"

經過使用 var!,咱們破壞了宏衛生將 name 從新綁定到一個新的值。破壞宏衛生通常用於一事一議的個案處理。固然一些高階的手法也須要破壞宏衛生,但咱們通常應該儘可能避免,由於它可能隱藏實現細節,同時添加一些不爲調用者所知的隱含行爲。之後的練習咱們會有選擇的破壞衛生,但那是絕對必要的。

使用宏時,咱們必定要清楚地知道宏運行在哪一個 context,同時要保持宏衛生。咱們體驗過直接聲明破壞衛生,用於探索宏在整個生命週期所進入的不一樣 context。咱們要秉持這些信念來指導咱們後續的開發實踐。

進一步探索

咱們已經揭開了抽象語法樹的神祕面紗,它是支撐全部 Elixir 代碼的基礎。經過 quote 一個表達式,操縱 AST,定義宏,你的元編程之旅一路進階。在後續的章節,咱們會建立更爲高級的宏,用來定製語言結構,咱們還會編寫一個迷你測試框架,能夠推斷 Elixir 表達式的含義。

至於你,須要將前面講的知識點展開。這有一些想法你能夠嘗試一下:

  • 不依賴 Kernel.if 定義一個 unless 宏,使用其餘的 Elixir 流程控制結構。
  • 定義一個宏用來返回手寫代碼的原始 AST,固然不許使用 quote 代碼生成。
相關文章
相關標籤/搜索