咱們已經揭開了 Elixir 元編程的神祕面紗。咱們從基礎開始一路走來。這一路,咱們深刻 Elixir 內部,相信同我同樣,你會對語言自己的語法及習慣用法有全新的認識。稍安勿躁,咱們再回顧下這些技巧和方法,跳出 Elixir 宏系統外,討論下如何避免一些常見陷阱。聽從元編程好的一面會讓你寫出易編寫,易維護,易擴展的代碼。html
Elixir 語言自己即構建在宏上,所以你能夠很容易想到你所編寫的每一個程序庫實際上都須要用到宏。固然咱們不是要討論這個。咱們應該在只有常規的函數定義難以解決問題的特殊狀況下才使用宏。不管什麼時候一旦你的代碼試圖使用 defmacro,停下來捫心自問你真的須要用代碼生成才能解決問題嗎。有時代碼生成必不可少,但有時咱們用常規函數徹底能夠取代宏。web
某些狀況下判斷是否選擇宏相對容易。好比程序中的分支語句,這須要訪問 AST 表達式,所以宏必不可少。試試看在 if 語句的實現中咱們能不能用函數替代宏,就像咱們前面用宏實現的那樣。面試
iex> defmodule ControlFlow do ...> def if(expr, do: block, else: else_block) do ...> case expr do ...> result when result in [nil, false] -> else_block ...> result -> block ...> end ...> end ...> end {:module, ControlFlow, <<70, 79, 82, 49, 0, 0, 5, 120, 66, 69, 65, 77, 69, 120, 68, ... iex> ControlFlow.if true do ...> IO.puts "It's true!" ...> else ...> IO.puts "It's false!" ...> end It's true! It's false
出了啥事?兩條 IO.puts 語句都執行了,由於在運行時咱們將其做爲參數傳遞給了 if 函數。在這裏咱們就只能使用宏,只有宏才能在編譯時將表達式轉換成 case 語句,才能避免在運行時傳入的兩個子句都被運行。有時判斷是否選擇宏就沒那麼明顯了。數據庫
在建立 Phoenix (一個 Elixir web 框架)程序時,我使用了宏來表述 router 層。這裏 Phoenix router 中的宏幹了兩件事。一是提供了一套簡單好用的 routing DSL。二是它在內部建立了不少子句,免去了用戶手工編寫的麻煩。咱們從更高的角度來看下 router 生成器生成的一些代碼。而後咱們討論下對宏的利弊權衡。編程
這裏是一個最小化的 Phoenix router,它會將請求路由至 controller 模塊:性能優化
defmodule MyRouter do use Phoenix.Router pipeline :browser do plug :accepts, ~w(html) plug :fetch_session end scope "/" do pipe_through :browser get "/pages", PageController, :index get "/pages/:page", PageController, :show resources "/users", UserController do resources "/comments", CommentController end end end
在 MyRouter 編譯完成後,Phoenix 會在模塊中生成以下的函數頭:服務器
defmodule MyRouter do ... def match(conn, "GET", ["pages"]) def match(conn, "GET", ["pages", page]) def match(conn, "GET", ["users", "new"]) def match(conn, "POST", ["users"]) def match(conn, "PUT", ["users", id]) def match(conn, "PATCH", ["users", id]) def match(conn, "DELETE",["users", id]) def match(conn, "GET", ["users", user_id, "comments"]) def match(conn, "GET", ["users", user_id, "comments", id, "edit"]) def match(conn, "GET", ["users", user_id, "comments", id]) def match(conn, "GET", ["users", user_id, "comments", "new"]) def match(conn, "POST", ["users", user_id, "comments"]) def match(conn, "PUT", ["users", user_id, "comments", id]) def match(conn, "PATCH", ["users", user_id, "comments", id]) def match(conn, "DELETE",["users", user_id, "comments", id]) end
Phoenix router 使用 get,post,resources 宏將 HTTP DSL 轉化成一系列的 match/3 函數定義。我選擇使用宏來實現 Phoenix 的 router,是通過反覆權衡的,routing DSL 不光是提供了一套高階的 API 用來路由 HTTP 請求,它還有效地消除了一大堆須要手工編寫的模板代碼。這樣作的代價是代碼生成部分的程序會比較複雜,可好處是用宏編寫代碼太清晰漂亮了。session
選擇宏必定要在便捷性和複雜性間作好平衡。在 Phoenix 中的宏我就力求採用最簡潔的方法。調用者會相信代碼是最簡的最快的。這是你同你的代碼調用者之間的隱含約定。框架
最重要的元編程原則就是必定要保持簡單。你要當心的在保持代碼威力,易於使用,以及內部實現的複雜性之間走鋼絲,力求保持平衡。接下來你會看到如何保持簡單,以及那些危害要極力迴避。函數
工具越鋒利,越容易傷到本身。在個人 Elixir 編程生涯中,我時常會回想起一些會對代碼帶來巨大傷害的疏忽,其實很容易避免。讓咱們看看有何辦法讓你不要陷入到本身編織的代碼生成的陷阱中。
新鮮出爐的元編程新手一個最爲常見的錯誤就是將 use 當成一種從其餘模塊 mix in 混入函數的方法。這種想法可能來自於其餘語言,在其餘語言中能夠經過mix-in的方式將方法和函數從一個模塊導入到另外一個模塊中,他們也認爲理應如此。在 Elixir 中,看上去彷佛還真像那麼回事,但這是陷阱啊。
這裏有一個 StringTransforms 模塊,定義了一大堆字符串轉換函數。你可能會指望在模塊間共享這些函數,所以可能會以下編碼:
defmodule StringTransforms do defmacro __using__(_opts) do quote do def title_case(str) do str |> String.split(" ") |> Enum.map(fn <<first::utf8, rest::binary>> -> String.upcase(List.to_string([first])) <> rest end) |> Enum.join(" ") end def dash_case(str) do str |> String.downcase |> String.replace(~r/[^\w]/, "-") end # ... hundreds of more lines of string transform functions end end end defmodule User do use StringTransforms def friendly_id(user) do dash_case(user.name) end end iex> User.friendly_id(%{name: "Elixir Lang"}) "elixir-lang
第2行,經過 using 宏定義來容納 title_case 以及 dash_case 等字符串轉換函數的 quoted 表達式。在第24行,User 模塊中經過 use StringTransforms 將這些函數注入到當前上下文。第27行,在 friendly_id 函數內部就能夠調用 dash_case 了。運行正常,但錯的離譜。
這裏,咱們濫用了 use 來將 title_case, dash_case 等函數注入到另外一個函數。它確實能工做,但咱們根本不須要注入代碼。Elixir 的 import 已經提供了全部的功能。咱們刪除全部代碼生成部分,重構 StringTransforms:
defmodule StringTransforms do def title_case(str) do str |> String.split(" ") |> Enum.map(fn <<first::utf8, rest::binary>> -> String.upcase(List.to_string([first])) <> rest end) |> Enum.join(" ") end def dash_case(str) do str |> String.downcase |> String.replace(~r/[^\w]/, "-") end # ... end defmodule User do import StringTransforms def friendly_id(user) do dash_case(user.name) end end iex> User.friendly_id(%{name: "Elixir Lang"}) "elixir-lang"
咱們刪除了 using 塊,在 User 模塊中使用 import 來共享函數。import 提供了前一版本的所有功能,而咱們只須要在 StringTransforms 模塊中定義常規函數就好了。若是僅僅是爲了混入函數功能,咱們絕對不要使用 use 宏。import 方式就能夠達到這個目的,並且無需生成代碼。即使是在確實須要用 use 生成代碼的狀況下,也應該控制好只注入必須的代碼,其他部分仍是要採用 import 普通函數的方式。
不少人犯的一個常見錯誤就是讓代碼生成作了太多太多的東西。你應該仔細衡量事物的兩面性,你應該知道使用宏是爲了解決問題。這個錯誤在於你可能會無限榨取 quote 代碼塊,甚至往裏面注入了幾百行的代碼。這會使你的代碼碎片化,徹底沒法調試。不管什麼時候注入代碼,你都應該儘量地將任務轉派到調用者上下文的外部去執行。經過這種方式,你的程序庫代碼封閉在你的程序庫中,只注入很小的一部分基礎代碼,用來將調用者上下文外部的調用引入到程序庫中。
爲便於理解,咱們回想下在「是否選擇 DSL」一章中提到的 email 程序庫。儘管它不是一個很好的 DSL 樣板,咱們仍是假設下如何經過一個宏擴展庫來實現它。這個程序庫須要將 send_email 函數注入到調用者的模塊中,而後這個函數被定義成發送各類不一樣類型的消息。send_mail 函數會使用 email 使用者的配置信息來鏈接郵件服務器。咱們隨時都會用到這個信息,你首先必須在 use 代碼塊中傳遞這個參數。
defmodule Emailer do defmacro __using__(config) do quote do def send_email(to, from, subject, body) do host = Dict.fetch!(unquote(config), :host) user = Dict.fetch!(unquote(config), :username) pass = Dict.fetch!(unquote(config), :password) :gen_smtp_client.send({to, [from], subject}, [ relay: host, username: user, password: pass ]) end end end end
在一個客戶端的 MyMailer 模塊中咱們如何使用這個庫呢:
defmodule MyMailer do use Emailer, username: "myusername", password: "mypassword", host: "smtp.example.com" def send_welcome_email(user) do send_email user.email, "support@example.com", "Welcome!", """ Welcome aboard! Thanks for signing up... """ end end
初看上去,代碼還不錯。你將 send_mail 注入到了調用者的模塊中,內容不過是幾行手工代碼。可是你又掉到陷阱裏了。這裏的問題是,你將配置文件的註冊信息保存下來,並且直接在注入代碼中將明細信息發給了一個 email。這會致使你的實現細節都泄露給了外部調用你模塊的全部人。這會使你的程序更難測試。
讓咱們改寫庫,在調用者上下文之外轉派任務實現郵件發送:
defmodule Emailer do defmacro __using__(config) do quote do def send_email(to, from, subject, body) do Emailer.send_email(unquote(config), to, from, subect, body) end end end def send_email(config, to, from, subject, body) do host = Dict.fetch!(config, :host) user = Dict.fetch!(config, :username) pass = Dict.fetch!(config, :password) :gen_smtp_client.send({to, [from], subject}, [ relay: host, username: user, password: pass ]) end end
注意一下咱們是如何推送全部的業務邏輯,以及又是如何將發送郵件的任務發回給 Emailer 模塊的?注入的 send_email/4 函數當即將任務轉派出去,並將調用者的配置做爲參數單獨傳給它。這裏微妙的差異就在於咱們的實現變成了在庫模塊中定義的普通函數。你的對外 API 徹底不變,可是如今你徹底能夠直接測試你的 Emailer.send_email/5 函數了。另一個好處就是如今堆棧跟蹤只會跟蹤到你的 Emailer 模塊,而不會是跟蹤到調用者模塊中那堆讓人費解的生成代碼。
這個修改也讓庫的調用更直接,無需在另一個模塊中使用了。這樣對測試很是友好,對僅僅只是想快速發送個郵件的調用者也更爲友好。如今發送郵件簡單到,無非就是調用 Emailer.send_email 函數而已:
[username: "myusername", password: "mypassword", host: "smtp.example.com"] |> Emailer.send_email("you@example.com", "me@example.com", "Hi!", "")
只要你在生成代碼時堅持採用這個任務分發的思想,你的代碼就會乾淨整潔,易於測試,調試也更友好。
Elixir 語言是一種超級容易擴展的語言,即使如此它也有些特例絕對不容觸碰。瞭解這些特例是什麼,它們存在的意義將更有助於你在擴展語言時劃清你的界限。這也有助於你對代碼在何處執行的跟蹤。
Kernel.SpecialForms 模塊定義了一組結構體,絕對不能修改。它們組成了語言自己的基本構成,以及包含了一些宏如 alias,case,{},<<>>等等。SpecialForms 模塊還包含了一系列僞變量,其包含了編譯時的環境信息。有一些變量你可能已經很熟悉了,好比 MODULE 和 DIR。下面這些 SpecialForms 定義的僞變量不能被重綁定或是覆蓋:
__ENV__
:返回一個 Macro.ENV 結構體,包含當前環境信息__MODULE__
:返回當前模塊名稱,類型爲 atom,等價於 __ENV__.module
__DIR__
:返回當前目錄__CALLER__
:返回調用者環境信息,類型爲 Macro.ENV 結構體__ENV__
變量在任什麼時候候均可以訪問,__CALLER__
只能在宏內部調用,用來返回調用者環境。這些變量通常都在元編程時使用。前面幾張學過的__before_compile__
鉤子,就只接受__ENV__
結構做爲惟一參數。在註冊鉤子時能夠提供重要的環境信息。
咱們在 iex 裏面看看__ENV__
結構,以及它包含的各類信息:
iex(1)> __ENV__.file "iex" iex(2)> __ENV__.line 2 iex(3)> __ENV__.vars [] iex(4)> name = "Elixir" "Elixir" iex(5)> version = "~> 1.0" "~> 1.0" iex(6)> __ENV__.vars [name: nil, version: nil] iex(7)> binding [name: "Elixir", version: "~> 1.0"]
在 iex 裏面你都能看到,Elixir會跟蹤環境所在文件以及行號。在程序代碼中,這裏就會是代碼所在的文件及行號。這在堆棧跟蹤以及一些特定的錯誤處理中頗有用,由於你能夠在程序的任何地方訪問調用者的環境信息。你還會看到Elixir跟蹤當前環境的綁定變量,這經過__ENV__.vars
訪問。要注意這不一樣於 binding 宏,這個宏是返回全部的綁定變量跟他們的值,而 vars 是跟蹤變量上下文。這是由於變量值在運行時是動態變化的,所以環境變量只能跟蹤哪一個變量被綁定了,以及在那綁定的。
Elixir 中還有一小部分是不能觸碰的,只是一些特殊格式以及環境上下文。面對這些不斷延伸的領域,咱們已經能看到種種陷阱埋伏。但做爲一個元編程的有爲青年,咱們應該知道什麼時候該盡己所能,將元編程推向極限。
例行官方警告完畢。咱們回想下咱們說過 Elixir 將程序世界變成一個遊樂場。規則就是用來打破的。所以讓咱們來闖闖灰色地帶,在 Elixir 中有時濫用宏是很值得的,下面咱們來嘗試下扭曲 Elixir 的語法。
重寫 AST 來改變當前 Elixir 表達式的含義,對大多數人多是夢魘。但在某些狀況下,這是一個很是強大的工具。想一想 Elixir 的 Ecto 庫,這是一個數據庫包裹器,集成了一套查詢語言。讓咱們看看 Ecto 查詢長啥樣,以及它是如何濫用 Elixir 語法。你無需瞭解 Ecto;只須要可以領會下面查詢語句的意思就行:
query = from user in User, where: user.age > 21 and user.enrolled == true, select: user
Ecto 在內部會將上述徹底有效的 Elixir 表達式轉化成一個 SQL 字符串。他濫用了 in,and,==,以及 > 用來構建 SQL 表達式,這些東西本來是 Elixir 的有效表達式哦。這是對宏極其優雅的運用。Ecto 讓你可以用 Elixir 原生語法構建查詢,可以對 SQL 中的綁定變量進行適當的類型轉換。而其餘的語言中,若是要集成一套查詢語言,就必須在語言只上另外構建一套完整的新語法。使用 Elixir,咱們能夠用宏來改變常規 Elixir 代碼,使其可以很好的表現 SQL。
Ecto 是個很是龐大的項目,能夠另外寫本書了,但咱們要探討的是咱們能夠如何編寫相似的庫。咱們來分析下上面的查詢語句 quoted 後長啥樣。在 iex 中嘗試下不一樣形式,琢磨下咱們能夠用前面學到的哪些 AST 技巧來實現它,好比 Macro.postwalk。
iex> quote do ...> from user in User, ...> where: user.age > 21 and user.enrolled == true, ...> select: user ...> end {:from, [], [{:in, [context: Elixir, import: Kernel], [{:user, [], Elixir}, {:__aliases__, [alias: false], [:User]}]}, [where: {:and, [context: Elixir, import: Kernel], [{:>, [context: Elixir, import: Kernel], [{{:., [], [{:user, [], Elixir}, :age]}, [], []}, 21]}, {:==, [context: Elixir, import: Kernel], [{{:., [], [{:user, [], Elixir}, :enrolled]}, [], []}, true]}]}, select: {:user, [], Elixir}]]}
看看上面 Ecto 查詢的 AST,咱們知道利用宏能夠濫用 Elixir 語法,頗有趣,也頗有用。要匹配 AST 中不一樣的操做符,如 :in,:==,等等,咱們須要在編譯時將對應片斷解析成 SQL 表達式。宏容許將任何有效的 Elixir 表達式轉換成你想要的形式。你要慎重使用這項技術,由於賦予語言不一樣的含義將致使在不一樣語境下的理解上的困惑。可對於 Ecto 之類的庫,它不須要藉助任何語言外部的東西,僅僅在 Elixir 上構建了一個新層,這種技術正是威力巨大。
另外一個你須要扭曲元編程規則的灰色地帶就是爲了性能優化。宏可以讓你在運行時優化代碼,有時候這須要注入海量的代碼,比一般狀況下多得多。咱們在前面幾章構建 Translator 庫時就這樣幹過。咱們經過在調用者模塊中注入大量的函數頭,用編譯時的字符串拼接,替代了運行時的正則匹配,從而優化了字符串解析。爲了快速執行,咱們不得不生成了海量代碼,但爲了性能優化,引入更多的複雜性也徹底值得。若是你使用前面學到的技術組織元編程,你也可以寫出快速,清晰,易維護的代碼。
我已經見識過一些很是聰明的想法,它們採用了一些很是不負責任的宏代碼,我永遠不會把他們用到個人產品當中。最好的學習就是實踐。不要被本書貫穿始終的條條框框還有這一章描述的嚴重後果嚇到你,放開手腳大膽地探索 Elixir 的宏系統。編寫一些任性的代碼,試驗它,從中得到樂趣。用你獲得的知識來啓迪你在生產環境中作出設計上的決斷。
各類試驗性的想法多是無窮無盡的,我這有幾個瘋狂的想法刺激一下你。還記得任意 quoted 的表達式都是有效的 Elixir 代碼嗎?你能利用這一事實編寫一個天然語言測試框架嗎?
下面是有效的 Elixir 代碼:
the answer should be between 3 and 5 the list should contain 10 the user name should resemble "Max"
你不信能實現?在 iex 裏面試試 quote 這些表達式:
iex> quote do ...> the answer should be between 3 and 5 ...> the list should contain 10 ...> the user name should resemble "Max" ...> end |> Macro.to_string |> IO.puts ( the(answer(should(be(between(3 and 5))))) the(list(should(contain(10)))) the(user(name(should(resemble("Max"))))) ) :ok
你能夠解析這些天然語言聲明的 AST 格式,所以能夠在背後悄悄地將其轉換成斷言。你能寫出來嗎?也許不能。但你能從中學到更多的 Elixir 宏系統的知識,以及更多的樂趣嗎?絕對能。
下一步幹什麼呢?是時候回過頭來,構建下 Elixir 軟件開發的將來了!如今你已經有足夠的技能來錘鍊語言,編寫強力的工具同世界分享。Elixir 和 Erlang 子系統已足夠成熟(The programming landscape is ripe for disruption by the power that Elixir and the Erlang ecosystem bring to the table。看不懂就亂翻了)。走出去解決真正感興趣的問題,不過必定要記得玩的開心(have fun)。
讓咱們共建將來!