Elixir元编程-第六章 能力越大,责任也越大(乐趣也越大)

Elixir元编程-第六章 能力越大,责任也越大(乐趣也越大)

我们已经揭开了 Elixir 元编程的神秘面纱。我们从基础开始一路走来。这一路,我们深入 Elixir 内部,相信同我一样,你会对语言本身的语法及习惯用法有全新的认识。稍安勿躁,我们再回顾下这些技巧和方法,跳出 Elixir 宏系统外,讨论下如何避免一些常见陷阱。遵从元编程好的一面会让你写出易编写,易维护,易扩展的代码。

何时何地才适合使用宏

Elixir 语言本身即构建在宏上,因此你可以很容易想到你所编写的每个程序库实际上都需要用到宏。当然我们不是要讨论这个。我们应该在只有常规的函数定义难以解决问题的特殊情况下才使用宏。无论何时一旦你的代码试图使用 defmacro,停下来扪心自问你真的需要用代码生成才能解决问题吗。有时代码生成必不可少,但有时我们用常规函数完全可以取代宏。

某些情况下判断是否选择宏相对容易。比如程序中的分支语句,这需要访问 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 请求,它还有效地消除了一大堆需要手工编写的模板代码。这样做的代价是代码生成部分的程序会比较复杂,可好处是用宏编写代码太清晰漂亮了。

选择宏一定要在便捷性和复杂性间做好平衡。在 Phoenix 中的宏我就力求采用最简洁的方法。调用者会相信代码是最简的最快的。这是你同你的代码调用者之间的隐含约定。

最重要的元编程原则就是一定要保持简单。你要小心的在保持代码威力,易于使用,以及内部实现的复杂性之间走钢丝,力求保持平衡。接下来你会看到如何保持简单,以及那些危害要极力回避。

避开常见陷阱

工具越锋利,越容易伤到自己。在我的 Elixir 编程生涯中,我时常会回想起一些会对代码带来巨大伤害的疏忽,其实很容易避免。让我们看看有何办法让你不要陷入到自己编织的代码生成的陷阱中。

能用 import 就不要用 use

新鲜出炉的元编程新手一个最为常见的错误就是将 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!", "")

只要你在生成代码时坚持采用这个任务分发的思想,你的代码就会干净整洁,易于测试,调试也更友好。

Kernel.SpecialForms:了解身处的环境以及限制

Elixir 语言是一种超级容易扩展的语言,即便如此它也有些特例绝对不容触碰。了解这些特例是什么,它们存在的意义将更有助于你在扩展语言时划清你的界限。这也有助于你对代码在何处执行的跟踪。

Kernel.SpecialForms 模块定义了一组结构体,绝对不能修改。它们组成了语言本身的基本构成,以及包含了一些宏如 alias,case,{},<<>>等等。SpecialForms 模块还包含了一系列伪变量,其包含了编译时的环境信息。有一些变量你可能已经很熟悉了,比如 MODULEDIR。下面这些 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 的语法。

滥用有效的 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)。

让我们共建未来!

转载于:https://my.oschina.net/u/4130622/blog/3066515

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Elixir是一种运行在Erlang虚拟机上的函数式编程语言,它具有并发性、容错性和高可扩展性。学习Elixir可以帮助我们更好地理解函数式编程的思维方式,提高并发编程能力,并且为构建可靠的、高性能的分布式应用提供了一种优秀的工具。 首先,学习Elixir可以让我们更好地理解并掌握函数式编程的思维方式。函数式编程强调不可变性、纯函数和高阶函数等概念,这些概念在Elixir中得到了很好的体现。通过学习Elixir,我们可以学会如何编写函数式风格的代码,提高代码的可读性和可维护性。 其次,Elixir的并发编程特性也是学习的重点之一。Elixir提供了轻量级的原生线程——进程,以及消息传递机制来实现并发。通过学习Elixir,我们可以了解如何使用进程来实现并发编程,以及如何避免常见的并发编程问题,如死锁和竞争条件。 再者,学习Elixir还可以帮助我们构建高性能的分布式应用。由于Elixir建立在Erlang虚拟机之上,所以它继承了Erlang对于分布式应用的支持。在Elixir中,我们可以轻松地编写分布式系统,并且可以利用Erlang提供的强大的容错机制来确保系统的可靠性。 综上所述,学习Elixir对我们来说是非常有价值的。通过学习Elixir,我们可以更好地理解函数式编程的思维方式,提高并发编程能力,并且为构建可靠的、高性能的分布式应用提供了一种强大的工具。因此,学习Elixir将使我们成为更加全面和高效的程序员。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值