1. 程式人生 > >Elixir超程式設計-第一章 巨集語言

Elixir超程式設計-第一章 巨集語言

Elixir超程式設計-第一章 巨集語言

注:本章內容來自 Metaprogramming Elixir 一書,寫的非常好,強烈推薦。內容不是原文照翻,部分文字採取意譯,主要內容都基本保留,加上自己的一些理解描述。為更好理解,建議參考原文。

是時候來探索超程式設計了。學習了Elixir的基礎知,或許你想寫出更好的產品庫,或者構建一門 dsl,或者優化執行效能。或許你只是想簡單體會一下 Elixir 超強能力給你帶來的樂趣。如果這就是你想要的,那麼我們開始吧!

現在,我假定你已經熟悉了 Elixir;你已經體驗過這麼語言,或許還發布過一兩個庫。我們要進入新的階段,開始學習通過巨集來編寫生成程式碼的程式碼。Elixir 巨集是用來改變遊戲規則的。由它開啟的超程式設計會讓我們編寫強大程式時信手拈來。

生成程式碼的程式碼,聽起來有點拗口,但你不久就會看到它是如何組織起 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將這個理念貫徹的如此之深,遠超你的想象。

正如一個遊樂場,你總是從一小塊地方開始,然後以你的方式不斷探索新的領域。超程式設計會是較難理解掌握的,對它的運用也需要考慮更高階的問題。貫穿本書,我們會通過大量的簡單練習,初步揭開神祕面紗,最終掌握高階的程式碼生成技術。在開始編寫程式碼前,我們先回顧下 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 在每一步捕獲結果後展開一次。注意開啟 iex 要在 unless.exs 檔案相同目錄中,輸入下面表示式:

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_fully = Macro.expand_once(expanded_once, __ENV__)
{:case, [optimize_boolean: true],
[{:!, [context: ControlFlow, import: Kernel],
[{:==, [context: Elixir, import: Kernel], [2, 5]}]},
[do: [{:->, [],
[[{:when, [],
[{:x, [counter: 4], Kernel},
{:in, [context: Kernel, import: Kernel],
[{:x, [counter: 4], Kernel}, [false, nil]]}]}], nil]},
{:->, [], [[{:_, [], Kernel}], "block entered"]}]]]}

第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],
[{:==, [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 程