檢視原始碼 巨集

儘管 Elixir 盡力為巨集提供安全的環境,但撰寫乾淨的巨集程式碼大部分的責任仍落在開發人員身上。巨集比一般的 Elixir 函式難寫,而且在不需要時使用巨集被認為是不好的風格。負責任地撰寫巨集。

Elixir 已經提供機制,透過使用其資料結構和函式,以簡單易讀的方式撰寫日常程式碼。巨集只應作為最後的手段。請記住,明確優於含蓄清晰的程式碼優於簡潔的程式碼。

我們的第一个巨集

Elixir 中的巨集是透過 defmacro/2 定義的。

在這個指南中,我們將使用檔案,而不是在 IEx 中執行程式碼範例。這是因為程式碼範例將跨越多行程式碼,而且在 IEx 中輸入所有程式碼可能會適得其反。您應該能夠透過將程式碼範例儲存到 macros.exs 檔案中,並使用 elixir macros.exsiex macros.exs 執行它來執行程式碼範例。

為了更了解巨集如何運作,讓我們建立一個新的模組,我們將在其中實作 unless(與 if/2 相反),作為巨集和函式

defmodule Unless do
  def fun_unless(clause, do: expression) do
    if(!clause, do: expression)
  end

  defmacro macro_unless(clause, do: expression) do
    quote do
      if(!unquote(clause), do: unquote(expression))
    end
  end
end

函式接收引數,並將它們傳遞給 if/2。但是,正如我們在 前一個指南 中所學到的,巨集將接收引用的表達式,將它們注入到引用中,最後傳回另一個引用的表達式。

讓我們用上述模組啟動 iex

$ iex macros.exs

並使用這些定義

iex> require Unless
iex> Unless.macro_unless(true, do: IO.puts "this should never be printed")
nil
iex> Unless.fun_unless(true, do: IO.puts "this should never be printed")
"this should never be printed"
nil

在我們的巨集實作中,句子並未列印,儘管它在我們的函式實作中已列印。這是因為函式呼叫的引數會在呼叫函式之前評估。然而,巨集不會評估其引數。相反地,它們會接收引數作為引號表達式,然後轉換為其他引號表達式。在這個案例中,我們已改寫我們的 unless 巨集,使其在幕後成為 if/2

換句話說,當呼叫為

Unless.macro_unless(true, do: IO.puts "this should never be printed")

我們的 macro_unless 巨集收到了下列內容

macro_unless(true, [do: {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], ["this should never be printed"]}])

然後它回傳引號表達式,如下所示

{:if, [],
 [{:!, [], [true]},
  [do: {{:., [],
     [{:__aliases__,
       [], [:IO]},
      :puts]}, [], ["this should never be printed"]}]]}

我們實際上可以使用 Macro.expand_once/2 來驗證這是正確的

iex> expr = quote do: Unless.macro_unless(true, do: IO.puts("this should never be printed"))
iex> res  = Macro.expand_once(expr, __ENV__)
iex> IO.puts(Macro.to_string(res))
if(!true) do
  IO.puts("this should never be printed")
end
:ok

Macro.expand_once/2 接收引號表達式,並根據目前的環境來擴充它。在這個案例中,它擴充/呼叫 Unless.macro_unless/2 巨集,並回傳其結果。然後我們繼續將回傳的引號表達式轉換為字串並列印它(我們將在本章稍後討論 __ENV__)。

這就是巨集的全部內容。它們接收引號表達式,並將它們轉換為其他東西。事實上,Elixir 中的 unless/2 是以巨集實作的

defmacro unless(clause, do: expression) do
  quote do
    if(!unquote(clause), do: unquote(expression))
  end
end

例如 unless/2defmacro/2def/2defprotocol/2 等結構,以及在整個 Elixir 標準函式庫中使用的許多其他結構,都是以純 Elixir 編寫的,通常是作為巨集。這表示用於建構語言的結構可以用於開發人員將語言擴充到他們正在處理的網域。

我們可以定義我們想要的任何函式和巨集,包括覆寫 Elixir 提供的內建定義。唯一的例外是 Elixir 特殊形式,它並非在 Elixir 中實作,因此無法覆寫。完整特殊形式清單可在 Kernel.SpecialForms 中取得。

巨集衛生

Elixir 巨集具有「延遲解析」。這保證在引號中定義的變數不會與在巨集擴充的內容中定義的變數衝突。例如

defmodule Hygiene do
  defmacro no_interference do
    quote do: a = 1
  end
end

defmodule HygieneTest do
  def go do
    require Hygiene
    a = 13
    Hygiene.no_interference()
    a
  end
end

HygieneTest.go()
# => 13

在上面的範例中,即使巨集注入 a = 1,它也不會影響 go/0 函式定義的變數 a。如果巨集想要明確影響內容,它可以使用 var!/1

defmodule Hygiene do
  defmacro interference do
    quote do: var!(a) = 1
  end
end

defmodule HygieneTest do
  def go do
    require Hygiene
    a = 13
    Hygiene.interference()
    a
  end
end

HygieneTest.go()
# => 1

上面的程式碼會執行,但會發出警告:變數「a」未用。巨集覆寫了原始值,而原始值從未被使用。

