查看源代码 元编程反模式
本文档概述了与元编程相关的潜在反模式。
大量代码生成
问题
此反模式与生成过多代码的宏相关。当宏生成大量代码时,会影响编译器和/或运行时的工作方式。原因是 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/1
、alias/1
和 use/1
等机制来建立模块之间的依赖关系。使用这些机制实现的代码本身并不代表代码异味。但是,虽然 import/1
和 alias/1
指令具有词法范围,并且只允许一个模块调用另一个模块的函数,但 use/1
指令具有更广的范围,这可能成为问题。
use/1
指令允许一个模块将任何类型的代码注入另一个模块,包括传播依赖关系。这样,使用 use/1
指令会使代码更难阅读,因为要准确了解引用模块时会发生什么,需要了解被引用模块的内部细节。
示例
下面显示的代码是此反模式的一个示例。它定义了三个模块——ModuleA
、Library
和 ClientApp
。 ClientApp
通过 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/1
或 import/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.