咱们已经揭开了 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)。
让咱们共建将来!