Elixir中的元编程(附代码示例)

383 阅读8分钟

通常情况下,我们认为程序是操纵数据以达到某种结果的东西。

但什么是数据?

我们可以把程序本身作为数据吗?🤔

在今天的文章中,我们将在Elixir的协助下进入兔子洞,这是一种渗透着元编程的编程语言。

我将向你介绍Elixir中的元编程,并向你展示如何创建一个用于定义咖喱函数的宏。

什么是元编程?

元编程就是编写操纵程序的程序。这是一个广泛的术语,可以包括编译器、解释器和其他种类的程序。

在这篇文章中,我们将专注于元编程,因为它是在Elixir中完成的,这涉及到宏和编译时代码生成。

Elixir中的元编程

要理解元编程在Elixir中是如何工作的,重要的是要理解关于编译器如何工作的一些事情。在编译过程中,每个计算机程序都被转化为抽象语法树(AST)--一种使计算机能够理解程序内容的树状结构。

AST

快速的数学运算。

在Elixir中,AST的每个节点(基本值除外)是一个由三部分组成的元组:函数名、元数据、函数参数。

Elixir使我们能够通过quote 来访问这个内部的AST表示。

iex(1)> quote do 2 + 2 - 1 end
{:-, [context: Elixir, import: Kernel],
 [{:+, [context: Elixir, import: Kernel], [2, 2]}, 1]}

我们可以用宏来修改这些AST,宏是在编译时执行的从AST到AST的函数。

你可以使用宏来生成模板代码,创建新的语言功能,甚至构建特定领域的语言(DSL)。

实际上,我们在Elixir中经常使用的很多语言结构,如def,defmodule,if 等,都是宏。此外,许多流行的库,如PhoenixEctoAbsinthe,都大量使用宏来创造方便的开发者体验。

下面是文档中的一个Ecto查询实例:

query = from u in "users",
          where: u.age > 18,
          select: u.name

Elixir中的元编程是一个强大的工具。它在表现力上接近LISP(OG元编程的滚筒),但在抽象性上又比它高一个层次,使你在需要的时候才深入到AST。换句话说,Elixir基本上是LISP,但是可读。🙃

开始使用

那么,我们如何引导这种巨大的力量呢?🧙

虽然元编程可能相当棘手,但在Elixir中开始元编程却相当简单。你只需要知道三件事。

quoteunquotedefmacro
代码 -> AST外引号 -> 内引号AST -> AST

引用

quote将Elixir代码转换为其内部AST表示。

你可以把正则表达式和引号表达式的区别看成是两个不同请求的区别:

  • 请说出你的名字。这里的请求是用你的名字来回答。
  • 请说 "你的名字"。这里,请求是用语言中的请求的内部表示法来回复--"你的名字"。
iex(1)> 2 + 2
4
iex(2)> quote do 2 + 2 end
{:+, [context: Elixir, import: Kernel], [2, 2]}

quote 使得编写Elixir宏变得轻而易举,因为我们不需要手工生成或编写AST。

取消引号

但是,如果我们想访问quote 里面的变量怎么办?解决方案是 unquote.

unquote 的功能类似于字符串插值,使你能够将变量从周围的环境中拉入引号块。

下面是它在Elixir中的样子:

iex(1)> two = 2
2
iex(2)> quote do 2 + 2 end
{:+, [context: Elixir, import: Kernel], [2, 2]}
iex(3)> quote do two + two end
{:+, [context: Elixir, import: Kernel],
 [{:two, [], Elixir}, {:two, [], Elixir}]}
iex(4)> quote do unquote(two) + unquote(two) end
{:+, [context: Elixir, import: Kernel], [2, 2]}

如果我们不取消引号two ,我们将得到Elixir的内部表示,即一些未分配的变量,称为2。如果我们取消引用,我们就可以访问引用块中的变量。

defmacro

宏是指从AST到AST的函数。

例如,假设我们想做一个新的表达式类型来检查数字的奇异性。

我们可以用defmacro,quote, 和unquote 为它制作一个宏,只需几行:

defmodule My do
  defmacro odd(number, do: do_clause, else: else_clause) do
    quote do
      if rem(unquote(number), 2) == 1, do: unquote(do_clause), else: unquote(else_clause)
    end
  end
end
iex(1)> require My
My
iex(2)> My.odd 5, do: "is odd", else: "is not odd"
"is odd"
iex(3)> My.odd 6, do: "is odd", else: "is not odd"
"is not odd"

什么时候应该使用元编程?

"规则1:不要写宏" - Chris McCord, Elixir中的元编程

虽然元编程可以是一个很棒的工具,但是应该谨慎使用。

宏可能会增加调试的难度,并增加整体的复杂性。只有在必要的时候才应该使用它们--当你遇到常规函数无法解决的问题时,或者有很多幕后的管道需要隐藏时。

不过,如果使用得当,它们可以带来很大的收获。为了了解它们如何改善开发者的生活,让我们看看Phoenix(主要的Elixir网络框架)的一些真实例子。

Phoenix中如何使用宏

在下面的章节中,我们将分析一个新做的Phoenix项目的路由器子模块,作为Elixir中如何使用宏的例子。

使用

如果你看一下基本上任何Phoenix文件的顶部,你很可能会看到一个use 宏。我们的路由器子模块就有一个:

defmodule HelloWeb.Router do
  use HelloWeb, :router

这个宏的扩展内容是

require HelloWeb
HelloWeb.__using__(:router)

require 要求 编译它的宏,以便它们可以用于该模块。但什么是 ?它,你可能已经猜到了,是另一个宏!HelloWeb using

  defmacro __using__(which) when is_atom(which) do
    apply(__MODULE__, which, [])
  end 

在我们的例子中,这个宏调用了HelloWeb 模块中的router 函数。

  def router do
    quote do
      use Phoenix.Router

      import Plug.Conn
      import Phoenix.Controller
    end
  end

router 导入两个模块并启动另一个 宏。__using__

正如你所看到的,这隐藏了很多东西,这可能是一件好事,也可能是一件坏事。但它也给我们提供了一个神奇的use HelloWeb, :router ,让我们在需要的时候为快速的webdev行动准备好一切。

管线

现在,看看下面的use

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

是的,更多的宏。

pipeline 和 ,定义了插头的管道,这是改造连接数据结构的函数的函数。plug

前面的宏用于方便的单行导入,而这个宏则有助于用非常清晰自然的语言来写管道。

范围

当然,路由表也是一个宏:

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index

  end

scope,pipe_through,get - 所有的宏。

事实上,整个模块都是宏和一个if语句(也是一个宏),它添加了一个导入语句并执行一个宏。

我希望这能帮助你看到元编程是如何成为Elixir的核心的。

现在,让我们试着建立我们自己的宏。

教程:建立你自己的Elixir宏

Elixir和currying并不是真正的共生关系。但是,通过一些努力,你可以在Elixir中创建一个咖喱函数。

这是一个普通的Elixir求和函数:

def sum(a, b), do: a + b

这是一个诅咒式求和函数:

def sum() do
  fn x ->
    fn y ->
      x + y
    end
  end
end

以下是它们的表现:

iex(1)> Example.sum(1,2)
3
iex(2)> plustwo = Example.sum.(2)
#Function<10.76762873/1 in Example.sum/0>
iex(3)> plustwo.(2)
4

假设我们出于某种原因想在Elixir中使用curried函数(例如,我们想创建一个单体库)。这样写出我们代码中的每一个函数,至少可以说是不方便的。

但是借助元编程的力量,我们可以引入咖喱函数而不需要大量的模板。让我们定义我们自己的defc 宏,它将为我们定义咖喱函数。

首先,我们需要看一下一个普通的def 是如何作为一个AST的:

iex(1)> quote do def sum(a, b), do: a + b end
{:def, [context: Elixir, import: Kernel],
 [
   {:sum, [context: Elixir], [{:a, [], Elixir}, {:b, [], Elixir}]},
   [
     do: {:+, [context: Elixir, import: Kernel],
      [{:a, [], Elixir}, {:b, [], Elixir}]}
   ]
 ]}

它是一个有两个参数的宏:函数定义(在这个例子中,sum 被定义)和一个do:表达式。

因此,我们的defc (应该接受相同的数据)将是一个接受两个东西的宏:

  1. 一个函数定义,它由函数名、上下文和提供的参数组成。
  2. 一个do: 表达式,由应该对这些参数进行的所有操作组成。
defmodule Curry do
  defmacro defc({name, ctx, arguments} = clause, do: expression) do
  end
end

我们希望这个宏能够定义两个函数:

  1. defc 中定义的函数。
  2. 一个0参数的函数,它返回第一个函数,并进行curried。
  defmacro defc({name, ctx, arguments} = clause, do: expression) do
    quote do
      def unquote(clause), do: unquote(expression)
      def unquote({name, ctx, []}), do: unquote(body)
    end
  end

这或多或少就是宏了。现在,我们需要生成它的主要部分,即主体。

要做到这一点,我们需要浏览整个参数列表,对于每个参数,用一个lambda包住表达式:

  defp create_fun([h | t], expression) do
    rest = create_fun(t, expression)

    quote do
      fn unquote(h) -> unquote(rest) end
    end
  end

  defp create_fun([], expression) do
    quote do
      unquote(expression)
    end
  end

然后,我们将宏中的变量body 赋值为create_fun 的结果,应用于参数和表达式:

  defmacro defc({name, ctx, arguments} = clause, do: expression) do
    body = create_fun(arguments, expression)

    quote do
      def unquote(clause), do: unquote(expression)
      def unquote({name, ctx, []}), do: unquote(body)
    end
  end

这就是了!🥳

为了尝试一下,让我们定义另一个模块,其中有一个求和函数:

defmodule Example do
  import Curry

  defc sum(a, b), do: a + b

end
iex(1)> Example.sum(2,2)
4
iex(2)> Example.sum.(2).(2)
4
iex(3)> plustwo = Example.sum.(2)
#Function<5.100981091/1 in Example.sum/0>
iex(4)> plustwo.(2)
4

你可以在这里看到完整的代码。

在我们的例子中,这个宏只提供了sum()sum(a,b) 函数。但从这里开始,我们可以很容易地扩展我们的宏来生成所有数的部分函数。

sum 的情况下,我们可以使宏生成sum()sum(a)sum(a,b) ,修改函数定义以考虑到缺少的参数。

由于这是个很好的练习,可以自己尝试,我就不破坏答案了。