變數衛生只適用於 Elixir 會使用 context 標記變數的情況。例如,模組第 3 行定義的變數 x 會表示為

{:x, [line: 3], nil}

然而,引號變數會表示為

defmodule Sample do
  def quoted do
    quote do: x
  end
end

Sample.quoted() #=> {:x, [line: 3], Sample}

請注意,引號變數中的第三個元素是原子 Sample,而不是 nil,這表示變數來自 Sample 模組。因此,Elixir 會將這兩個變數視為來自不同的 context,並適當地處理它們。

Elixir 也為匯入和別名提供類似的機制。這可確保巨集會按照其來源模組所指定的方式運作,而不是與巨集展開的目標模組衝突。在特定情況下,可以使用 var!/2alias!/1 等巨集繞過衛生,不過使用這些巨集時必須小心,因為它們會直接變更使用者環境。

有時變數名稱可能會動態建立。在這種情況下,可以使用 Macro.var/2 定義新變數

defmodule Sample do
  defmacro initialize_to_char_count(variables) do
    Enum.map(variables, fn name ->
      var = Macro.var(name, nil)
      length = name |> Atom.to_string() |> String.length()

      quote do
        unquote(var) = unquote(length)
      end
    end)
  end

  def run do
    initialize_to_char_count([:red, :green, :yellow])
    [red, green, yellow]
  end
end

> Sample.run() #=> [3, 5, 6]

請注意 Macro.var/2 的第二個引數。這是正在使用的 context,它會決定衛生,如下一節所述。當您需要產生具有唯一名稱的變數時,也可以查看 Macro.unique_var/2

環境

在本章節前面呼叫 Macro.expand_once/2 時,我們使用了特殊形式 __ENV__/0

__ENV__/0 會傳回一個 Macro.Env 結構,其中包含有關編譯環境的有用資訊,包括目前的模組、檔案和行,目前範圍內定義的所有變數,以及匯入、需求等

iex> __ENV__.module
nil
iex> __ENV__.file
"iex"
iex> __ENV__.requires
[IEx.Helpers, Kernel, Kernel.Typespec]
iex> require Integer
nil
iex> __ENV__.requires
[IEx.Helpers, Integer, Kernel, Kernel.Typespec]

Macro 模組中的許多函式都預期會有一個 Macro.Env 環境。您可以在 Macro 中進一步瞭解這些函式,並在 Macro.Env 中進一步瞭解編譯環境。

私有巨集

Elixir 也支援透過 defmacrop 來建立私有巨集。就像私有函式一樣,這些巨集只在定義它們的模組中可用,而且只在編譯時可用。

巨集在使用前定義很重要。如果在呼叫巨集之前沒有定義它,會在執行時引發錯誤,因為巨集不會展開,而且會轉換成函式呼叫

iex> defmodule Sample do
...>  def four, do: two + two
...>  defmacrop two, do: 2
...> end
** (CompileError) iex:2: function two/0 undefined

負責任地撰寫巨集

巨集是一種強大的建構,而 Elixir 提供許多機制來確保負責任地使用它們。

  • 巨集是衛生的:預設情況下,在巨集中定義的變數不會影響使用者程式碼。此外,巨集內容中可用的函式呼叫和別名不會洩漏到使用者內容中。

  • 巨集是字彙的:無法在全域注入程式碼或巨集。若要使用巨集,您需要明確地 requireimport 定義巨集的模組。

  • 巨集是明確的:無法在未明確呼叫巨集的情況下執行巨集。例如,有些語言允許開發人員在幕後完全改寫函式,通常透過剖析轉換或透過一些反射機制。在 Elixir 中,必須在編譯時在呼叫者中明確呼叫巨集。

  • 巨集的語言很清楚:許多語言提供 quoteunquote 的語法捷徑。在 Elixir 中,我們偏好明確地拼寫它們,以便清楚地界定巨集定義及其引號表達式的界線。

即使有這些保證,開發人員在負責任地撰寫巨集時仍扮演著重要的角色。如果您確信需要使用巨集,請記住巨集不是您的 API。讓您的巨集定義簡短,包括它們的引號內容。例如,不要撰寫像這樣的巨集

defmodule MyModule do
  defmacro my_macro(a, b, c) do
    quote do
      do_this(unquote(a))
      # ...
      do_that(unquote(b))
      # ...
      and_that(unquote(c))
    end
  end
end

撰寫

defmodule MyModule do
  defmacro my_macro(a, b, c) do
    quote do
      # Keep what you need to do here to a minimum
      # and move everything else to a function
      MyModule.do_this_that_and_that(unquote(a), unquote(b), unquote(c))
    end
  end

  def do_this_that_and_that(a, b, c) do
    do_this(a)
    ...
    do_that(b)
    ...
    and_that(c)
  end
end

這會讓您的程式碼更清楚、更容易測試和維護,因為您可以直接呼叫和測試 do_this_that_and_that/3。它還能協助您為不希望依賴巨集的開發人員設計實際的 API。

有了這份指南,我們完成了巨集的介紹。下一份指南將簡要討論 DSL,說明我們如何混合巨集和模組屬性來註解和延伸模組和函式。