5 宏(Macros)
一段Elixir程序可以展現(xiàn)為一組數(shù)據(jù)結(jié)構(gòu)。本章將說明這些結(jié)構(gòu)(看起來)是怎樣的,以及如何使用它們來創(chuàng)建你自己的宏。
5.1 Elixir程序的建構(gòu)block(building block)
Elixir程序的建構(gòu)block是一個包含三個元素的元組。例如,名為 sum(1,2,3) 的函數(shù)在 Elixir 中可以被展現(xiàn)為這樣:
{ :sum, [], [1, 2, 3] }
你可以使用 quote 這個宏來獲取任意表達(dá)式的展現(xiàn)方式:
iex> quote do: sum(1, 2, 3) { :sum, [], [1, 2, 3] }
操作符同樣能被展現(xiàn)為這樣的元組:
iex> quote do: 1 + 2 {:+, [context: Elixir, import: Kernel], [1, 2]}
甚至一個元組也可以表現(xiàn)為對 {} 的一次調(diào)用:
iex> quote do: { 1, 2, 3 } { :{}, [], [1, 2, 3] }
變量也能用類似元組表現(xiàn),只是最后一個元組不再是一個list,而是atom原子:
iex> quote do: x { :x, [], Elixir }
當(dāng)我們引用(譯注:quoting,指用 quote 宏來展現(xiàn)一個表達(dá)式)更復(fù)雜的表達(dá)式時,可以看到其展現(xiàn)方式是元組組合而成,其中的元組彼此嵌套為相似的樹,其中的每個節(jié)點都是元組。
iex> quote do: sum(1, 2 + 3, 4) {:sum, [], [1, {:+, [context: Elixir, import: Kernel], [2, 3]}, 4]}
通常,上述的每個節(jié)點(即元組)會遵循下列格式:
{ tuple | atom, list, list | atom }
元組的第一個元素是是一個atom原子或類似的展現(xiàn)元組
元組的第二個元素是一個元數(shù)據(jù)list,它將存放類似節(jié)點行號的元信息
元組的第三個元素可能是一個參數(shù)列表或者一個atom原子,如果是后者,表示這個元組是展現(xiàn)一個變量
除了上面定義的節(jié)點,有5種Elixir字面量會在引用(解釋同上)時返回其本身(不是元組)。他們是:
:sum #=> Atoms 1.0 #=> Numbers [1,2] #=> Lists "binaries" #=> Strings {key, value} #=> Tuples with two elements
掌握了這些基本概念后,我們就可以準(zhǔn)備定義自己的宏了。
5.2 定義我們自己的宏
可以使用 defmacro 定義一個宏。例如,通過下面少量幾行代碼,我就可以定義一個名叫 unless 的宏,讓它起到和 if 相反的效果:
defmodule MyMacro do defmacro unless(clause, options) do quote do: if(!unquote(clause), unquote(options)) end end
類似 if , unless 需要兩個參數(shù)—— clause 和 options :
require MyMacro MyMacro.unless var, do: IO.puts "false"
這樣,既然 unless 是一個宏,它的參數(shù)就不應(yīng)當(dāng)在(unless)被調(diào)用時求值,而是應(yīng)該直接傳入字面量。比如,如果有一個這樣的調(diào)用
unless 2 + 2 == 5, do: call_function()
我們的 unless 宏將會接受到下面的信息:
unless({:==, [], [{:+, [], [2, 2]}, 5]}, { :call_function, [], [] })
那么 unless 宏就要調(diào)用 quote 來返回一個 if 語句的結(jié)構(gòu)樹,這意味著我們將 unless 轉(zhuǎn)換為 if!
引用表達(dá)式的時候一個常見的錯誤是開發(fā)者常常會忘記 unquote 那個表達(dá)式。為了理解 unquote 所做的事情,讓我們簡單的去除它看看結(jié)果:
defmacro unless(clause, options) do quote do: if(!clause, options) end
當(dāng)我們調(diào)用unless 2 + 2 == 5, do: call_function()
, 我們的unless將返回字面量
if(!clause, options)
由于clause和options這兩個變量沒有定義在當(dāng)前上下文中,執(zhí)行就會失敗。而如果我們把unquote添加回來:
defmacro unless(clause, options) do quote do: if(!unquote(clause), unquote(options)) end
unless
將會返回:
if(!(2 + 2 == 5), do: call_function())
換句話說,unquote
是一個將表達(dá)式注入到被引用的解析樹的機(jī)制,同時也是元編程的核心工具。Elixir同時還提供 unquote_splicing 來允許我們一次注入多個表達(dá)式
我們可以定義我們需要的任何宏——甚至可以覆蓋掉Elixir內(nèi)建的宏。例如,你可以重新定義 case , receive , + ... 等等。但是 Elixir 有一些特殊形式不能被覆蓋,在 Kernel.SpecialForms 中有這些形式的完整列表。
5.3 宏的安全性
Elixir宏會被延遲解析,這將保證在展開宏時,定義在quote中的變量不會與上下文中的變量定義沖突,例如:
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 函數(shù)中的a變量。在某些場景中,宏需要顯式的影響上下文(的變量),我們可以使用 var!:
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
安全的變量僅僅由于Elixir在相應(yīng)上下文中標(biāo)注了變量而正常工作。例如,變量 x 定義在模塊的第三行,展開以后就是這樣:
{ :x, [line: 3], nil }
而一個被引用的變量(代碼中未定義)展開以后就會是這樣:
defmodule Sample do def quoted do quote do: x end end Sample.quoted #=> { :x, [line: 3], Sample }
注意:在引用變量的展現(xiàn)方式里的第三個元素是一個原子 Sample ,而不是 nil ,這標(biāo)記了這個變量來自 Sample 這個module。這樣,Elixir就能根據(jù)這些信息正確處理這兩個來自不同上下文的變量。
Elixir為imports和aliases提供了相似的機(jī)制,以確保宏將與其所在的特定源碼行為一致而不是與目標(biāo)模塊沖突。
5.4 私有宏
Elixir使用 defmacrop 支持私有宏。這些宏將僅能在所定義的模塊內(nèi)部被使用,就像私有函數(shù),只不過它是在編譯時工作的。一個常見的關(guān)于私有宏的例子是定義在同一個模塊內(nèi)部被頻繁使用的 guard :
defmodule MyMacros do defmacrop is_even?(x) do quote do rem(unquote(x), 2) == 0 end end def add_even(a, b) when is_even?(a) and is_even?(b) do a + b end end
很重要的一點是:宏必須在使用之前定義。如果沒有在調(diào)用前定義宏,那么我們將收到一個運行時錯誤,因為此時宏無法被展開并轉(zhuǎn)換為函數(shù)調(diào)用:
defmodule MyMacros do def four, do: two + two defmacrop two, do: 2 end MyMacros.four #=> ** (UndefinedFunctionError) undefined function: two/0
5.5 代碼執(zhí)行
在結(jié)束關(guān)于宏的討論之前,我們將簡短的論述代碼是如何在Elixir中被執(zhí)行的。在Elixir中,代碼的完整執(zhí)行涉及兩個步驟:
1) 代碼中的所有宏將被遞歸的展開;
2) 被展開的代碼將被編譯為Erlang字節(jié)碼并被執(zhí)行
理解這些非常重要,因為這會影響我們?nèi)绾慰创覀兊拇a結(jié)構(gòu)。看看如下的代碼:
defmodule Sample do case System.get_env("FULL") do "true" -> def full?(), do: true _ -> def full?(), do: false end end
上述代碼將定義一個名為 full? 的函數(shù),它將根據(jù)編譯時的環(huán)境變量 FULL 的值返回 true 或者 false。為了執(zhí)行這段代碼,Elixir將首先展開所有的宏。而因為 defmodule 和 def 本身也是宏,代碼將被展開為類似下面的樣子:
:elixir_module.store Sample, fn -> case System.get_env("FULL") do "true" -> :elixir_def.store(Foo, :def, :full?, [], true) _ -> :elixir_def.store(Foo, :def, :full?, [], false) end
接著,代碼將被執(zhí)行,定義一個名為 Foo的模塊,并在這個模塊內(nèi)部存放一個關(guān)聯(lián)的函數(shù),這個函數(shù)基于環(huán)境變量 FULL 的值。達(dá)成這些需要使用 elixir_module 和 elixir_def 函數(shù),這兩個函數(shù)都是來自Elixir內(nèi)部模塊,本身使用erlang編寫。
這個例子中我們可以學(xué)到兩點:
1) 宏總是會被展開的,無論它所在 case 的分支是否會被執(zhí)行到;
2) 我們不能緊接著一個函數(shù)或者宏的定義之后來調(diào)用它,例如如下代碼:
defmodule Sample do def full?, do: true IO.puts full? end
這段代碼將會失敗,因為它會被轉(zhuǎn)換成這樣:
:elixir_module.store Sample, fn -> :elixir_def.store(Foo, :def, :full?, [], true) IO.puts full? end
此時,模塊正在被定義,(因而)還沒有一個名為 full? 的函數(shù)被定義在模塊中,這樣, IO.puts full? 調(diào)用就會遇到編譯失敗。
5.6 避免使用宏Don't write macros
考慮到宏是一個很強(qiáng)大的編程結(jié)構(gòu),在這個領(lǐng)域的第一條原則是——避免使用宏。相比于普通的Elixir函數(shù),宏的編寫是比較難的,在不必要的時候使用宏被認(rèn)為是一個不好的風(fēng)格(bad style)。Elixir已經(jīng)提供了很多優(yōu)雅的機(jī)制幫助你日常的編碼工作,宏應(yīng)當(dāng)被作為最后手段。
通過上述課程,我們結(jié)束了對宏的介紹。接下來,讓我們進(jìn)入下一章,討論代碼文檔、非完整應(yīng)用(partial application)和一些其它話題。