查看源代码 元编程反模式

本文档概述了与元编程相关的潜在反模式。

大量代码生成

问题

此反模式与生成过多代码的宏相关。当宏生成大量代码时,会影响编译器和/或运行时的工作方式。原因是 Elixir 可能需要多次扩展、编译和执行代码,这将导致编译速度变慢,生成的编译工件更大。

示例

假设您正在为 Web 应用程序定义一个路由器,其中您可能拥有像 get/2 这样的宏。在每次调用宏时(可能是数百次),get/2 内部的代码将被扩展和编译,这会导致总代码量很大。

defmodule Routes do
  defmacro get(route, handler) do
    quote do
      route = unquote(route)
      handler = unquote(handler)

      if not is_binary(route) do
        raise ArgumentError, "route must be a binary"
      end

      if not is_atom(handler) do
        raise ArgumentError, "handler must be a module"
      end

      @store_route_for_compilation {route, handler}
    end
  end
end

重构

为了消除此反模式,开发人员应简化宏,将部分工作委托给其他函数。如下所示,通过将 quote/1 内部的代码封装到 __define__/3 函数中,我们减少了每次调用宏时扩展和编译的代码量,而是将工作分派给函数来执行大部分工作。

defmodule Routes do
  defmacro get(route, handler) do
    quote do
      Routes.__define__(__MODULE__, unquote(route), unquote(handler))
    end
  end

  def __define__(module, route, handler) do
    if not is_binary(route) do
      raise ArgumentError, "route must be a binary"
    end

    if not is_atom(handler) do
      raise ArgumentError, "handler must be a module"
    end

    Module.put_attribute(module, :store_route_for_compilation, {route, handler})
  end
end

不必要的宏

问题

是强大的元编程机制,可以在 Elixir 中用于扩展语言。虽然使用宏本身不是反模式,但此元编程机制只应在绝对必要时使用。无论何时使用宏,但可以使用函数或其他现有 Elixir 结构解决相同问题,代码就会变得不必要地更复杂且可读性更差。由于宏更难实现和推理,因此不加区分地使用它们会损害系统的演进,降低其可维护性。

示例

MyMath 模块实现了 sum/2 宏来执行作为参数接收的两个数字的总和。虽然此代码没有语法错误,可以正确执行以获得预期结果,但它不必要地更复杂。通过将此功能实现为宏而不是传统的函数,代码变得不太清晰

defmodule MyMath do
  defmacro sum(v1, v2) do
    quote do
      unquote(v1) + unquote(v2)
    end
  end
end
iex> require MyMath
MyMath
iex> MyMath.sum(3, 5)
8
iex> MyMath.sum(3 + 1, 5 + 6)
15

重构

为了消除此反模式,开发人员必须用更易于编写和理解的结构(例如命名函数)替换不必要的宏。下面显示的代码是之前示例重构的结果。基本上,sum/2 宏已转换为传统的命名函数。请注意,不再需要 require/2 调用

defmodule MyMath do
  def sum(v1, v2) do # <= The macro became a named function
    v1 + v2
  end
end
iex> MyMath.sum(3, 5)
8
iex> MyMath.sum(3+1, 5+6)
15

use 而不是 import

问题

Elixir 具有 import/1alias/1use/1 等机制来建立模块之间的依赖关系。使用这些机制实现的代码本身并不代表代码异味。但是,虽然 import/1alias/1 指令具有词法范围,并且只允许一个模块调用另一个模块的函数,但 use/1 指令具有更广的范围,这可能成为问题。

use/1 指令允许一个模块将任何类型的代码注入另一个模块,包括传播依赖关系。这样,使用 use/1 指令会使代码更难阅读,因为要准确了解引用模块时会发生什么,需要了解被引用模块的内部细节。

示例

下面显示的代码是此反模式的一个示例。它定义了三个模块——ModuleALibraryClientAppClientApp 通过 use/1 指令重用来自 Library 的代码,但不知道其内部细节。这使得 ClientApp 的作者更难直观地了解哪些模块和功能现在在其模块中可用。更糟糕的是,Library 还导入了 ModuleA,它定义了一个 foo/0 函数,该函数与 ClientApp 中定义的本地函数冲突

defmodule ModuleA do
  def foo do
    "From Module A"
  end
end
defmodule Library do
  defmacro __using__(_opts) do
    quote do
      import Library
      import ModuleA  # <= propagating dependencies!
    end
  end

  def from_lib do
    "From Library"
  end
end
defmodule ClientApp do
  use Library

  def foo do
    "Local function from client app"
  end

  def from_client_app do
    from_lib() <> " - " <> foo()
  end
end

当我们尝试编译 ClientApp 时,Elixir 会检测到冲突并抛出以下错误

error: imported ModuleA.foo/0 conflicts with local function
  └ client_app.ex:4:

重构

为了消除此反模式,我们建议库作者在可以使用 alias/1import/1 指令的情况下,避免提供 __using__/1 回调。在以下代码中,我们假设 use Library 不再可用,并且 ClientApp 已以此方式重构,因此代码更清晰,之前显示的冲突也不再存在

defmodule ClientApp do
  import Library

  def foo do
    "Local function from client app"
  end

  def from_client_app do
    from_lib() <> " - " <> foo()
  end
end
iex> ClientApp.from_client_app()
"From Library - Local function from client app"

其他说明

在您需要执行更多操作而不是导入和别名模块的情况下,可能需要提供 use MyModule,因为它提供了 Elixir 生态系统中的一个通用扩展点。

因此,为了提供指导和清晰度,我们建议库作者在其 @moduledoc 中包含一个警告块,以解释 use MyModule 如何影响开发人员的代码。例如,GenServer 文档概述

use GenServer

当您 use GenServer 时,GenServer 模块将设置 @behaviour GenServer 并定义一个 child_spec/1 函数,因此您的模块可以用作监督树中的子模块。

将此摘要视为代码生成的 "营养成分标签"。确保只列出对模块的公共 API 所做的更改。例如,如果 use Library 设置了一个名为 @_some_module_info 的内部属性,而此属性并非公开属性,请避免在营养成分标签中记录它。

为了方便起见,用于生成上述警告块的标记表示法如下

> #### `use GenServer` {: .info}
>
> When you `use GenServer`, the `GenServer` module will
> set `@behaviour GenServer` and define a `child_spec/1`
> function, so your module can be used as a child
> in a supervision tree.